Skip to content

Python API

depkeeper is primarily a CLI. Its internals are importable and documented here for tools that need to embed dependency analysis.

Stability

Only the CLI surface is covered by a compatibility guarantee in 0.1.x. The Python API is documented but may change between minor versions. Pin the version if you depend on it.


Design rules you must follow

Rule Reason
Everything network-facing is async. All I/O is asyncio-based. Drive it with asyncio.run.
HTTPClient must be used as an async context manager. The underlying httpx.AsyncClient is created lazily and bound to the running loop.
Construct one PyPIDataStore and share it. This is what guarantees one HTTP request per package.
VersionChecker and DependencyAnalyzer require a data store. Passing None raises TypeError; they have no independent HTTP path.
Canonicalise names with normalize_package_name. Every cache, mapping and comparison in depkeeper is keyed on the PEP 503 form.
DependencyAnalyzer mutates its input. resolve_and_annotate_conflicts updates each Package in place.

End-to-end example

Python
import asyncio

from depkeeper.core import (
    DependencyAnalyzer,
    PyPIDataStore,
    RequirementsParser,
    VersionChecker,
)
from depkeeper.utils import HTTPClient


async def analyse(path: str):
    parser = RequirementsParser()
    requirements = parser.parse_file(path)          # synchronous

    async with HTTPClient() as http:
        store = PyPIDataStore(http)                 # one store, shared
        await store.prefetch_packages([r.name for r in requirements])

        checker = VersionChecker(data_store=store)
        packages = await checker.check_packages(requirements)

        analyzer = DependencyAnalyzer(data_store=store)
        result = await analyzer.resolve_and_annotate_conflicts(packages)

    return packages, result


packages, result = asyncio.run(analyse("requirements.txt"))

for pkg in packages:
    if pkg.has_update():
        print(f"{pkg.name}: {pkg.current_version} -> {pkg.recommended_version}")

print(result.summary())

Rendering an update without writing

Python
from depkeeper.core import RequirementsParser

parser = RequirementsParser()
req = parser.parse_line("celery[redis]>=5.0,<6.0  # queue", 1)

req.update_version("5.6.3")
# 'celery[redis]>=5.6.3,<6.0  # queue\n'

req.update_version("5.6.3", pin=True)
# 'celery[redis]==5.6.3  # queue\n'

Overriding network limits

Python
from depkeeper.core import PyPIDataStore
from depkeeper.utils import HTTPClient

async with HTTPClient(timeout=10, max_retries=1, max_concurrency=4,
                      rate_limit_delay=0.25) as http:
    store = PyPIDataStore(http, concurrent_limit=4)
    ...

This is the only supported way to change timeouts, retry counts or concurrency — the CLI exposes no flags for them.


Package exports

Python
from depkeeper.core import (
    RequirementsParser, PyPIDataStore, PyPIPackageData,
    VersionChecker, DependencyAnalyzer, ResolutionResult,
)
from depkeeper.models import Package, Requirement, Conflict
from depkeeper.utils import (
    HTTPClient, get_logger, setup_logging,
    normalize_package_name, get_update_type,
    retained_specs, rewrite_version_specs, specs_allow_version, specs_to_string,
    safe_read_file, safe_write_file, create_timestamped_backup,
)
from depkeeper.exceptions import (
    DepKeeperError, ParseError, ConfigError, FileOperationError, NetworkError, PyPIError,
)

Importing from depkeeper.core and depkeeper.utils rather than from the concrete modules keeps your code stable if the internal layout changes.


Core

RequirementsParser

RequirementsParser

Python
RequirementsParser()

Stateful parser for pip-style requirements files.

Maintains two pieces of internal state across multiple parse_file calls:

  1. Include stack — tracks the chain of -r directives to detect circular dependencies.
  2. Constraint map — stores all requirements loaded via -c directives; these are applied to matching package names during parsing.

Call reset to clear state before reusing the parser on an unrelated set of files.

Initialize the parser with empty state.

Source code in depkeeper/core/parser.py
Python
def __init__(self) -> None:
    """Initialize the parser with empty state."""
    self.logger = get_logger("parser")

    # Stack of files currently being parsed (guards against cycles)
    self._included_files_stack: List[Path] = []

    # Constraint requirements loaded via -c directives
    self._constraint_requirements: Dict[str, Requirement] = {}
Methods:
parse_file
Python
parse_file(
    file_path: Union[str, Path],
    is_constraint_file: bool = False,
    _parent_directory_path: Optional[Path] = None,
) -> List[Requirement]

Parse a requirements file from disk.

Reads the file at file_path, processes all directives (-r, -c, -e, --hash), and returns a flat list of Requirement objects. If file_path is relative and _parent_directory_path is provided (internal use by -r), the path is resolved relative to the parent.

Circular include chains (A.txt includes B.txt which includes A.txt) are detected and raise ParseError.

PARAMETER DESCRIPTION
file_path

Path to the requirements file (absolute or relative).

TYPE: Union[str, Path]

is_constraint_file

If True, all parsed requirements are stored as constraints (via _constraint_requirements) rather than returned. Used internally by -c handlers.

TYPE: bool DEFAULT: False

_parent_directory_path

Internal parameter used when resolving -r includes; the parent's directory is used as the base for relative paths.

TYPE: Optional[Path] DEFAULT: None

RETURNS DESCRIPTION
List[Requirement]

List of Requirement objects (empty if

List[Requirement]

is_constraint_file is True).

RAISES DESCRIPTION
FileOperationError

The file does not exist or cannot be read.

ParseError

A circular include was detected or the file contains invalid syntax.

Source code in depkeeper/core/parser.py
Python
def parse_file(
    self,
    file_path: Union[str, Path],
    is_constraint_file: bool = False,
    _parent_directory_path: Optional[Path] = None,
) -> List[Requirement]:
    """Parse a requirements file from disk.

    Reads the file at *file_path*, processes all directives (``-r``,
    ``-c``, ``-e``, ``--hash``), and returns a flat list of
    `Requirement` objects.  If *file_path* is relative and
    *_parent_directory_path* is provided (internal use by ``-r``), the
    path is resolved relative to the parent.

    Circular include chains (``A.txt`` includes ``B.txt`` which
    includes ``A.txt``) are detected and raise `ParseError`.

    Args:
        file_path: Path to the requirements file (absolute or relative).
        is_constraint_file: If ``True``, all parsed requirements are
            stored as constraints (via `_constraint_requirements`)
            rather than returned.  Used internally by ``-c`` handlers.
        _parent_directory_path: Internal parameter used when resolving
            ``-r`` includes; the parent's directory is used as the base
            for relative paths.

    Returns:
        List of `Requirement` objects (empty if
        *is_constraint_file* is ``True``).

    Raises:
        FileOperationError: The file does not exist or cannot be read.
        ParseError: A circular include was detected or the file contains
            invalid syntax.
    """
    resolved_path = self._resolve_file_path(
        file_path=Path(file_path),
        parent_directory=_parent_directory_path,
    )

    self.logger.debug(
        "Parsing file: %s%s",
        resolved_path,
        " (constraint file)" if is_constraint_file else "",
    )

    # Detect circular includes before reading
    if resolved_path in self._included_files_stack:
        cycle_path = " -> ".join(
            str(p) for p in self._included_files_stack + [resolved_path]
        )
        self.logger.error("Circular dependency detected: %s", cycle_path)
        raise ParseError(
            f"Circular dependency detected: {cycle_path}",
            file_path=str(resolved_path),
        )

    file_content = safe_read_file(resolved_path)

    self._included_files_stack.append(resolved_path)
    try:
        result = self.parse_string(
            file_content,
            source_file_path=str(resolved_path),
            is_constraint_file=is_constraint_file,
            _current_directory_path=resolved_path,
        )
        self.logger.debug(
            "Parsed %d requirement(s) from %s",
            len(result),
            resolved_path.name,
        )
        return result
    finally:
        self._included_files_stack.pop()
parse_string
Python
parse_string(
    requirements_content: str,
    source_file_path: Optional[str] = None,
    is_constraint_file: bool = False,
    _current_directory_path: Optional[Path] = None,
) -> List[Requirement]

Parse requirements from raw text content.

Splits requirements_content into lines and processes each via parse_line. Requirements loaded from -r includes are flattened into the result list.

PARAMETER DESCRIPTION
requirements_content

Multi-line requirements text.

TYPE: str

source_file_path

Optional file path for error messages (purely informational; does not affect parsing).

TYPE: Optional[str] DEFAULT: None

is_constraint_file

If True, all parsed requirements are stored in _constraint_requirements instead of being returned.

TYPE: bool DEFAULT: False

_current_directory_path

Internal parameter; the directory containing the "file" being parsed (used to resolve relative -r / -c paths).

TYPE: Optional[Path] DEFAULT: None

RETURNS DESCRIPTION
List[Requirement]

List of Requirement objects.

Example::

Text Only
>>> content = """
... flask>=2.0
... # A comment
... requests>=2.25.0
... """
>>> parser = RequirementsParser()
>>> reqs = parser.parse_string(content)
>>> [r.name for r in reqs]
['flask', 'requests']
Source code in depkeeper/core/parser.py
Python
def parse_string(
    self,
    requirements_content: str,
    source_file_path: Optional[str] = None,
    is_constraint_file: bool = False,
    _current_directory_path: Optional[Path] = None,
) -> List[Requirement]:
    """Parse requirements from raw text content.

    Splits *requirements_content* into lines and processes each via
    `parse_line`.  Requirements loaded from ``-r`` includes are
    flattened into the result list.

    Args:
        requirements_content: Multi-line requirements text.
        source_file_path: Optional file path for error messages (purely
            informational; does not affect parsing).
        is_constraint_file: If ``True``, all parsed requirements are
            stored in `_constraint_requirements` instead of being
            returned.
        _current_directory_path: Internal parameter; the directory
            containing the "file" being parsed (used to resolve
            relative ``-r`` / ``-c`` paths).

    Returns:
        List of `Requirement` objects.

    Example::

        >>> content = \"\"\"
        ... flask>=2.0
        ... # A comment
        ... requests>=2.25.0
        ... \"\"\"
        >>> parser = RequirementsParser()
        >>> reqs = parser.parse_string(content)
        >>> [r.name for r in reqs]
        ['flask', 'requests']
    """
    # A byte order mark is a stream-level signature, not part of line 1.
    # It survives non-BOM-aware decoding and is not removed by strip().
    if requirements_content.startswith(BOM_CHARACTER):
        requirements_content = requirements_content[len(BOM_CHARACTER):]

    parsed_requirements: List[Requirement] = []
    total_lines = len(requirements_content.splitlines())
    self.logger.debug(
        "Parsing %d line(s)%s",
        total_lines,
        f" from {source_file_path}" if source_file_path else "",
    )

    for line_number, line_text in enumerate(
        requirements_content.splitlines(), start=1
    ):
        parse_result = self.parse_line(
            line_text,
            line_number,
            source_file_path,
            _current_directory_path=_current_directory_path,
        )

        if parse_result is None:
            # Comment or blank line
            continue

        if isinstance(parse_result, list):
            # Nested requirements from -r directive
            self.logger.debug(
                "Included %d requirement(s) from directive on line %d",
                len(parse_result),
                line_number,
            )
            parsed_requirements.extend(parse_result)
        elif isinstance(parse_result, Requirement):
            # Record provenance so the update writer can rewrite the
            # correct file. Requirements flattened in from -r includes
            # (the list branch above) already carry the included file's
            # path set by the recursive parse_string call.
            parse_result.source_file = source_file_path

            if is_constraint_file:
                # Store in constraint map instead of returning
                self._constraint_requirements[parse_result.name] = parse_result
                self.logger.debug(
                    "Stored constraint: %s %s",
                    parse_result.name,
                    parse_result.specs,
                )
            else:
                parsed_requirements.append(parse_result)

    self.logger.debug(
        "Completed parsing: %d requirement(s)", len(parsed_requirements)
    )
    return parsed_requirements
parse_line
Python
parse_line(
    line_text: str,
    line_number: int,
    source_file_path: Optional[str] = None,
    _current_directory_path: Optional[Path] = None,
) -> Optional[Union[Requirement, List[Requirement]]]

Parse a single line from a requirements file.

Handles all pip-supported line types:

  • Blank lines and # comments → None
  • -r file.txtList[Requirement] (nested parse)
  • -c file.txtNone (side-effect: populates constraints)
  • Pip global option lines (e.g. --index-url ...) → None
  • -e <url-or-path> → editable Requirement
  • pkg==1.0 --hash sha256:...Requirement with hashes
  • Standard PEP 508 specs → Requirement
PARAMETER DESCRIPTION
line_text

Raw line text (may include leading/trailing whitespace).

TYPE: str

line_number

Line number (1-indexed) for error reporting.

TYPE: int

source_file_path

Optional source file path for error messages.

TYPE: Optional[str] DEFAULT: None

_current_directory_path

Internal; directory of the file being parsed (used to resolve relative -r / -c paths).

TYPE: Optional[Path] DEFAULT: None

RETURNS DESCRIPTION
Optional[Union[Requirement, List[Requirement]]]
  • None for comments, blank lines, or -c directives.
Optional[Union[Requirement, List[Requirement]]]
  • List[Requirement] when the line is a -r include.
Optional[Union[Requirement, List[Requirement]]]
  • Requirement for all other valid package specs.
RAISES DESCRIPTION
ParseError

The line contains invalid syntax or a directive that cannot be processed.

Source code in depkeeper/core/parser.py
Python
def parse_line(
    self,
    line_text: str,
    line_number: int,
    source_file_path: Optional[str] = None,
    _current_directory_path: Optional[Path] = None,
) -> Optional[Union[Requirement, List[Requirement]]]:
    """Parse a single line from a requirements file.

    Handles all pip-supported line types:

    - Blank lines and ``#`` comments → ``None``
    - ``-r file.txt`` → ``List[Requirement]`` (nested parse)
    - ``-c file.txt`` → ``None`` (side-effect: populates constraints)
    - Pip global option lines (e.g. ``--index-url ...``) → ``None``
    - ``-e <url-or-path>`` → editable `Requirement`
    - ``pkg==1.0 --hash sha256:...`` → `Requirement` with hashes
    - Standard PEP 508 specs → `Requirement`

    Args:
        line_text: Raw line text (may include leading/trailing whitespace).
        line_number: Line number (1-indexed) for error reporting.
        source_file_path: Optional source file path for error messages.
        _current_directory_path: Internal; directory of the file being
            parsed (used to resolve relative ``-r`` / ``-c`` paths).

    Returns:
        - ``None`` for comments, blank lines, or ``-c`` directives.
        - ``List[Requirement]`` when the line is a ``-r`` include.
        - ``Requirement`` for all other valid package specs.

    Raises:
        ParseError: The line contains invalid syntax or a directive
            that cannot be processed.
    """
    stripped_line = line_text.strip()

    if not stripped_line or stripped_line.startswith("#"):
        return None

    # Extract inline comment (everything after a non-URL '#')
    requirement_spec, inline_comment = self._extract_inline_comment(stripped_line)

    # ── Handle -r / --requirement (include another file) ──────────
    if requirement_spec.startswith((INCLUDE_DIRECTIVE, INCLUDE_DIRECTIVE_LONG)):
        return self._handle_include_directive(
            requirement_spec,
            line_number,
            source_file_path,
            _current_directory_path,
        )

    # ── Handle -c / --constraint (load constraints) ───────────────
    if requirement_spec.startswith(
        (CONSTRAINT_DIRECTIVE, CONSTRAINT_DIRECTIVE_LONG)
    ):
        self._handle_constraint_directive(
            requirement_spec,
            line_number,
            source_file_path,
            _current_directory_path,
        )
        return None  # constraints are stored, not returned

    # Recognized pip global options configure installer behavior and are
    # not package requirements, so they should be ignored by the parser.
    if self._is_supported_global_option_line(requirement_spec):
        self.logger.debug(
            "Line %d: Skipping supported pip global option: %s",
            line_number,
            requirement_spec,
        )
        return None

    # Strip quotes that may wrap the entire spec
    requirement_spec = self._remove_surrounding_quotes(requirement_spec)

    # ── Check for -e / --editable flag ────────────────────────────
    is_editable = requirement_spec.startswith(
        (EDITABLE_DIRECTIVE, EDITABLE_DIRECTIVE_LONG)
    )
    if is_editable:
        # Extract everything after "-e " or "--editable "
        requirement_spec = (
            requirement_spec.split(None, 1)[1] if " " in requirement_spec else ""
        )

    # ── Extract --hash directives ──────────────────────────────────
    hash_values: List[str] = _HASH_DIRECTIVE_PATTERN.findall(requirement_spec)
    if hash_values:
        # Remove the entire ``--hash <digest>`` / ``--hash=<digest>``
        # directive (flag AND digest) using the same pattern that
        # extracted it.  Token-based filtering only dropped the ``--hash``
        # flag and left the space-separated digest behind, which then
        # failed PEP 508 parsing.
        requirement_spec = _HASH_DIRECTIVE_PATTERN.sub(" ", requirement_spec)
        requirement_spec = " ".join(requirement_spec.split())

    # ── Dispatch to appropriate builder ────────────────────────────
    url_components = self._parse_direct_url(requirement_spec)
    if url_components:
        parsed_requirement = self._build_url_based_requirement(
            url_string=requirement_spec,
            url_components=url_components,
            is_editable=is_editable,
            hash_values=hash_values,
            inline_comment=inline_comment,
            original_line=line_text,
            line_number=line_number,
        )

    elif local_path_components := self._parse_local_file_path(requirement_spec):
        parsed_requirement = self._build_local_path_requirement(
            path_components=local_path_components,
            current_directory=_current_directory_path,
            is_editable=is_editable,
            hash_values=hash_values,
            inline_comment=inline_comment,
            original_line=line_text,
            line_number=line_number,
        )

    else:
        # Standard PEP 508 package specifier
        parsed_requirement = self._build_standard_pep508_requirement(
            requirement_spec=requirement_spec,
            is_editable=is_editable,
            hash_values=hash_values,
            inline_comment=inline_comment,
            original_line=line_text,
            line_number=line_number,
            source_file_path=source_file_path,
        )

    # Apply any constraint loaded via -c directive
    return self._apply_constraint_to_requirement(parsed_requirement)
get_constraints
Python
get_constraints() -> Dict[str, Requirement]

Return a copy of all constraint requirements loaded via -c.

RETURNS DESCRIPTION
Dict[str, Requirement]

Dictionary mapping normalized package names to their constraint

Dict[str, Requirement]

Requirement objects.

Source code in depkeeper/core/parser.py
Python
def get_constraints(self) -> Dict[str, Requirement]:
    """Return a copy of all constraint requirements loaded via ``-c``.

    Returns:
        Dictionary mapping normalized package names to their constraint
        `Requirement` objects.
    """
    return self._constraint_requirements.copy()
reset
Python
reset() -> None

Clear all internal state (include stack and constraints).

Call this before reusing the parser on a new, unrelated set of files to prevent cross-contamination.

Source code in depkeeper/core/parser.py
Python
def reset(self) -> None:
    """Clear all internal state (include stack and constraints).

    Call this before reusing the parser on a new, unrelated set of
    files to prevent cross-contamination.
    """
    self._included_files_stack = []
    self._constraint_requirements = {}

PyPIDataStore

PyPIDataStore

Python
PyPIDataStore(
    http_client: HTTPClient, concurrent_limit: int = 10
)

Async-safe, per-process cache for PyPI package metadata.

Each unique (normalized) package name triggers at most one concurrent HTTP request to /pypi/{pkg}/json. Two independent mechanisms cooperate:

  • a per-key in-flight map coalesces callers that ask for the same package (or the same name==version dependency list) while a fetch is already running — the first caller performs the request and every later caller awaits its result;
  • a asyncio.Semaphore caps how many distinct fetches may be outbound at once.

A counting semaphore alone cannot deduplicate: it admits concurrent_limit coroutines simultaneously, so a re-check inside it is not mutually exclusive. Waiters coalesced by the in-flight map do not consume a semaphore slot.

Failures are never cached. Once a fetch fails, its in-flight entry is dropped so that a later call re-attempts the request (transient network errors must stay recoverable).

PARAMETER DESCRIPTION
http_client

A pre-configured HTTPClient instance (owns connection pool / session).

TYPE: HTTPClient

concurrent_limit

Maximum number of PyPI fetches that may be in-flight at once. Defaults to 10.

TYPE: int DEFAULT: 10

RAISES DESCRIPTION
ValueError

concurrent_limit is less than 1 (a limit of zero would deadlock every fetch).

Example::

Text Only
async with HTTPClient() as client:
    store = PyPIDataStore(client, concurrent_limit=5)

    # warm the cache for several packages at once
    await store.prefetch_packages(["flask", "click", "jinja2"])

    # subsequent calls return instantly from cache
    flask = await store.get_package_data("flask")
    print(flask.latest_version)
Source code in depkeeper/core/data_store.py
Python
def __init__(
    self,
    http_client: HTTPClient,
    concurrent_limit: int = 10,
) -> None:
    if concurrent_limit < 1:
        raise ValueError(
            f"concurrent_limit must be >= 1, got {concurrent_limit}"
        )

    self.http_client = http_client
    self._semaphore = asyncio.Semaphore(concurrent_limit)

    # Primary cache: normalized name → parsed package snapshot
    self._package_data: Dict[str, PyPIPackageData] = {}

    # Secondary cache: "name==version" → dependency list (avoids
    # repeated per-version fetches even after the main cache is warm)
    self._version_deps_cache: Dict[str, List[str]] = {}

    # In-flight maps: normalized key → the task currently fetching it.
    # Entries live only for the duration of a fetch (see _coalesce).
    self._inflight_packages: Dict[str, "asyncio.Task[PyPIPackageData]"] = {}
    self._inflight_version_deps: Dict[str, "asyncio.Task[List[str]]"] = {}
Methods:
get_package_data async
Python
get_package_data(name: str) -> PyPIPackageData

Fetch (or return cached) metadata for name.

Cached entries are returned without awaiting anything. Otherwise the call is coalesced: the first caller starts the fetch and any concurrent caller for the same normalized name awaits that same fetch, so PyPI sees exactly one request.

PARAMETER DESCRIPTION
name

PyPI package name (any casing / underscore style).

TYPE: str

RETURNS DESCRIPTION
PyPIPackageData

A PyPIPackageData populated from the latest PyPI

PyPIPackageData

JSON response. Every caller for the same normalized name

PyPIPackageData

receives the same object.

RAISES DESCRIPTION
PyPIError

The package does not exist on PyPI or the API returned an unexpected status code.

NetworkError

The request failed (timeout, rate limit, 5xx).

Example::

Text Only
>>> data = await store.get_package_data("Requests")
>>> data.name
'requests'
>>> data.latest_version
'2.31.0'
Source code in depkeeper/core/data_store.py
Python
async def get_package_data(self, name: str) -> PyPIPackageData:
    """Fetch (or return cached) metadata for *name*.

    Cached entries are returned without awaiting anything.  Otherwise
    the call is *coalesced*: the first caller starts the fetch and any
    concurrent caller for the same normalized name awaits that same
    fetch, so PyPI sees exactly one request.

    Args:
        name: PyPI package name (any casing / underscore style).

    Returns:
        A `PyPIPackageData` populated from the latest PyPI
        JSON response.  Every caller for the same normalized name
        receives the *same* object.

    Raises:
        PyPIError: The package does not exist on PyPI or the API
            returned an unexpected status code.
        NetworkError: The request failed (timeout, rate limit, 5xx).

    Example::

        >>> data = await store.get_package_data("Requests")
        >>> data.name
        'requests'
        >>> data.latest_version
        '2.31.0'
    """
    normalized = _normalize(name)

    # Fast path — already cached (never suspends)
    cached = self._package_data.get(normalized)
    if cached is not None:
        return cached

    return await self._coalesce(
        self._inflight_packages,
        normalized,
        lambda: self._load_package_data(name, normalized),
    )
prefetch_packages async
Python
prefetch_packages(names: List[str]) -> None

Concurrently warm the cache for a batch of packages.

Duplicate names (including different spellings that normalize to the same key, e.g. Flask and flask) are collapsed to a single fetch. Errors for individual packages are silenced so that one bad package name does not prevent the rest from being cached.

PARAMETER DESCRIPTION
names

Package names to prefetch.

TYPE: List[str]

Example::

Text Only
>>> await store.prefetch_packages(["numpy", "pandas", "numpy"])
# two fetches, not three; subsequent get_package_data calls
# for these return instantly
Source code in depkeeper/core/data_store.py
Python
async def prefetch_packages(self, names: List[str]) -> None:
    """Concurrently warm the cache for a batch of packages.

    Duplicate names (including different spellings that normalize to
    the same key, e.g. ``Flask`` and ``flask``) are collapsed to a
    single fetch.  Errors for individual packages are silenced so that
    one bad package name does not prevent the rest from being cached.

    Args:
        names: Package names to prefetch.

    Example::

        >>> await store.prefetch_packages(["numpy", "pandas", "numpy"])
        # two fetches, not three; subsequent get_package_data calls
        # for these return instantly
    """
    # dict preserves insertion order → deterministic request ordering,
    # and the first spelling of each name is the one sent to PyPI.
    unique: Dict[str, str] = {}
    for name in names:
        unique.setdefault(_normalize(name), name)

    await asyncio.gather(
        *(self.get_package_data(name) for name in unique.values()),
        return_exceptions=True,  # swallow per-package failures
    )
get_version_dependencies async
Python
get_version_dependencies(
    name: str, version: str
) -> List[str]

Return the base dependencies for a specific version of name.

Resolution order (fastest first):

  1. Per-version dependency cache (_version_deps_cache).
  2. Already-populated fields inside the cached PyPIPackageData (latest_dependencies or dependencies_cache).
  3. A targeted /pypi/{name}/{version}/json fetch, coalesced per name==version key and throttled by the semaphore.
PARAMETER DESCRIPTION
name

Package name.

TYPE: str

version

Exact version string, e.g. "1.2.3".

TYPE: str

RETURNS DESCRIPTION
List[str]

List of PEP-508 dependency specifiers with extras and

List[str]

environment markers stripped.

Example::

Text Only
>>> deps = await store.get_version_dependencies("flask", "2.3.0")
>>> deps
['Werkzeug>=2.0', 'Jinja2>=3.0', ...]
Source code in depkeeper/core/data_store.py
Python
async def get_version_dependencies(
    self,
    name: str,
    version: str,
) -> List[str]:
    """Return the base dependencies for a specific version of *name*.

    Resolution order (fastest first):

    1. Per-version dependency cache (``_version_deps_cache``).
    2. Already-populated fields inside the cached
       `PyPIPackageData` (``latest_dependencies`` or
       ``dependencies_cache``).
    3. A targeted ``/pypi/{name}/{version}/json`` fetch, coalesced per
       ``name==version`` key and throttled by the semaphore.

    Args:
        name: Package name.
        version: Exact version string, e.g. ``"1.2.3"``.

    Returns:
        List of PEP-508 dependency specifiers with extras and
        environment markers stripped.

    Example::

        >>> deps = await store.get_version_dependencies("flask", "2.3.0")
        >>> deps
        ['Werkzeug>=2.0', 'Jinja2>=3.0', ...]
    """
    normalized = _normalize(name)
    cache_key = f"{normalized}=={version}"

    # ── layer 1: flat version-deps cache ──────────────────────────
    cached_deps = self._version_deps_cache.get(cache_key)
    if cached_deps is not None:
        return cached_deps

    # ── layer 2: already inside PyPIPackageData ────────────────────
    pkg_data = self._package_data.get(normalized)
    if pkg_data:
        if version == pkg_data.latest_version:
            self._version_deps_cache[cache_key] = pkg_data.latest_dependencies
            return pkg_data.latest_dependencies

        if version in pkg_data.dependencies_cache:
            deps = pkg_data.dependencies_cache[version]
            self._version_deps_cache[cache_key] = deps
            return deps

    # ── layer 3: network fetch (coalesced per name==version) ───────
    return await self._coalesce(
        self._inflight_version_deps,
        cache_key,
        lambda: self._load_version_dependencies(name, version, normalized, cache_key),
    )
get_cached_package
Python
get_cached_package(name: str) -> Optional[PyPIPackageData]

Return cached data for name without triggering a fetch.

PARAMETER DESCRIPTION
name

Package name (any casing / underscore style).

TYPE: str

RETURNS DESCRIPTION
Optional[PyPIPackageData]

The cached PyPIPackageData, or None if the

Optional[PyPIPackageData]

package has not been fetched yet.

Source code in depkeeper/core/data_store.py
Python
def get_cached_package(self, name: str) -> Optional[PyPIPackageData]:
    """Return cached data for *name* without triggering a fetch.

    Args:
        name: Package name (any casing / underscore style).

    Returns:
        The cached `PyPIPackageData`, or ``None`` if the
        package has not been fetched yet.
    """
    return self._package_data.get(_normalize(name))
get_versions
Python
get_versions(name: str) -> List[str]

Return cached stable versions for name (newest first).

Returns an empty list when name has not been fetched yet.

PARAMETER DESCRIPTION
name

Package name.

TYPE: str

RETURNS DESCRIPTION
List[str]

List of version strings, or [].

Source code in depkeeper/core/data_store.py
Python
def get_versions(self, name: str) -> List[str]:
    """Return cached stable versions for *name* (newest first).

    Returns an empty list when *name* has not been fetched yet.

    Args:
        name: Package name.

    Returns:
        List of version strings, or ``[]``.
    """
    pkg = self.get_cached_package(name)
    return pkg.all_versions if pkg else []
is_python_compatible
Python
is_python_compatible(
    name: str, version: str, python_version: str
) -> bool

Check Python compatibility using only cached metadata.

Returns True when the package has not been fetched yet — the caller should call get_package_data first if a definitive answer is needed.

PARAMETER DESCRIPTION
name

Package name.

TYPE: str

version

Package version string.

TYPE: str

python_version

Dot-separated Python version.

TYPE: str

RETURNS DESCRIPTION
bool

Compatibility flag (see PyPIPackageData.is_python_compatible).

Source code in depkeeper/core/data_store.py
Python
def is_python_compatible(
    self,
    name: str,
    version: str,
    python_version: str,
) -> bool:
    """Check Python compatibility using only cached metadata.

    Returns ``True`` when the package has not been fetched yet — the
    caller should call `get_package_data` first if a definitive
    answer is needed.

    Args:
        name: Package name.
        version: Package version string.
        python_version: Dot-separated Python version.

    Returns:
        Compatibility flag (see `PyPIPackageData.is_python_compatible`).
    """
    pkg = self.get_cached_package(name)
    return pkg.is_python_compatible(version, python_version) if pkg else True
get_current_python_version staticmethod
Python
get_current_python_version() -> str

Return the running interpreter's version as "major.minor.micro".

Example::

Text Only
>>> PyPIDataStore.get_current_python_version()
'3.11.4'
Source code in depkeeper/core/data_store.py
Python
@staticmethod
def get_current_python_version() -> str:
    """Return the running interpreter's version as ``"major.minor.micro"``.

    Example::

        >>> PyPIDataStore.get_current_python_version()
        '3.11.4'
    """
    return (
        f"{sys.version_info.major}."
        f"{sys.version_info.minor}."
        f"{sys.version_info.micro}"
    )

PyPIPackageData

PyPIPackageData dataclass

Python
PyPIPackageData(
    name: str,
    latest_version: Optional[str] = None,
    latest_requires_python: Optional[str] = None,
    latest_dependencies: List[str] = list(),
    all_versions: List[str] = list(),
    parsed_versions: List[Tuple[str, Version]] = list(),
    python_requirements: Dict[str, Optional[str]] = dict(),
    releases: Dict[str, List[Dict[str, Any]]] = dict(),
    dependencies_cache: Dict[str, List[str]] = dict(),
)

Immutable-by-convention snapshot of one PyPI package.

Populated once by PyPIDataStore._parse_package_data and then shared across every caller that requests the same package. All mutable collections use field(default_factory=…) so that each instance owns its own lists / dicts.

ATTRIBUTE DESCRIPTION
name

Normalized package name (lower-case, hyphens).

TYPE: str

latest_version

Version string reported by PyPI info.version.

TYPE: Optional[str]

latest_requires_python

requires_python marker for latest.

TYPE: Optional[str]

latest_dependencies

Base (non-extra) deps of latest.

TYPE: List[str]

all_versions

Stable (non-pre-release) versions, newest first.

TYPE: List[str]

parsed_versions

Every version that could be parsed, as (raw_str, Version) pairs sorted descending.

TYPE: List[Tuple[str, Version]]

python_requirements

Maps version string → its requires_python specifier (or None when the upload omits it).

TYPE: Dict[str, Optional[str]]

releases

Raw releases dict from the PyPI JSON response.

TYPE: Dict[str, List[Dict[str, Any]]]

dependencies_cache

Lazily populated per-version dependency lists; seeded with latest on construction.

TYPE: Dict[str, List[str]]

Methods:
get_versions_in_major
Python
get_versions_in_major(major: int) -> List[str]

Return stable versions that share a given major number.

Pre-releases and versions whose release tuple is empty are skipped.

PARAMETER DESCRIPTION
major

The major version number to filter on (e.g. 2).

TYPE: int

RETURNS DESCRIPTION
List[str]

Version strings in descending order (inherits the sort order

List[str]

of parsed_versions).

Source code in depkeeper/core/data_store.py
Python
def get_versions_in_major(self, major: int) -> List[str]:
    """Return stable versions that share a given major number.

    Pre-releases and versions whose ``release`` tuple is empty are
    skipped.

    Args:
        major: The major version number to filter on (e.g. ``2``).

    Returns:
        Version strings in descending order (inherits the sort order
        of `parsed_versions`).
    """
    result: List[str] = []

    for version_str, parsed in self.parsed_versions:
        if parsed.is_prerelease:
            continue
        # `release` may be empty for exotic versions such as an epoch-only
        # tag, so index 0 is not guaranteed to exist.
        if parsed.release and parsed.release[0] == major:
            result.append(version_str)

    return result
is_python_compatible
Python
is_python_compatible(
    version: str, python_version: str
) -> bool

Check whether a package version supports a given Python version.

Returns True when the package omits requires_python or when parsing the specifier fails — matching pip's own permissive behavior.

PARAMETER DESCRIPTION
version

Package version string, e.g. "1.4.2".

TYPE: str

python_version

Dot-separated Python version, e.g. "3.11.2".

TYPE: str

RETURNS DESCRIPTION
bool

True if python_version satisfies the package's

bool

requires_python constraint (or if the constraint is absent /

bool

unparseable).

Example::

Text Only
>>> data.python_requirements["1.4.2"] = ">=3.7"
>>> data.is_python_compatible("1.4.2", "3.11.2")
True
>>> data.is_python_compatible("1.4.2", "2.7.18")
False
Source code in depkeeper/core/data_store.py
Python
def is_python_compatible(
    self,
    version: str,
    python_version: str,
) -> bool:
    """Check whether a package version supports a given Python version.

    Returns ``True`` when the package omits ``requires_python`` or when
    parsing the specifier fails — matching pip's own permissive
    behavior.

    Args:
        version: Package version string, e.g. ``"1.4.2"``.
        python_version: Dot-separated Python version, e.g.
            ``"3.11.2"``.

    Returns:
        ``True`` if *python_version* satisfies the package's
        ``requires_python`` constraint (or if the constraint is absent /
        unparseable).

    Example::

        >>> data.python_requirements["1.4.2"] = ">=3.7"
        >>> data.is_python_compatible("1.4.2", "3.11.2")
        True
        >>> data.is_python_compatible("1.4.2", "2.7.18")
        False
    """
    requires_python = self.python_requirements.get(version)

    # Absent constraint means "any Python", which is how pip reads it.
    if not requires_python:
        return True

    try:
        return python_version in SpecifierSet(requires_python)
    except InvalidSpecifier:
        # Malformed upstream metadata must not exclude an otherwise
        # installable release.
        return True
get_python_compatible_versions
Python
get_python_compatible_versions(
    python_version: str, major: Optional[int] = None
) -> List[str]

Return stable versions compatible with python_version.

Optionally restrict results to a single major version. Versions are returned in descending order.

PARAMETER DESCRIPTION
python_version

Dot-separated Python version to check against, e.g. "3.10.0".

TYPE: str

major

If provided, only versions with this major number are included.

TYPE: Optional[int] DEFAULT: None

RETURNS DESCRIPTION
List[str]

Filtered, descending list of version strings.

Source code in depkeeper/core/data_store.py
Python
def get_python_compatible_versions(
    self,
    python_version: str,
    major: Optional[int] = None,
) -> List[str]:
    """Return stable versions compatible with *python_version*.

    Optionally restrict results to a single major version.  Versions
    are returned in descending order.

    Args:
        python_version: Dot-separated Python version to check against,
            e.g. ``"3.10.0"``.
        major: If provided, only versions with this major number are
            included.

    Returns:
        Filtered, descending list of version strings.
    """
    result: List[str] = []

    for version_str, parsed in self.parsed_versions:
        if parsed.is_prerelease:
            continue

        if major is not None:
            if not parsed.release or parsed.release[0] != major:
                continue

        if self.is_python_compatible(version_str, python_version):
            result.append(version_str)

    return result

VersionChecker

VersionChecker

Python
VersionChecker(
    data_store: PyPIDataStore,
    infer_version_from_constraints: bool = True,
)

Async package version checker with strict major version boundaries.

Fetches metadata from PyPI (via the shared data store) and determines the highest Python-compatible version for each package, strictly respecting major-version boundaries when a current version is known. Unlike the base implementation, this checker will never recommend crossing a major version boundary, even if a newer major exists.

All network I/O is delegated to data_store, which guarantees that each unique package is fetched at most once.

PARAMETER DESCRIPTION
data_store

Shared PyPI metadata cache. Required.

TYPE: PyPIDataStore

infer_version_from_constraints

When True and a requirement has no pinned version (==), attempt to infer a "current" version from range constraints like >=2.0. Defaults to True.

TYPE: bool DEFAULT: True

RAISES DESCRIPTION
TypeError

If data_store is None.

Source code in depkeeper/core/checker.py
Python
def __init__(
    self,
    data_store: PyPIDataStore,
    infer_version_from_constraints: bool = True,
) -> None:
    if data_store is None:
        raise TypeError(
            "data_store must not be None; pass a PyPIDataStore instance"
        )

    self.data_store: PyPIDataStore = data_store
    self.infer_version_from_constraints: bool = infer_version_from_constraints
Methods:
get_package_info async
Python
get_package_info(
    name: str,
    current_version: Optional[str] = None,
    constraints: Optional[Sequence[Tuple[str, str]]] = None,
) -> Package

Fetch metadata and compute a recommended version for name.

CRITICAL: Recommendations NEVER cross major version boundaries. If current_version is 2.x.x, the recommended version will be 2.y.z (never 3.0.0), even if 3.0.0 is the latest available version on PyPI.

Calls PyPIDataStore.get_package_data (which may trigger a network fetch or return cached data), then applies the strict major-boundary recommendation algorithm to choose the best upgrade target.

PARAMETER DESCRIPTION
name

Package name (any casing / separator style).

TYPE: str

current_version

The version currently installed (if known). When provided, the recommendation stays within the same major version. If no compatible version exists in that major, stays on current version rather than crossing the boundary.

TYPE: Optional[str] DEFAULT: None

constraints

Additional (operator, version) specifiers the recommendation must satisfy — typically the upper bounds and exclusions declared by the requirement itself (see retained_specs). Recommending a version that violates them would produce an unsatisfiable requirement line.

TYPE: Optional[Sequence[Tuple[str, str]]] DEFAULT: None

RETURNS DESCRIPTION
Package

A Package with latest_version,

Package

recommended_version, and metadata fields populated. When PyPI

Package

data cannot be retrieved (missing package, unexpected status,

Package

timeout, rate limiting) an unavailable stub is returned instead

Package

— see create_unavailable_package.

Source code in depkeeper/core/checker.py
Python
async def get_package_info(
    self,
    name: str,
    current_version: Optional[str] = None,
    constraints: Optional[Sequence[Tuple[str, str]]] = None,
) -> Package:
    """Fetch metadata and compute a recommended version for *name*.

    **CRITICAL**: Recommendations **NEVER** cross major version boundaries.
    If *current_version* is ``2.x.x``, the recommended version will be
    ``2.y.z`` (never ``3.0.0``), even if ``3.0.0`` is the latest available
    version on PyPI.

    Calls `PyPIDataStore.get_package_data` (which may trigger a
    network fetch or return cached data), then applies the strict
    major-boundary recommendation algorithm to choose the best upgrade
    target.

    Args:
        name: Package name (any casing / separator style).
        current_version: The version currently installed (if known).
            When provided, the recommendation stays within the same
            major version. If no compatible version exists in that
            major, stays on current version rather than crossing
            the boundary.
        constraints: Additional ``(operator, version)`` specifiers the
            recommendation must satisfy — typically the upper bounds and
            exclusions declared by the requirement itself (see
            `retained_specs`).
            Recommending a version that violates them would produce an
            unsatisfiable requirement line.

    Returns:
        A `Package` with ``latest_version``,
        ``recommended_version``, and metadata fields populated. When PyPI
        data cannot be retrieved (missing package, unexpected status,
        timeout, rate limiting) an *unavailable stub* is returned instead
        — see `create_unavailable_package`.
    """
    try:
        pkg_data = await self.data_store.get_package_data(name)
    except NetworkError:
        # 404, unexpected status, timeout or rate limiting (PyPIError is a
        # NetworkError) — return an unavailable stub rather than failing
        # the whole run.
        logger.warning("Package '%s' unavailable; creating stub", name)
        return self.create_unavailable_package(name, current_version)

    return self._build_package_from_data(pkg_data, current_version, constraints)
check_packages async
Python
check_packages(
    requirements: List[Requirement],
) -> List[Package]

Check multiple packages concurrently.

For each requirement, extracts the current version (via extract_current_version) and calls get_package_info. Errors for individual packages are caught and replaced with unavailable stubs so that one bad package does not block the rest.

PARAMETER DESCRIPTION
requirements

Parsed requirements from a requirements file.

TYPE: List[Requirement]

RETURNS DESCRIPTION
List[Package]

List of Package objects, one per requirement.

Source code in depkeeper/core/checker.py
Python
async def check_packages(
    self,
    requirements: List[Requirement],
) -> List[Package]:
    """Check multiple packages concurrently.

    For each requirement, extracts the current version (via
    `extract_current_version`) and calls `get_package_info`.
    Errors for individual packages are caught and replaced with
    unavailable stubs so that one bad package does not block the rest.

    Args:
        requirements: Parsed requirements from a requirements file.

    Returns:
        List of `Package` objects, one per requirement.
    """
    tasks = [self._create_package_check_task(req) for req in requirements]
    results = await asyncio.gather(*tasks, return_exceptions=True)
    return self._process_check_results(requirements, results)
extract_current_version
Python
extract_current_version(req: Requirement) -> Optional[str]

Infer a "current" version from a requirement's version specifiers.

Heuristic:

  1. If the requirement has exactly one specifier and it is ==, return that version (pinned).
  2. If infer_version_from_constraints is False, stop here.
  3. Otherwise, scan for the first >=, >, or ~= specifier and return its version. This treats >=2.0 as "currently on 2.0" for major-version boundary purposes.
PARAMETER DESCRIPTION
req

A parsed Requirement.

TYPE: Requirement

RETURNS DESCRIPTION
Optional[str]

The inferred version string, or None when inference is not

Optional[str]

possible.

Source code in depkeeper/core/checker.py
Python
def extract_current_version(
    self,
    req: Requirement,
) -> Optional[str]:
    """Infer a "current" version from a requirement's version specifiers.

    Heuristic:

    1. If the requirement has exactly one specifier and it is ``==``,
       return that version (pinned).
    2. If `infer_version_from_constraints` is ``False``, stop here.
    3. Otherwise, scan for the first ``>=``, ``>``, or ``~=`` specifier
       and return its version. This treats ``>=2.0`` as "currently on
       2.0" for major-version boundary purposes.

    Args:
        req: A parsed `Requirement`.

    Returns:
        The inferred version string, or ``None`` when inference is not
        possible.
    """
    if not req.specs:
        return None

    if len(req.specs) == 1 and req.specs[0][0] == "==":
        return req.specs[0][1]

    if not self.infer_version_from_constraints:
        return None

    for operator, version in req.specs:
        if operator in (">=", ">", "~="):
            return version

    return None
create_unavailable_package
Python
create_unavailable_package(
    name: str, current_version: Optional[str]
) -> Package

Create a stub Package when PyPI data is unavailable.

Keeps the package in the result list (rather than dropping it) so the report still shows what is declared in the file, while the missing latest_version renders as an error row and no update is proposed.

PARAMETER DESCRIPTION
name

Package name.

TYPE: str

current_version

The version that was installed (if known).

TYPE: Optional[str]

RETURNS DESCRIPTION
Package

A Package with latest_version and

Package

recommended_version both set to None.

Source code in depkeeper/core/checker.py
Python
def create_unavailable_package(
    self,
    name: str,
    current_version: Optional[str],
) -> Package:
    """Create a stub `Package` when PyPI data is unavailable.

    Keeps the package in the result list (rather than dropping it) so the
    report still shows what is declared in the file, while the missing
    ``latest_version`` renders as an error row and no update is proposed.

    Args:
        name: Package name.
        current_version: The version that was installed (if known).

    Returns:
        A `Package` with ``latest_version`` and
        ``recommended_version`` both set to ``None``.
    """
    return Package(
        name=name,
        current_version=current_version,
        latest_version=None,
        recommended_version=None,
        metadata={},
    )

DependencyAnalyzer

DependencyAnalyzer

Python
DependencyAnalyzer(
    data_store: PyPIDataStore, concurrent_limit: int = 10
)

Detect and resolve version conflicts within major version boundaries.

Conflict resolution never moves a package into a different major version, so resolving one dependency's requirement cannot silently introduce a breaking change elsewhere.

The analyzer works exclusively through a PyPIDataStore instance, which guarantees that every /pypi/{pkg}/json call is made at most once. All public entry points are async.

PARAMETER DESCRIPTION
data_store

Shared PyPI data store. Required — the class has no independent HTTP path.

TYPE: PyPIDataStore

concurrent_limit

Upper bound on in-flight PyPI fetches. Forwarded to the internal semaphore. Defaults to 10.

TYPE: int DEFAULT: 10

RAISES DESCRIPTION
TypeError

If data_store is None.

Source code in depkeeper/core/dependency_analyzer.py
Python
def __init__(
    self,
    data_store: PyPIDataStore,
    concurrent_limit: int = 10,
) -> None:
    if data_store is None:
        raise TypeError(
            "data_store must not be None; pass a PyPIDataStore instance"
        )
    self.data_store: PyPIDataStore = data_store
    self._semaphore: asyncio.Semaphore = asyncio.Semaphore(concurrent_limit)

    # Normalized names whose metadata could not be fetched during this
    # resolution run. Remembering them keeps the resolution loop from
    # re-issuing (and re-retrying) a request that is already known to fail.
    self._unavailable_packages: Set[str] = set()
Methods:
resolve_and_annotate_conflicts async
Python
resolve_and_annotate_conflicts(
    packages: List[Package],
) -> ResolutionResult

Resolve conflicts while strictly respecting major version boundaries.

Algorithm outline:

  1. Build an update set mapping each package name to its proposed version (recommended_version if available, otherwise current_version). Recommended versions already respect major version boundaries.
  2. Prefetch metadata for every package in one concurrent burst.
  3. Loop up to _MAX_RESOLUTION_ITERATIONS times:

a. Scan for cross-conflicts in the current update set. b. If none remain, stop — the set is self-consistent. c. Attempt resolution within major version boundaries only:

Text Only
  - Try to find a compatible source version within its current major
  - If that fails, try to constrain the target within its current major
  - If both fail, revert both packages to their current versions

d. Break early when no progress is made.

  1. For packages the loop could not fix, adopt the best version that satisfies every conflict at once, when one exists.
  2. Annotate each Package with its final version and any conflicts still live against that final version.
  3. Return a ResolutionResult with complete details.

Invariant: after this call, pkg.recommended_version equals result.resolved_versions[pkg.name].resolved for every package declared once. ResolutionResult is therefore the single source of truth for a singly-declared package — the version reported in the summary is always the version applied by depkeeper update.

Duplicate declarations (the same normalized package name appearing more than once in packages, e.g. the same distribution pulled in via two -r includes with different constraints) are each resolved independently: cross-package conflict detection still reasons about the name as a whole, but a name-level adjustment only reaches a given declaration's recommended_version when a real conflict was recorded for that name. Absent one, each declaration keeps the recommendation it already had. resolved_versions still holds one summary entry per name, so for a duplicated name it reports a single representative outcome — consult each Package.recommended_version directly for the authoritative per-declaration outcome.

PARAMETER DESCRIPTION
packages

Mutable list of Package objects. Each object is updated in place with the resolved version and conflict metadata.

TYPE: List[Package]

RETURNS DESCRIPTION
ResolutionResult

ResolutionResult containing the final version for each

ResolutionResult

package, conflict details, and resolution statistics.

Source code in depkeeper/core/dependency_analyzer.py
Python
async def resolve_and_annotate_conflicts(
    self,
    packages: List[Package],
) -> ResolutionResult:
    """Resolve conflicts while strictly respecting major version boundaries.

    Algorithm outline:

    1. Build an *update set* mapping each package name to its
       proposed version (``recommended_version`` if available,
       otherwise ``current_version``). Recommended versions already
       respect major version boundaries.
    2. Prefetch metadata for every package in one concurrent burst.
    3. Loop up to `_MAX_RESOLUTION_ITERATIONS` times:

       a. Scan for cross-conflicts in the current update set.
       b. If none remain, stop — the set is self-consistent.
       c. Attempt resolution within major version boundaries only:

          - Try to find a compatible source version within its current major
          - If that fails, try to constrain the target within its current major
          - If both fail, revert both packages to their current versions

       d. Break early when no progress is made.

    4. For packages the loop could not fix, adopt the best version that
       satisfies every conflict at once, when one exists.
    5. Annotate each `Package` with its final version and any
       conflicts still live against that final version.
    6. Return a `ResolutionResult` with complete details.

    Invariant: after this call, ``pkg.recommended_version`` equals
    ``result.resolved_versions[pkg.name].resolved`` for every package
    declared **once**. `ResolutionResult` is therefore the
    single source of truth for a singly-declared package — the version
    reported in the summary is always the version applied by
    ``depkeeper update``.

    Duplicate declarations (the same normalized package name appearing
    more than once in *packages*, e.g. the same distribution pulled in
    via two ``-r`` includes with different constraints) are each
    resolved **independently**: cross-package conflict detection still
    reasons about the name as a whole, but a name-level adjustment only
    reaches a given declaration's ``recommended_version`` when a *real*
    conflict was recorded for that name. Absent one, each declaration
    keeps the recommendation it already had. ``resolved_versions``
    still holds one summary entry per *name*, so for a duplicated name
    it reports a single representative outcome — consult each
    `Package.recommended_version` directly for the authoritative
    per-declaration outcome.

    Args:
        packages: Mutable list of `Package` objects. Each
            object is updated in place with the resolved version and
            conflict metadata.

    Returns:
        `ResolutionResult` containing the final version for each
        package, conflict details, and resolution statistics.
    """
    # ── initialize update set ─────────────────────────────────────
    pkg_lookup: Dict[str, Package] = {pkg.name: pkg for pkg in packages}
    update_set: Dict[str, Optional[str]] = {}
    conflict_tracking: Dict[str, List[Conflict]] = {}

    # Kept separately so PackageResolution can report what was originally
    # proposed even after update_set has been rewritten in place.
    original_versions: Dict[str, Optional[str]] = {}

    # Each package instance's own pre-resolution proposal, captured
    # positionally (parallel to `packages`) before any name-collapsing,
    # so a duplicated name's declarations can be told apart later.
    own_proposals: List[Optional[str]] = [
        pkg.recommended_version or pkg.current_version for pkg in packages
    ]

    for pkg, proposed in zip(packages, own_proposals):
        # recommended_version already respects major boundaries; falling
        # back to current_version means "propose no change". A name
        # declared more than once seeds the name-level working value
        # from the lower of its duplicates' proposals -- bookkeeping
        # only, it never overrides an individual declaration's own
        # recommendation unless a real conflict is found for that name.
        if pkg.name in update_set:
            proposed = _lower_proposal(update_set[pkg.name], proposed)
        update_set[pkg.name] = proposed
        original_versions[pkg.name] = proposed

    # ── warm the cache in one round-trip ──────────────────────────
    await self.data_store.prefetch_packages([pkg.name for pkg in packages])

    # ── iterative conflict resolution ─────────────────────────────
    iterations_used = 0
    converged = False

    for iteration in range(_MAX_RESOLUTION_ITERATIONS):
        iterations_used = iteration + 1
        cross_conflicts = await self._find_cross_conflicts(packages, update_set)

        if not cross_conflicts:
            logger.debug(
                "Update set is conflict-free after %d iteration(s)", iteration
            )
            converged = True
            break

        # Record every conflict for later annotation. The same conflict can
        # be re-detected on each pass, so identical signatures are dropped.
        for conflict in cross_conflicts:
            conflicts_list = conflict_tracking.setdefault(
                conflict.target_package, []
            )

            conflict_key = (
                conflict.source_package,
                conflict.source_version,
                conflict.required_spec,
                conflict.conflicting_version,
            )
            existing_keys = {
                (
                    c.source_package,
                    c.source_version,
                    c.required_spec,
                    c.conflicting_version,
                )
                for c in conflicts_list
            }
            if conflict_key not in existing_keys:
                conflicts_list.append(conflict)

        # Attempt resolution while respecting major version boundaries
        resolved_any = await self._resolve_conflicts_within_major(
            pkg_lookup, update_set, cross_conflicts
        )

        if not resolved_any:
            # No version change was made → further iterations would
            # produce the exact same conflict set; stop early.
            logger.warning(
                "Conflict resolution stalled after %d iteration(s)",
                iteration + 1,
            )
            break
    else:
        # for/else: exhausted all iterations without breaking
        logger.warning(
            "Conflict resolution did not converge within %d iterations",
            _MAX_RESOLUTION_ITERATIONS,
        )

    # ── advisory alternatives, computed from one stable snapshot ──
    # `conflict_tracking` is cumulative: it holds every conflict seen in
    # *any* iteration, including ones the loop went on to resolve. The
    # alternative search below is therefore only advisory — it must never
    # silently override the version the loop actually decided on (that
    # divergence is exactly what made the printed summary disagree with
    # the version written to the file).
    alternatives: Dict[str, Optional[str]] = {}
    live_conflicts: Dict[str, List[Conflict]] = {}

    for pkg in packages:
        conflicts = conflict_tracking.get(pkg.name, [])
        if not conflicts:
            continue
        alternatives[pkg.name] = self._find_alternative_within_major(
            pkg, conflicts
        )
        live_conflicts[pkg.name] = _live_conflicts(
            update_set, pkg.name, update_set.get(pkg.name), conflicts
        )

    # ── adopt an alternative only where the decision is still broken ──
    # Every decision above is made against the same pre-adoption snapshot
    # so the outcome does not depend on package ordering.
    for name, live in live_conflicts.items():
        alternative = alternatives.get(name)
        if not live or not alternative or alternative == update_set.get(name):
            continue
        if not _satisfies_all(alternative, live):
            continue
        logger.info(
            "Adopting compatible alternative for %s: %s%s "
            "(%d conflict(s) unresolved by the resolution loop)",
            name,
            update_set.get(name),
            alternative,
            len(live),
        )
        update_set[name] = alternative

    # ── annotate packages and build resolution map ────────────────
    resolved_versions: Dict[str, PackageResolution] = {}
    packages_with_conflicts = 0

    for pkg, own_proposal in zip(packages, own_proposals):
        name = pkg.name
        original = original_versions.get(name)
        name_resolved = update_set.get(name)
        raw_conflicts = conflict_tracking.get(name, [])
        compatible_alt = alternatives.get(name)

        # A name-level value only reflects a *real* resolution decision
        # when it moved away from its initial seed -- whether this name
        # was the target of a conflict, or was adjusted as the source of
        # one (conflicts are recorded by target only, so
        # `conflict_tracking` alone can't tell the two apart). Absent
        # any such move, a divergence between `name_resolved` and this
        # declaration's own proposal is just the seeding above; it must
        # not change what THIS declaration recommends.
        name_changed = name_resolved != original_versions.get(name)
        if name_resolved is None:
            instance_resolved: Optional[str] = None
        elif not name_changed:
            instance_resolved = own_proposal
        else:
            instance_resolved = name_resolved

        # Recomputed against the FINAL update_set (after alternative
        # adoption above), not the pre-adoption snapshot used only to
        # decide whether to adopt -- otherwise a conflict the adoption
        # step just resolved would still be reported as live. Only used
        # for what `pkg` actually displays: a shown conflict must never
        # cite a source/target pairing that was never applied.
        live = _live_conflicts(update_set, name, name_resolved, raw_conflicts)

        # Counts every name a conflict was ever recorded for, not just
        # the still-live ones: a fallback revert always looks resolved
        # to `_live_conflicts` even when the reverted pairing is, in
        # reality, still incompatible.
        if raw_conflicts:
            packages_with_conflicts += 1

        status = self._determine_status(pkg, original, name_resolved, raw_conflicts)

        # Applies what THIS declaration resolves to, which for a
        # duplicated name may legitimately differ from the name-level
        # summary below (see the docstring).
        if instance_resolved is not None:
            pkg.recommended_version = instance_resolved
            self._refresh_recommended_metadata(pkg, instance_resolved)
        pkg.set_conflicts(live)

        resolved_versions[name] = PackageResolution(
            name=name,
            original=original,
            resolved=name_resolved,
            status=status,
            conflicts=raw_conflicts,
            compatible_alternative=compatible_alt,
        )

    return ResolutionResult(
        resolved_versions=resolved_versions,
        total_packages=len(packages),
        packages_with_conflicts=packages_with_conflicts,
        iterations_used=iterations_used,
        converged=converged,
    )
find_compatible_version
Python
find_compatible_version(
    conflict_set: ConflictSet,
    available_versions: List[str],
    min_version: Optional[str] = None,
) -> Optional[str]

Pick the highest version from available_versions that satisfies every constraint in conflict_set and is at least min_version.

PARAMETER DESCRIPTION
conflict_set

Aggregated conflicts for a single package.

TYPE: ConflictSet

available_versions

Candidate versions (any order; the conflict_set itself determines compatibility). Typically pre-filtered to only include versions within the current major.

TYPE: List[str]

min_version

If provided, discard any candidate that parses below this version. Typically the currently-installed version.

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
Optional[str]

A compatible version string, or None when no candidate

Optional[str]

passes all filters.

Source code in depkeeper/core/dependency_analyzer.py
Python
def find_compatible_version(
    self,
    conflict_set: ConflictSet,
    available_versions: List[str],
    min_version: Optional[str] = None,
) -> Optional[str]:
    """Pick the highest version from *available_versions* that satisfies
    every constraint in *conflict_set* and is at least *min_version*.

    Args:
        conflict_set: Aggregated conflicts for a single package.
        available_versions: Candidate versions (any order; the
            conflict_set itself determines compatibility). Typically
            pre-filtered to only include versions within the current
            major.
        min_version: If provided, discard any candidate that parses
            below this version. Typically the currently-installed
            version.

    Returns:
        A compatible version string, or ``None`` when no candidate
        passes all filters.
    """
    if not conflict_set.has_conflicts():
        return None

    compatible: Optional[str] = conflict_set.get_max_compatible_version(
        available_versions
    )

    # A candidate below the installed version would be a downgrade the
    # caller never asked for, so reject rather than propose it.
    if compatible and min_version:
        try:
            if parse(compatible) < parse(min_version):
                return None
        except InvalidVersion:
            return None

    return compatible

ResolutionResult

ResolutionResult dataclass

Python
ResolutionResult(
    resolved_versions: Dict[str, PackageResolution],
    total_packages: int,
    packages_with_conflicts: int,
    iterations_used: int,
    converged: bool,
)

Complete result of dependency conflict resolution.

ATTRIBUTE DESCRIPTION
resolved_versions

Map of package name → resolution details.

TYPE: Dict[str, PackageResolution]

total_packages

Total number of packages analyzed.

TYPE: int

packages_with_conflicts

Number of packages that have conflicts.

TYPE: int

iterations_used

How many resolution iterations were performed.

TYPE: int

converged

Whether resolution reached a stable state (True) or hit the iteration limit (False).

TYPE: bool

Methods:
get_changed_packages
Python
get_changed_packages() -> List[PackageResolution]

Return packages whose resolved version differs from the original.

Source code in depkeeper/core/dependency_analyzer.py
Python
def get_changed_packages(self) -> List[PackageResolution]:
    """Return packages whose resolved version differs from the original."""
    return [r for r in self.resolved_versions.values() if r.was_changed()]
get_conflicts
Python
get_conflicts() -> List[PackageResolution]

Return packages that had at least one conflict recorded.

Source code in depkeeper/core/dependency_analyzer.py
Python
def get_conflicts(self) -> List[PackageResolution]:
    """Return packages that had at least one conflict recorded."""
    return [r for r in self.resolved_versions.values() if r.has_conflicts()]
summary
Python
summary() -> str

Render a human-readable summary of the resolution run.

RETURNS DESCRIPTION
str

Multi-line summary covering totals, convergence, conflicts and

str

every version change.

Source code in depkeeper/core/dependency_analyzer.py
Python
def summary(self) -> str:
    """Render a human-readable summary of the resolution run.

    Returns:
        Multi-line summary covering totals, convergence, conflicts and
        every version change.
    """
    lines = [
        "Resolution Summary:",
        "=" * 50,
        f"Total packages: {self.total_packages}",
        f"Packages with conflicts: {self.packages_with_conflicts}",
        f"Packages changed: {len(self.get_changed_packages())}",
        f"Converged: {'Yes' if self.converged else 'No'} ({self.iterations_used} iterations)",
        "",
    ]

    if self.packages_with_conflicts > 0:
        lines.append("Packages with conflicts:")
        for pkg in self.get_conflicts():
            lines.append(f"  • {pkg.name}: {pkg.original}{pkg.resolved}")
            for conflict in pkg.conflicts:
                lines.append(
                    f"    - {conflict.source_package} requires {conflict.required_spec}"
                )
            if pkg.compatible_alternative:
                lines.append(
                    f"    Compatible alternative: {pkg.compatible_alternative}"
                )
        lines.append("")

    changed = self.get_changed_packages()
    if changed:
        lines.append("Version changes:")
        for pkg in changed:
            lines.append(
                f"  • {pkg.name}: {pkg.original}{pkg.resolved} ({pkg.status.value})"
            )

    return "\n".join(lines)

PackageResolution

PackageResolution dataclass

Python
PackageResolution(
    name: str,
    original: Optional[str],
    resolved: Optional[str],
    status: ResolutionStatus,
    conflicts: List[Conflict],
    compatible_alternative: Optional[str] = None,
)

Resolution details for a single package.

ATTRIBUTE DESCRIPTION
name

Package name (normalized).

TYPE: str

original

Version that was initially proposed (from recommended_version or current_version).

TYPE: Optional[str]

resolved

Final version chosen after conflict resolution. This is the version that is applied — Package.recommended_version is set to exactly this value.

TYPE: Optional[str]

status

Why this version was chosen.

TYPE: ResolutionStatus

conflicts

Every conflict recorded for this package during resolution, including ones a later iteration went on to resolve.

TYPE: List[Conflict]

compatible_alternative

Advisory only. Best version satisfying all recorded conflicts at once, or None if no such version exists. It is adopted into resolved only when the resolution loop left a conflict unresolved; otherwise it is display data and does not affect what gets written.

TYPE: Optional[str]

Methods:
was_changed
Python
was_changed() -> bool

Return True when the resolved version differs from the original.

Source code in depkeeper/core/dependency_analyzer.py
Python
def was_changed(self) -> bool:
    """Return ``True`` when the resolved version differs from the original."""
    return self.original != self.resolved
has_conflicts
Python
has_conflicts() -> bool

Return True when any conflict was recorded for this package.

Source code in depkeeper/core/dependency_analyzer.py
Python
def has_conflicts(self) -> bool:
    """Return ``True`` when any conflict was recorded for this package."""
    return len(self.conflicts) > 0

ResolutionStatus

ResolutionStatus

Bases: Enum

Outcome of version resolution for a single package.


Models

Requirement

Requirement dataclass

Python
Requirement(
    name: str,
    specs: List[Tuple[str, str]] = list(),
    extras: List[str] = list(),
    markers: Optional[str] = None,
    url: Optional[str] = None,
    editable: bool = False,
    hashes: List[str] = list(),
    comment: Optional[str] = None,
    line_number: int = 0,
    raw_line: Optional[str] = None,
    source_file: Optional[str] = None,
)

A single requirement line from a requirements file.

ATTRIBUTE DESCRIPTION
name

Canonical package name.

TYPE: str

specs

List of (operator, version) specifiers.

TYPE: List[Tuple[str, str]]

extras

Optional extras to install.

TYPE: List[str]

markers

Environment marker expression (PEP 508).

TYPE: Optional[str]

url

Direct URL or VCS source.

TYPE: Optional[str]

editable

Whether this is an editable install (-e).

TYPE: bool

hashes

Hash values used for verification.

TYPE: List[str]

comment

Inline comment without the # prefix.

TYPE: Optional[str]

line_number

Original line number in the source file.

TYPE: int

raw_line

Original unmodified line text.

TYPE: Optional[str]

source_file

Absolute path of the file this requirement was parsed from. Requirements pulled in via -r/--requirement includes retain the path of the included file, not the parent. Used by the update writer to rewrite the correct file. Excluded from equality comparison as it is provenance metadata, not part of the requirement's semantic identity.

TYPE: Optional[str]

Methods:
to_string
Python
to_string(
    *,
    include_hashes: bool = True,
    include_comment: bool = True
) -> str

Render the canonical requirements.txt representation.

Version specifiers are omitted when url is set, because a direct URL/VCS/local-path reference cannot carry a version specifier (doing so yields an uninstallable line).

PARAMETER DESCRIPTION
include_hashes

Whether to include --hash= entries.

TYPE: bool DEFAULT: True

include_comment

Whether to include inline comments.

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
str

Formatted requirement string.

Source code in depkeeper/models/requirement.py
Python
def to_string(
    self,
    *,
    include_hashes: bool = True,
    include_comment: bool = True,
) -> str:
    """Render the canonical ``requirements.txt`` representation.

    Version specifiers are omitted when `url` is set, because a
    direct URL/VCS/local-path reference cannot carry a version specifier
    (doing so yields an uninstallable line).

    Args:
        include_hashes: Whether to include ``--hash=`` entries.
        include_comment: Whether to include inline comments.

    Returns:
        Formatted requirement string.
    """
    parts: List[str] = []

    if self.editable:
        parts.append("-e")

    if self.url:
        requirement = self.url
    else:
        requirement = self.name

    if self.extras:
        requirement += f"[{','.join(self.extras)}]"

    if self.specs and not self.url:
        requirement += ",".join(
            f"{operator}{version}" for operator, version in self.specs
        )

    parts.append(requirement)

    if self.markers:
        parts.append(f"; {self.markers}")

    result = " ".join(parts)

    if include_hashes:
        for hash_value in self.hashes:
            result += f" --hash={hash_value}"

    if include_comment and self.comment:
        result += f"  # {self.comment}"

    return result
update_version
Python
update_version(
    new_version: str,
    *,
    pin: bool = False,
    preserve_trailing_newline: bool = True,
    allow_hash_removal: bool = False
) -> str

Return a requirement string updated to the given version.

By default only the specifiers that describe the currently selected version are rewritten. Upper bounds (<, <=), exclusions (!=) and wildcard bands (==2.*) are deliberate compatibility statements authored by the user and are preserved verbatim, so celery[redis]>=5.0,<6.0 updated to 5.5.3 becomes celery[redis]>=5.5.3,<6.0 rather than a hard == pin. Pass pin=True to opt into replacing every specifier with ==new_version.

Hashes are version-specific. Updating the version while silently removing --hash entries degrades integrity guarantees and can break hash-pinned installs (pip --require-hashes mode). To prevent silent security regressions, hashed requirements are rejected by default unless allow_hash_removal=True is explicitly provided by the caller.

PARAMETER DESCRIPTION
new_version

Version string to apply.

TYPE: str

pin

Replace all specifiers with an exact ==new_version pin, discarding any declared range. Defaults to False.

TYPE: bool DEFAULT: False

preserve_trailing_newline

Ensure output ends with \n.

TYPE: bool DEFAULT: True

allow_hash_removal

Allow updating requirements that include --hash entries by removing those hashes in the output. Defaults to False.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
str

Updated requirement string.

RAISES DESCRIPTION
ValueError

The requirement has one or more --hash entries and allow_hash_removal is False; or the requirement's own preserved constraints exclude new_version (which would produce an unsatisfiable line).

Source code in depkeeper/models/requirement.py
Python
def update_version(
    self,
    new_version: str,
    *,
    pin: bool = False,
    preserve_trailing_newline: bool = True,
    allow_hash_removal: bool = False,
) -> str:
    """Return a requirement string updated to the given version.

    By default only the specifiers that describe the *currently selected*
    version are rewritten. Upper bounds (``<``, ``<=``), exclusions
    (``!=``) and wildcard bands (``==2.*``) are deliberate compatibility
    statements authored by the user and are preserved verbatim, so
    ``celery[redis]>=5.0,<6.0`` updated to ``5.5.3`` becomes
    ``celery[redis]>=5.5.3,<6.0`` rather than a hard ``==`` pin. Pass
    ``pin=True`` to opt into replacing every specifier with
    ``==new_version``.

    Hashes are version-specific. Updating the version while silently
    removing ``--hash`` entries degrades integrity guarantees and can break
    hash-pinned installs (pip ``--require-hashes`` mode). To prevent silent
    security regressions, hashed requirements are rejected by default unless
    ``allow_hash_removal=True`` is explicitly provided by the caller.

    Args:
        new_version: Version string to apply.
        pin: Replace all specifiers with an exact ``==new_version`` pin,
            discarding any declared range. Defaults to ``False``.
        preserve_trailing_newline: Ensure output ends with ``\\n``.
        allow_hash_removal: Allow updating requirements that include
            ``--hash`` entries by removing those hashes in the output.
            Defaults to ``False``.

    Returns:
        Updated requirement string.

    Raises:
        ValueError: The requirement has one or more ``--hash`` entries and
            ``allow_hash_removal`` is ``False``; or the requirement's own
            preserved constraints exclude *new_version* (which would
            produce an unsatisfiable line).
    """
    if self.hashes and not allow_hash_removal:
        raise ValueError(
            f"Cannot update hashed requirement '{self.name}' without explicit "
            "hash-removal opt-in"
        )

    if pin:
        new_specs: List[Tuple[str, str]] = [("==", new_version)]
    else:
        new_specs = rewrite_version_specs(self.specs, new_version)

    # Specifiers are not rendered for direct references, so an unsatisfiable
    # combination cannot reach the file in that case.
    if not self.url and not specs_allow_version(new_specs, new_version):
        raise ValueError(
            f"Cannot update '{self.name}' to {new_version}: the declared "
            f"constraint '{specs_to_string(self.specs)}' excludes that "
            "version. Relax the constraint, or pass pin=True to replace it"
        )

    updated = Requirement(
        name=self.name,
        specs=new_specs,
        extras=list(self.extras),
        markers=self.markers,
        url=self.url,
        editable=self.editable,
        hashes=[],
        comment=self.comment,
        line_number=self.line_number,
        source_file=self.source_file,
    )

    result = updated.to_string(
        include_hashes=False,
        include_comment=True,
    )

    if preserve_trailing_newline and not result.endswith("\n"):
        result += "\n"

    return result

Package

Package dataclass

Python
Package(
    name: str,
    current_version: Optional[str] = None,
    latest_version: Optional[str] = None,
    recommended_version: Optional[str] = None,
    metadata: Dict[str, Any] = dict(),
    conflicts: List[Conflict] = list(),
    _parsed_versions: Dict[str, Optional[Version]] = dict(),
)

A Python package with version and compatibility state.

ATTRIBUTE DESCRIPTION
name

Normalized package name.

TYPE: str

current_version

Installed or specified version.

TYPE: Optional[str]

latest_version

Latest known upstream version (informational only).

TYPE: Optional[str]

recommended_version

Best version considering constraints.

TYPE: Optional[str]

metadata

Arbitrary metadata (typically from PyPI).

TYPE: Dict[str, Any]

conflicts

Dependency conflicts affecting this package.

TYPE: List[Conflict]

Attributes
current property
Python
current: Optional[Version]

Parsed current version.

latest property
Python
latest: Optional[Version]

Parsed latest version (informational only).

recommended property
Python
recommended: Optional[Version]

Parsed recommended version.

requires_downgrade property
Python
requires_downgrade: bool

Whether the recommended version is lower than the current one.

True when conflict resolution or Python incompatibility forced the recommendation below the version declared in the requirements file.

Methods:
has_conflicts
Python
has_conflicts() -> bool

Return True when dependency conflicts were recorded.

Source code in depkeeper/models/package.py
Python
def has_conflicts(self) -> bool:
    """Return ``True`` when dependency conflicts were recorded."""
    return bool(self.conflicts)
set_conflicts
Python
set_conflicts(
    conflicts: List[Conflict],
    *,
    resolved_version: Optional[str] = None
) -> None

Record dependency conflicts and optionally override the recommendation.

PARAMETER DESCRIPTION
conflicts

Detected conflicts affecting this package.

TYPE: List[Conflict]

resolved_version

Version that resolves the conflicts. When given, it replaces recommended_version.

TYPE: Optional[str] DEFAULT: None

Source code in depkeeper/models/package.py
Python
def set_conflicts(
    self,
    conflicts: List[Conflict],
    *,
    resolved_version: Optional[str] = None,
) -> None:
    """Record dependency conflicts and optionally override the recommendation.

    Args:
        conflicts: Detected conflicts affecting this package.
        resolved_version: Version that resolves the conflicts. When given,
            it replaces `recommended_version`.
    """
    self.conflicts = conflicts
    if resolved_version:
        self.recommended_version = resolved_version
get_conflict_summary
Python
get_conflict_summary() -> List[str]

Return one short conflict summary per recorded conflict.

Source code in depkeeper/models/package.py
Python
def get_conflict_summary(self) -> List[str]:
    """Return one short conflict summary per recorded conflict."""
    return [conflict.to_short_string() for conflict in self.conflicts]
get_conflict_details
Python
get_conflict_details() -> List[str]

Return one detailed description per recorded conflict.

Source code in depkeeper/models/package.py
Python
def get_conflict_details(self) -> List[str]:
    """Return one detailed description per recorded conflict."""
    return [conflict.to_display_string() for conflict in self.conflicts]
has_update
Python
has_update() -> bool

Return True when the recommended version is newer than current.

Source code in depkeeper/models/package.py
Python
def has_update(self) -> bool:
    """Return ``True`` when the recommended version is newer than current."""
    return (
        self.current is not None
        and self.recommended is not None
        and self.recommended > self.current
    )
get_version_python_req
Python
get_version_python_req(version_key: str) -> Optional[str]

Return the requires_python specifier for one version slot.

PARAMETER DESCRIPTION
version_key

One of "current", "latest" or "recommended".

TYPE: str

RETURNS DESCRIPTION
Optional[str]

The specifier string, or None when the upload omitted it or

Optional[str]

the slot has no metadata.

Source code in depkeeper/models/package.py
Python
def get_version_python_req(self, version_key: str) -> Optional[str]:
    """Return the ``requires_python`` specifier for one version slot.

    Args:
        version_key: One of ``"current"``, ``"latest"`` or
            ``"recommended"``.

    Returns:
        The specifier string, or ``None`` when the upload omitted it or
        the slot has no metadata.
    """
    meta = self.metadata.get(f"{version_key}_metadata")
    if isinstance(meta, dict):
        value = meta.get("requires_python")
        return value if isinstance(value, str) else None
    return None
get_status_summary
Python
get_status_summary() -> Tuple[str, str, str, Optional[str]]

Compute the high-level status used by line-based output.

The status ladder is ordered by severity: a missing recommendation means PyPI data was unavailable, and a required downgrade outranks a plain "outdated" because it signals an incompatible pin.

RETURNS DESCRIPTION
str

Tuple of (status, installed, latest, recommended), where

str

status is one of no-update, install, downgrade,

str

outdated or latest.

Source code in depkeeper/models/package.py
Python
def get_status_summary(self) -> Tuple[str, str, str, Optional[str]]:
    """Compute the high-level status used by line-based output.

    The status ladder is ordered by severity: a missing recommendation
    means PyPI data was unavailable, and a required downgrade outranks a
    plain "outdated" because it signals an incompatible pin.

    Returns:
        Tuple of ``(status, installed, latest, recommended)``, where
        *status* is one of ``no-update``, ``install``, ``downgrade``,
        ``outdated`` or ``latest``.
    """
    installed = self.current_version or "none"
    latest = self.latest_version or "error"
    recommended = self.recommended_version

    if not self.recommended_version:
        status = "no-update"
    elif not self.current_version:
        status = "install"
    elif self.requires_downgrade:
        status = "downgrade"
    elif self.has_update():
        status = "outdated"
    else:
        status = "latest"

    return status, installed, latest, recommended
to_json
Python
to_json() -> Dict[str, Any]

Serialize package state to a JSON-compatible dictionary.

Optional sections (versions, update_type, python_requirements, conflicts) are omitted when empty, so consumers must treat every key except name and status as optional.

RETURNS DESCRIPTION
Dict[str, Any]

JSON-safe package representation.

Source code in depkeeper/models/package.py
Python
def to_json(self) -> Dict[str, Any]:
    """Serialize package state to a JSON-compatible dictionary.

    Optional sections (``versions``, ``update_type``,
    ``python_requirements``, ``conflicts``) are omitted when empty, so
    consumers must treat every key except ``name`` and ``status`` as
    optional.

    Returns:
        JSON-safe package representation.
    """
    # Mirrors the status ladder in get_status_summary(); keep both in sync.
    if not self.recommended_version:
        status = "no-update"
    elif not self.current_version:
        status = "install"
    elif self.requires_downgrade:
        status = "downgrade"
    elif self.has_update():
        status = "outdated"
    else:
        status = "latest"

    entry: Dict[str, Any] = {
        "name": self.name,
        "status": status,
    }

    versions: Dict[str, str] = {}
    if self.current_version:
        versions["current"] = self.current_version
    if self.latest_version:
        versions["latest"] = self.latest_version
    if self.recommended_version:
        versions["recommended"] = self.recommended_version

    if versions:
        entry["versions"] = versions

    if status in ("outdated", "downgrade"):
        entry["update_type"] = get_update_type(
            self.current_version,
            self.recommended_version,
        )

    python_reqs: Dict[str, str] = {}
    for key in ("current", "latest", "recommended"):
        req = self.get_version_python_req(key)
        if req:
            python_reqs[key] = req

    if python_reqs:
        entry["python_requirements"] = python_reqs

    if self.has_conflicts():
        entry["conflicts"] = [c.to_json() for c in self.conflicts]

    if status == "no-update":
        entry["error"] = "Package information unavailable"

    return entry
render_python_compatibility
Python
render_python_compatibility() -> str

Render Python compatibility as a multi-line cell for the table view.

RETURNS DESCRIPTION
str

Newline-separated requirement lines, or a dimmed placeholder when

str

no requires_python metadata is known.

Source code in depkeeper/models/package.py
Python
def render_python_compatibility(self) -> str:
    """Render Python compatibility as a multi-line cell for the table view.

    Returns:
        Newline-separated requirement lines, or a dimmed placeholder when
        no ``requires_python`` metadata is known.
    """
    parts: List[str] = []

    current_req = self.get_version_python_req("current")
    if current_req:
        parts.append(f"Current: {current_req}")

    latest_req = self.get_version_python_req("latest")
    if latest_req:
        parts.append(f"Latest: {latest_req}")

    if self.has_update():
        rec_req = self.get_version_python_req("recommended")
        if rec_req:
            parts.append(f"Recommended:{rec_req}")

    return "\n".join(parts) if parts else "[dim]-[/dim]"
get_display_data
Python
get_display_data() -> Dict[str, Any]

Compute the derived values required for UI rendering.

Centralizing this keeps the renderers free of status logic, so table and simple output can never disagree about a package's state.

RETURNS DESCRIPTION
Dict[str, Any]

Dictionary of derived display properties.

Source code in depkeeper/models/package.py
Python
def get_display_data(self) -> Dict[str, Any]:
    """Compute the derived values required for UI rendering.

    Centralizing this keeps the renderers free of status logic, so table
    and simple output can never disagree about a package's state.

    Returns:
        Dictionary of derived display properties.
    """
    update_available = self.has_update()
    downgrade_required = self.requires_downgrade

    return {
        "update_available": update_available,
        "requires_downgrade": downgrade_required,
        "update_target": self.recommended_version,
        "update_type": (
            get_update_type(self.current_version, self.recommended_version)
            if update_available or downgrade_required
            else None
        ),
        "has_conflicts": self.has_conflicts(),
        "conflict_summary": (
            self.get_conflict_summary() if self.has_conflicts() else []
        ),
    }

Conflict

Conflict dataclass

Python
Conflict(
    source_package: str,
    target_package: str,
    required_spec: str,
    conflicting_version: str,
    source_version: Optional[str] = None,
)

A dependency conflict between two packages.

Frozen so a conflict can be hashed and deduplicated while the resolution loop accumulates findings across iterations.

ATTRIBUTE DESCRIPTION
source_package

Package declaring the dependency.

TYPE: str

target_package

Package being constrained.

TYPE: str

required_spec

Version specifier required by the source package.

TYPE: str

conflicting_version

Version that violates the requirement.

TYPE: str

source_version

Version of the source package, if known.

TYPE: Optional[str]

Methods:
to_display_string
Python
to_display_string() -> str

Return a human-readable description of the conflict.

Source code in depkeeper/models/conflict.py
Python
def to_display_string(self) -> str:
    """Return a human-readable description of the conflict."""
    source = (
        f"{self.source_package}=={self.source_version}"
        if self.source_version
        else self.source_package
    )
    return f"{source} requires {self.target_package}{self.required_spec}"
to_short_string
Python
to_short_string() -> str

Return a compact conflict summary.

Source code in depkeeper/models/conflict.py
Python
def to_short_string(self) -> str:
    """Return a compact conflict summary."""
    return f"{self.source_package} needs {self.required_spec}"
to_json
Python
to_json() -> Dict[str, Optional[str]]

Return a JSON-serializable representation.

Source code in depkeeper/models/conflict.py
Python
def to_json(self) -> Dict[str, Optional[str]]:
    """Return a JSON-serializable representation."""
    return {
        "source_package": self.source_package,
        "source_version": self.source_version,
        "target_package": self.target_package,
        "required_spec": self.required_spec,
        "conflicting_version": self.conflicting_version,
    }

ConflictSet

ConflictSet dataclass

Python
ConflictSet(
    package_name: str, conflicts: List[Conflict] = list()
)

Collection of conflicts affecting a single package.

ATTRIBUTE DESCRIPTION
package_name

Name of the affected package.

TYPE: str

conflicts

Conflicts associated with this package.

TYPE: List[Conflict]

Methods:
add_conflict
Python
add_conflict(conflict: Conflict) -> None

Append a conflict to the set.

Source code in depkeeper/models/conflict.py
Python
def add_conflict(self, conflict: Conflict) -> None:
    """Append a conflict to the set."""
    self.conflicts.append(conflict)
has_conflicts
Python
has_conflicts() -> bool

Return True when the set holds at least one conflict.

Source code in depkeeper/models/conflict.py
Python
def has_conflicts(self) -> bool:
    """Return ``True`` when the set holds at least one conflict."""
    return bool(self.conflicts)
get_max_compatible_version
Python
get_max_compatible_version(
    available_versions: List[str],
) -> Optional[str]

Return the highest version satisfying every recorded conflict.

All required_spec values are intersected into a single specifier set, so the answer is compatible with all conflicting dependents at once. Pre-releases are never returned.

PARAMETER DESCRIPTION
available_versions

Candidate version strings, in any order.

TYPE: List[str]

RETURNS DESCRIPTION
Optional[str]

The highest compatible version, or None when the set is empty,

Optional[str]

a specifier is unparseable, or no candidate satisfies them all.

Source code in depkeeper/models/conflict.py
Python
def get_max_compatible_version(
    self,
    available_versions: List[str],
) -> Optional[str]:
    """Return the highest version satisfying *every* recorded conflict.

    All ``required_spec`` values are intersected into a single specifier
    set, so the answer is compatible with all conflicting dependents at
    once. Pre-releases are never returned.

    Args:
        available_versions: Candidate version strings, in any order.

    Returns:
        The highest compatible version, or ``None`` when the set is empty,
        a specifier is unparseable, or no candidate satisfies them all.
    """
    if not self.conflicts:
        return None

    try:
        combined_spec = SpecifierSet(
            ",".join(conflict.required_spec for conflict in self.conflicts)
        )
    except InvalidSpecifier:
        return None

    compatible: List[Tuple[str, Version]] = []

    for version_str in available_versions:
        try:
            parsed = parse(version_str)
            if not isinstance(parsed, Version) or parsed.is_prerelease:
                continue
            if parsed in combined_spec:
                compatible.append((version_str, parsed))
        except InvalidVersion:
            continue

    if not compatible:
        return None

    compatible.sort(key=lambda item: item[1], reverse=True)
    return compatible[0][0]

Configuration

DepKeeperConfig dataclass

Python
DepKeeperConfig(
    check_conflicts: bool = DEFAULT_CHECK_CONFLICTS,
    strict_version_matching: bool = DEFAULT_STRICT_VERSION_MATCHING,
    source_path: Optional[Path] = None,
)

Parsed and validated depkeeper configuration.

Contains settings from depkeeper.toml or pyproject.toml. All fields have defaults, so empty config files are valid.

ATTRIBUTE DESCRIPTION
check_conflicts

Enable dependency conflict resolution. When True, analyzes transitive dependencies to avoid conflicts.

TYPE: bool

strict_version_matching

Only consider exact pins (==) as current versions. Ignores range constraints like >=2.0.

TYPE: bool

source_path

Path to loaded config file, or None if using defaults.

TYPE: Optional[Path]

Methods:

to_log_dict
Python
to_log_dict() -> Dict[str, Any]

Return configuration as dictionary for debug logging.

Excludes source_path metadata.

RETURNS DESCRIPTION
Dict[str, Any]

Dictionary of configuration option names to values.

Source code in depkeeper/config.py
Python
def to_log_dict(self) -> Dict[str, Any]:
    """Return configuration as dictionary for debug logging.

    Excludes ``source_path`` metadata.

    Returns:
        Dictionary of configuration option names to values.
    """
    return {
        "check_conflicts": self.check_conflicts,
        "strict_version_matching": self.strict_version_matching,
    }

load_config

Python
load_config(
    config_path: Optional[Path] = None,
) -> DepKeeperConfig

Load and validate depkeeper configuration.

Discovers config file (or uses provided path), parses and validates it. Returns config with defaults if no file found.

Handles both depkeeper.toml and pyproject.toml formats.

PARAMETER DESCRIPTION
config_path

Explicit path to config file. If None, uses auto-discovery (see discover_config_file).

TYPE: Optional[Path] DEFAULT: None

RETURNS DESCRIPTION
DepKeeperConfig

Validated DepKeeperConfig with values from file or defaults.

RAISES DESCRIPTION
ConfigError

File cannot be parsed, has unknown keys, or invalid values.

Source code in depkeeper/config.py
Python
def load_config(config_path: Optional[Path] = None) -> DepKeeperConfig:
    """Load and validate depkeeper configuration.

    Discovers config file (or uses provided path), parses and validates it.
    Returns config with defaults if no file found.

    Handles both ``depkeeper.toml`` and ``pyproject.toml`` formats.

    Args:
        config_path: Explicit path to config file. If ``None``, uses
            auto-discovery (see `discover_config_file`).

    Returns:
        Validated `DepKeeperConfig` with values from file or defaults.

    Raises:
        ConfigError: File cannot be parsed, has unknown keys, or invalid values.
    """
    resolved = discover_config_file(config_path)

    if resolved is None:
        logger.debug("No config file found, using defaults")
        return DepKeeperConfig()

    logger.info("Loading configuration from %s", resolved)
    raw = _read_toml(resolved)

    # The two supported files nest the settings differently.
    if resolved.name == "pyproject.toml":
        section = raw.get("tool", {}).get("depkeeper", {})
    else:
        section = raw.get("depkeeper", {})

    if not section:
        logger.debug("Config file found but no depkeeper section — using defaults")
        return DepKeeperConfig(source_path=resolved)

    config = _parse_section(section, config_path=str(resolved))
    config.source_path = resolved

    logger.debug("Loaded configuration: %s", config.to_log_dict())
    return config

discover_config_file

Python
discover_config_file(
    explicit_path: Optional[Path] = None,
) -> Optional[Path]

Find the configuration file to load.

Search order:

  1. explicit_path (from --config or DEPKEEPER_CONFIG)
  2. depkeeper.toml in current directory
  3. pyproject.toml with [tool.depkeeper] section in current directory

Validates pyproject.toml contains depkeeper section before using it.

PARAMETER DESCRIPTION
explicit_path

Explicit config path. If provided, must exist.

TYPE: Optional[Path] DEFAULT: None

RETURNS DESCRIPTION
Optional[Path]

Resolved path to config file, or None if not found.

RAISES DESCRIPTION
ConfigError

Explicit path provided but does not exist.

Source code in depkeeper/config.py
Python
def discover_config_file(explicit_path: Optional[Path] = None) -> Optional[Path]:
    """Find the configuration file to load.

    Search order:

    1. ``explicit_path`` (from ``--config`` or ``DEPKEEPER_CONFIG``)
    2. ``depkeeper.toml`` in current directory
    3. ``pyproject.toml`` with ``[tool.depkeeper]`` section in current directory

    Validates ``pyproject.toml`` contains depkeeper section before using it.

    Args:
        explicit_path: Explicit config path. If provided, must exist.

    Returns:
        Resolved path to config file, or ``None`` if not found.

    Raises:
        ConfigError: Explicit path provided but does not exist.
    """
    # 1. Explicit path takes priority
    if explicit_path is not None:
        resolved = explicit_path.resolve()
        if not resolved.is_file():
            raise ConfigError(
                f"Configuration file not found: {explicit_path}",
                config_path=str(explicit_path),
            )
        logger.debug("Using explicit config: %s", resolved)
        return resolved

    cwd = Path.cwd()

    depkeeper_toml = cwd / "depkeeper.toml"
    if depkeeper_toml.is_file():
        logger.debug("Found depkeeper.toml: %s", depkeeper_toml)
        return depkeeper_toml

    # pyproject.toml is only adopted when it actually configures depkeeper;
    # otherwise a project that merely has a pyproject.toml would shadow the
    # built-in defaults.
    pyproject_toml = cwd / "pyproject.toml"
    if pyproject_toml.is_file():
        if _pyproject_has_depkeeper_section(pyproject_toml):
            logger.debug("Found [tool.depkeeper] in pyproject.toml: %s", pyproject_toml)
            return pyproject_toml

    logger.debug("No configuration file found")
    return None

Utilities

HTTP client

HTTPClient

Python
HTTPClient(
    *,
    timeout: int = DEFAULT_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    rate_limit_delay: float = 0.0,
    verify_ssl: bool = True,
    user_agent: Optional[str] = None,
    max_concurrency: int = 10
)

Asynchronous HTTP client with retries, rate limiting, and concurrency control.

PARAMETER DESCRIPTION
timeout

Request timeout in seconds.

TYPE: int DEFAULT: DEFAULT_TIMEOUT

max_retries

Maximum number of retry attempts.

TYPE: int DEFAULT: DEFAULT_MAX_RETRIES

rate_limit_delay

Minimum delay (seconds) between requests.

TYPE: float DEFAULT: 0.0

verify_ssl

Whether to verify SSL certificates.

TYPE: bool DEFAULT: True

user_agent

Custom User-Agent header value.

TYPE: Optional[str] DEFAULT: None

max_concurrency

Maximum number of concurrent requests.

TYPE: int DEFAULT: 10

Example

async with HTTPClient() as client: ... data = await client.get_json("https://pypi.org/pypi/requests/json")

Source code in depkeeper/utils/http.py
Python
def __init__(
    self,
    *,
    timeout: int = DEFAULT_TIMEOUT,
    max_retries: int = DEFAULT_MAX_RETRIES,
    rate_limit_delay: float = 0.0,
    verify_ssl: bool = True,
    user_agent: Optional[str] = None,
    max_concurrency: int = 10,
) -> None:
    self.timeout = timeout
    self.max_retries = max_retries
    self.rate_limit_delay = rate_limit_delay
    self.verify_ssl = verify_ssl
    self.user_agent = user_agent or USER_AGENT_TEMPLATE.format(version=__version__)
    self.max_concurrency = max_concurrency

    self._client: Optional[httpx.AsyncClient] = None
    self._last_request_time: float = 0.0
    self._rate_limit_lock = asyncio.Lock()
    self._semaphore = asyncio.Semaphore(max_concurrency)
    self._max_429_retries: int = 5
Methods:
close async
Python
close() -> None

Close the underlying HTTP client.

Source code in depkeeper/utils/http.py
Python
async def close(self) -> None:
    """Close the underlying HTTP client."""
    if self._client is not None:
        await self._client.aclose()
        self._client = None
get async
Python
get(url: str, **kwargs: Any) -> Response

Perform a GET request.

PARAMETER DESCRIPTION
url

Target URL.

TYPE: str

**kwargs

Forwarded to httpx.AsyncClient.request.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
Response

The successful response.

RAISES DESCRIPTION
NetworkError

The request failed; see _request_with_retry.

Source code in depkeeper/utils/http.py
Python
async def get(self, url: str, **kwargs: Any) -> httpx.Response:
    """Perform a GET request.

    Args:
        url: Target URL.
        **kwargs: Forwarded to ``httpx.AsyncClient.request``.

    Returns:
        The successful response.

    Raises:
        NetworkError: The request failed; see `_request_with_retry`.
    """
    return await self._request_with_retry("GET", url, **kwargs)
post async
Python
post(url: str, **kwargs: Any) -> Response

Perform a POST request.

PARAMETER DESCRIPTION
url

Target URL.

TYPE: str

**kwargs

Forwarded to httpx.AsyncClient.request.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
Response

The successful response.

RAISES DESCRIPTION
NetworkError

The request failed; see _request_with_retry.

Source code in depkeeper/utils/http.py
Python
async def post(self, url: str, **kwargs: Any) -> httpx.Response:
    """Perform a POST request.

    Args:
        url: Target URL.
        **kwargs: Forwarded to ``httpx.AsyncClient.request``.

    Returns:
        The successful response.

    Raises:
        NetworkError: The request failed; see `_request_with_retry`.
    """
    return await self._request_with_retry("POST", url, **kwargs)
get_json async
Python
get_json(url: str, **kwargs: Any) -> Dict[str, Any]

Fetch a URL and decode the response as a JSON object.

PARAMETER DESCRIPTION
url

Target URL.

TYPE: str

**kwargs

Forwarded to httpx.AsyncClient.request.

TYPE: Any DEFAULT: {}

RETURNS DESCRIPTION
Dict[str, Any]

The decoded JSON object.

RAISES DESCRIPTION
NetworkError

The request failed, the body is not valid JSON, or the payload is a JSON value other than an object.

Source code in depkeeper/utils/http.py
Python
async def get_json(self, url: str, **kwargs: Any) -> Dict[str, Any]:
    """Fetch a URL and decode the response as a JSON object.

    Args:
        url: Target URL.
        **kwargs: Forwarded to ``httpx.AsyncClient.request``.

    Returns:
        The decoded JSON object.

    Raises:
        NetworkError: The request failed, the body is not valid JSON, or
            the payload is a JSON value other than an object.
    """
    response = await self.get(url, **kwargs)

    try:
        data = response.json()
    except Exception as exc:
        raise NetworkError(
            f"Invalid JSON response from {url}",
            url=url,
            response_body=response.text,
        ) from exc

    if not isinstance(data, dict):
        raise NetworkError(
            f"Expected JSON object from {url}",
            url=url,
            response_body=response.text,
        )

    return cast(Dict[str, Any], data)
batch_get_json async
Python
batch_get_json(
    urls: Iterable[str],
    *,
    progress_callback: Optional[
        Callable[[int, int], None]
    ] = None
) -> Dict[str, Dict[str, Any]]

Fetch multiple JSON endpoints concurrently.

Individual failures are logged and reported as an empty dict rather than raised, so one bad URL cannot abort a batch.

PARAMETER DESCRIPTION
urls

Iterable of URLs to fetch.

TYPE: Iterable[str]

progress_callback

Optional callback invoked as (completed, total) after each response settles.

TYPE: Optional[Callable[[int, int], None]] DEFAULT: None

RETURNS DESCRIPTION
Dict[str, Dict[str, Any]]

Mapping of URL to parsed JSON data. Failed requests map to {}.

Source code in depkeeper/utils/http.py
Python
async def batch_get_json(
    self,
    urls: Iterable[str],
    *,
    progress_callback: Optional[Callable[[int, int], None]] = None,
) -> Dict[str, Dict[str, Any]]:
    """Fetch multiple JSON endpoints concurrently.

    Individual failures are logged and reported as an empty dict rather
    than raised, so one bad URL cannot abort a batch.

    Args:
        urls: Iterable of URLs to fetch.
        progress_callback: Optional callback invoked as
            ``(completed, total)`` after each response settles.

    Returns:
        Mapping of URL to parsed JSON data. Failed requests map to ``{}``.
    """
    url_list = list(urls)
    total = len(url_list)
    completed = 0
    results: Dict[str, Dict[str, Any]] = {}

    tasks = [self.get_json(url) for url in url_list]
    responses = await asyncio.gather(*tasks, return_exceptions=True)

    for url, result in zip(url_list, responses):
        if isinstance(result, BaseException):
            logger.error("Failed to fetch %s: %s", url, result)
            results[url] = {}
        else:
            results[url] = result

        completed += 1
        if progress_callback:
            progress_callback(completed, total)

    return results

Version utilities

get_update_type

Python
get_update_type(
    current_version: Optional[str],
    target_version: Optional[str],
) -> str

Determine the semantic update type between two versions.

PARAMETER DESCRIPTION
current_version

Currently installed version, or None if not installed.

TYPE: Optional[str]

target_version

Target version to compare against.

TYPE: Optional[str]

RETURNS DESCRIPTION
str

One of: - "new" : No current version exists - "same" : Versions are identical - "downgrade" : Target version is lower than current - "major" : Major version change - "minor" : Minor version change - "patch" : Patch-level change - "update" : Update that cannot be classified further - "unknown" : Invalid or unsupported version comparison

Examples:

Python Console Session
>>> get_update_type("1.0.0", "2.0.0")
'major'
>>> get_update_type(None, "1.0.0")
'new'
>>> get_update_type("1.2.3", "1.2.3")
'same'
Source code in depkeeper/utils/version_utils.py
Python
def get_update_type(
    current_version: Optional[str],
    target_version: Optional[str],
) -> str:
    """Determine the semantic update type between two versions.

    Args:
        current_version: Currently installed version, or ``None`` if not installed.
        target_version: Target version to compare against.

    Returns:
        One of:
            - ``"new"``       : No current version exists
            - ``"same"``      : Versions are identical
            - ``"downgrade"`` : Target version is lower than current
            - ``"major"``     : Major version change
            - ``"minor"``     : Minor version change
            - ``"patch"``     : Patch-level change
            - ``"update"``    : Update that cannot be classified further
            - ``"unknown"``   : Invalid or unsupported version comparison

    Examples:
        >>> get_update_type("1.0.0", "2.0.0")
        'major'
        >>> get_update_type(None, "1.0.0")
        'new'
        >>> get_update_type("1.2.3", "1.2.3")
        'same'
    """
    if current_version is None and target_version is None:
        return "unknown"

    if current_version is None:
        return "new"

    if target_version is None:
        return "unknown"

    try:
        current = parse(current_version)
        target = parse(target_version)

        if target == current:
            return "same"

        if target < current:
            return "downgrade"

        return _classify_upgrade(current, target)

    except InvalidVersion:
        return "unknown"

retained_specs

Python
retained_specs(specs: Iterable[Spec]) -> List[Spec]

Return the specifiers that survive a version rewrite unchanged.

A version update only moves the floor of a requirement. Upper bounds (<, <=), exclusions (!=) and wildcard bands (==2.*) are deliberate compatibility statements and are carried through verbatim. Consequently any version depkeeper proposes must satisfy them.

PARAMETER DESCRIPTION
specs

The requirement's declared specifier pairs.

TYPE: Iterable[Spec]

RETURNS DESCRIPTION
List[Spec]

The subset of specs that rewrite_version_specs preserves.

Examples:

Python Console Session
>>> retained_specs([(">=", "5.0"), ("<", "6.0")])
[('<', '6.0')]
>>> retained_specs([("==", "2.20.0")])
[]
>>> retained_specs([("==", "2.*")])
[('==', '2.*')]
Source code in depkeeper/utils/version_utils.py
Python
def retained_specs(specs: Iterable[Spec]) -> List[Spec]:
    """Return the specifiers that survive a version rewrite unchanged.

    A version update only moves the *floor* of a requirement. Upper bounds
    (``<``, ``<=``), exclusions (``!=``) and wildcard bands (``==2.*``) are
    deliberate compatibility statements and are carried through verbatim.
    Consequently any version depkeeper proposes must satisfy them.

    Args:
        specs: The requirement's declared specifier pairs.

    Returns:
        The subset of *specs* that `rewrite_version_specs` preserves.

    Examples:
        >>> retained_specs([(">=", "5.0"), ("<", "6.0")])
        [('<', '6.0')]
        >>> retained_specs([("==", "2.20.0")])
        []
        >>> retained_specs([("==", "2.*")])
        [('==', '2.*')]
    """
    return [
        (operator, version)
        for operator, version in specs
        if _is_retained(operator, version)
    ]

rewrite_version_specs

Python
rewrite_version_specs(
    specs: Sequence[Spec], new_version: str
) -> List[Spec]

Rewrite a specifier set so it targets new_version.

Only the specifiers that describe the currently-selected version are changed:

  • >= and > become >=new_version (> is widened to >= so the newly selected version itself remains installable).
  • ~= keeps the compatible-release form and the author's chosen precision, e.g. ~=2.0 with 2.3.3 becomes ~=2.3.
  • A non-wildcard == / === pin is repinned to new_version.
  • Upper bounds, exclusions and wildcard bands are preserved verbatim.

A requirement with no specifiers gains an exact pin, matching the "add a version pin" behavior of the update command. A requirement that declares only upper bounds/exclusions gains an explicit >= floor so the selected version is actually recorded.

PARAMETER DESCRIPTION
specs

The requirement's declared specifier pairs, in order.

TYPE: Sequence[Spec]

new_version

The version to move the requirement to.

TYPE: str

RETURNS DESCRIPTION
List[Spec]

A new list of specifier pairs. Duplicates introduced by the rewrite

List[Spec]

(e.g. >=2.0,>2.1 collapsing to two identical floors) are removed

List[Spec]

while preserving order.

Examples:

Python Console Session
>>> rewrite_version_specs([(">=", "5.0"), ("<", "6.0")], "5.5.3")
[('>=', '5.5.3'), ('<', '6.0')]
>>> rewrite_version_specs([("~=", "2.0")], "2.3.3")
[('~=', '2.3')]
>>> rewrite_version_specs([], "1.0.0")
[('==', '1.0.0')]
Source code in depkeeper/utils/version_utils.py
Python
def rewrite_version_specs(specs: Sequence[Spec], new_version: str) -> List[Spec]:
    """Rewrite a specifier set so it targets *new_version*.

    Only the specifiers that describe the currently-selected version are
    changed:

    - ``>=`` and ``>`` become ``>=new_version`` (``>`` is widened to ``>=``
      so the newly selected version itself remains installable).
    - ``~=`` keeps the compatible-release form and the author's chosen
      precision, e.g. ``~=2.0`` with ``2.3.3`` becomes ``~=2.3``.
    - A non-wildcard ``==`` / ``===`` pin is repinned to *new_version*.
    - Upper bounds, exclusions and wildcard bands are preserved verbatim.

    A requirement with no specifiers gains an exact pin, matching the
    "add a version pin" behavior of the update command. A requirement that
    declares only upper bounds/exclusions gains an explicit ``>=`` floor so
    the selected version is actually recorded.

    Args:
        specs: The requirement's declared specifier pairs, in order.
        new_version: The version to move the requirement to.

    Returns:
        A new list of specifier pairs. Duplicates introduced by the rewrite
        (e.g. ``>=2.0,>2.1`` collapsing to two identical floors) are removed
        while preserving order.

    Examples:
        >>> rewrite_version_specs([(">=", "5.0"), ("<", "6.0")], "5.5.3")
        [('>=', '5.5.3'), ('<', '6.0')]
        >>> rewrite_version_specs([("~=", "2.0")], "2.3.3")
        [('~=', '2.3')]
        >>> rewrite_version_specs([], "1.0.0")
        [('==', '1.0.0')]
    """
    if not specs:
        return [("==", new_version)]

    rewritten: List[Spec] = []
    replaced = False

    for operator, version in specs:
        if operator == "~=":
            rewritten.append(("~=", _match_release_precision(new_version, version)))
            replaced = True
        elif operator in (">=", ">"):
            rewritten.append((">=", new_version))
            replaced = True
        elif operator in EXACT_OPERATORS and not _has_wildcard(version):
            rewritten.append((operator, new_version))
            replaced = True
        else:
            rewritten.append((operator, version))

    if not replaced:
        # Only upper bounds / exclusions / wildcard bands were declared, so
        # nothing recorded the selected version. Add an explicit floor.
        rewritten.append((">=", new_version))

    return _dedupe_specs(rewritten)

specs_allow_version

Python
specs_allow_version(
    specs: Iterable[Spec], version: str
) -> bool

Check whether version satisfies every specifier in specs.

Mirrors pip's permissive behavior: an unparseable specifier or version is treated as "allowed" rather than silently discarding an update. Pre-releases are accepted so that an explicitly requested pre-release target is not rejected by SpecifierSet defaults.

PARAMETER DESCRIPTION
specs

Specifier pairs to evaluate.

TYPE: Iterable[Spec]

version

Candidate version string.

TYPE: str

RETURNS DESCRIPTION
bool

True when the version satisfies all specifiers (or the inputs

bool

cannot be interpreted), False otherwise.

Examples:

Python Console Session
>>> specs_allow_version([("<", "3.0")], "2.9.0")
True
>>> specs_allow_version([("<", "3.0")], "3.1.0")
False
>>> specs_allow_version([], "1.0.0")
True
Source code in depkeeper/utils/version_utils.py
Python
def specs_allow_version(specs: Iterable[Spec], version: str) -> bool:
    """Check whether *version* satisfies every specifier in *specs*.

    Mirrors pip's permissive behavior: an unparseable specifier or version
    is treated as "allowed" rather than silently discarding an update.
    Pre-releases are accepted so that an explicitly requested pre-release
    target is not rejected by `SpecifierSet`
    defaults.

    Args:
        specs: Specifier pairs to evaluate.
        version: Candidate version string.

    Returns:
        ``True`` when the version satisfies all specifiers (or the inputs
        cannot be interpreted), ``False`` otherwise.

    Examples:
        >>> specs_allow_version([("<", "3.0")], "2.9.0")
        True
        >>> specs_allow_version([("<", "3.0")], "3.1.0")
        False
        >>> specs_allow_version([], "1.0.0")
        True
    """
    specifier_string = specs_to_string(specs)
    if not specifier_string:
        return True

    try:
        specifier_set = SpecifierSet(specifier_string)
    except InvalidSpecifier:
        return True

    # Parsed explicitly rather than passed as a string: packaging 26.0
    # changed contains() to return False for a bad version instead of
    # raising, which would silently veto it instead of permitting it.
    try:
        parsed_version = Version(version)
    except InvalidVersion:
        return True

    return specifier_set.contains(parsed_version, prereleases=True)

specs_to_string

Python
specs_to_string(specs: Iterable[Spec]) -> str

Render (operator, version) pairs as a PEP 440 specifier string.

PARAMETER DESCRIPTION
specs

Specifier pairs in declaration order.

TYPE: Iterable[Spec]

RETURNS DESCRIPTION
str

Comma-joined specifier string, e.g. ">=2.0,<3.0". Empty when

str

specs is empty.

Examples:

Python Console Session
>>> specs_to_string([(">=", "2.0"), ("<", "3.0")])
'>=2.0,<3.0'
>>> specs_to_string([])
''
Source code in depkeeper/utils/version_utils.py
Python
def specs_to_string(specs: Iterable[Spec]) -> str:
    """Render ``(operator, version)`` pairs as a PEP 440 specifier string.

    Args:
        specs: Specifier pairs in declaration order.

    Returns:
        Comma-joined specifier string, e.g. ``">=2.0,<3.0"``. Empty when
        *specs* is empty.

    Examples:
        >>> specs_to_string([(">=", "2.0"), ("<", "3.0")])
        '>=2.0,<3.0'
        >>> specs_to_string([])
        ''
    """
    return ",".join(f"{operator}{version}" for operator, version in specs)

is_lower_bound

Python
is_lower_bound(operator: str) -> bool

Return whether operator constrains only the floor of a range.

PARAMETER DESCRIPTION
operator

A PEP 440 comparison operator.

TYPE: str

RETURNS DESCRIPTION
bool

True for >=, > and ~=.

Examples:

Python Console Session
>>> is_lower_bound(">=")
True
>>> is_lower_bound("<")
False
Source code in depkeeper/utils/version_utils.py
Python
def is_lower_bound(operator: str) -> bool:
    """Return whether *operator* constrains only the floor of a range.

    Args:
        operator: A PEP 440 comparison operator.

    Returns:
        ``True`` for ``>=``, ``>`` and ``~=``.

    Examples:
        >>> is_lower_bound(">=")
        True
        >>> is_lower_bound("<")
        False
    """
    return operator in LOWER_BOUND_OPERATORS

Name canonicalisation

normalize_package_name

Python
normalize_package_name(name: str) -> str

Return the canonical PEP 503 form of a distribution name.

Runs of -, _ and . collapse to a single - and the result is lower-cased. The operation is idempotent, so it is safe to apply to a value that has already been normalized.

PARAMETER DESCRIPTION
name

Raw distribution name in any casing / separator style.

TYPE: str

RETURNS DESCRIPTION
str

The canonical name. An empty string maps to an empty string.

Example::

Text Only
>>> normalize_package_name("Flask_Login")
'flask-login'
>>> normalize_package_name("zope.interface")
'zope-interface'
>>> normalize_package_name("My_Cool.Package")
'my-cool-package'
>>> normalize_package_name("requests")
'requests'
Source code in depkeeper/utils/naming.py
Python
def normalize_package_name(name: str) -> str:
    """Return the canonical PEP 503 form of a distribution name.

    Runs of ``-``, ``_`` and ``.`` collapse to a single ``-`` and the
    result is lower-cased. The operation is idempotent, so it is safe to
    apply to a value that has already been normalized.

    Args:
        name: Raw distribution name in any casing / separator style.

    Returns:
        The canonical name. An empty string maps to an empty string.

    Example::

        >>> normalize_package_name("Flask_Login")
        'flask-login'
        >>> normalize_package_name("zope.interface")
        'zope-interface'
        >>> normalize_package_name("My_Cool.Package")
        'my-cool-package'
        >>> normalize_package_name("requests")
        'requests'
    """
    # canonicalize_name returns a NormalizedName (a NewType over str);
    # str() keeps the public signature a plain str for callers/serializers.
    return str(canonicalize_name(name))

Filesystem utilities

Two backup layouts exist

create_timestamped_backup produces <stem>.<timestamp>_<uuid8>.backup<suffix> — this is what the CLI uses. create_backup produces <name><suffix>.<timestamp>_<uuid8>.backup, and restore_backup's target inference only understands that layout. Restore a CLI-produced backup by copying it manually.

safe_read_file

Python
safe_read_file(
    file_path: PathLike,
    *,
    max_size: Optional[int] = MAX_FILE_SIZE,
    encoding: str = DEFAULT_READ_ENCODING
) -> str

Safely read a text file with optional size limits.

The default encoding is utf-8-sig, which decodes plain UTF-8 exactly like utf-8 but also strips a leading byte order mark. Without this, the BOM survives as a \ufeff character at the start of the first line (str.strip() does not remove it) and corrupts the first token of the file. Pass encoding="utf-8" explicitly to retain the BOM.

PARAMETER DESCRIPTION
file_path

Path to the file.

TYPE: PathLike

max_size

Maximum allowed file size in bytes (None disables limit). Measured in bytes on disk, so a BOM counts toward the limit.

TYPE: Optional[int] DEFAULT: MAX_FILE_SIZE

encoding

Text encoding.

TYPE: str DEFAULT: DEFAULT_READ_ENCODING

RETURNS DESCRIPTION
str

File contents as a string, without a leading byte order mark.

RAISES DESCRIPTION
FileOperationError

The file is missing, is not a regular file, exceeds max_size, or cannot be decoded.

Source code in depkeeper/utils/filesystem.py
Python
def safe_read_file(
    file_path: PathLike,
    *,
    max_size: Optional[int] = MAX_FILE_SIZE,
    encoding: str = DEFAULT_READ_ENCODING,
) -> str:
    """Safely read a text file with optional size limits.

    The default encoding is ``utf-8-sig``, which decodes plain UTF-8 exactly
    like ``utf-8`` but also strips a leading byte order mark. Without this,
    the BOM survives as a ``\\ufeff`` character at the start of the first line
    (``str.strip()`` does not remove it) and corrupts the first token of the
    file. Pass ``encoding="utf-8"`` explicitly to retain the BOM.

    Args:
        file_path: Path to the file.
        max_size: Maximum allowed file size in bytes (None disables limit).
            Measured in bytes on disk, so a BOM counts toward the limit.
        encoding: Text encoding.

    Returns:
        File contents as a string, without a leading byte order mark.

    Raises:
        FileOperationError: The file is missing, is not a regular file,
            exceeds *max_size*, or cannot be decoded.
    """
    path = _validated_file(Path(file_path))
    size = path.stat().st_size

    if max_size is not None and size > max_size:
        raise FileOperationError(
            f"File too large: {size} bytes (max {max_size})",
            file_path=str(path),
            operation="read",
        )

    try:
        return path.read_text(encoding=encoding)
    except Exception as exc:
        raise FileOperationError(
            f"Failed to read file: {exc}",
            file_path=str(path),
            operation="read",
            original_error=exc,
        ) from exc

safe_write_file

Python
safe_write_file(
    file_path: PathLike,
    content: str,
    *,
    create_backup: bool = True,
    encoding: str = DEFAULT_WRITE_ENCODING
) -> Optional[Path]

Safely write text to a file using atomic replacement.

The write is atomic: the content lands in a temporary file that is fsync-ed and then renamed over the destination, so the destination is never left truncated or half-written. Line endings in content are preserved exactly.

PARAMETER DESCRIPTION
file_path

Destination path.

TYPE: PathLike

content

Text content to write.

TYPE: str

create_backup

Whether to create a backup before writing.

TYPE: bool DEFAULT: True

encoding

Text encoding. Use utf-8-sig to re-emit a byte order mark for files that originally carried one.

TYPE: str DEFAULT: DEFAULT_WRITE_ENCODING

RETURNS DESCRIPTION
Optional[Path]

Path to the created backup, if any.

RAISES DESCRIPTION
FileOperationError

The write failed. Any backup taken beforehand is restored over the destination on a best-effort basis first.

Source code in depkeeper/utils/filesystem.py
Python
def safe_write_file(
    file_path: PathLike,
    content: str,
    *,
    create_backup: bool = True,
    encoding: str = DEFAULT_WRITE_ENCODING,
) -> Optional[Path]:
    """Safely write text to a file using atomic replacement.

    The write is atomic: the content lands in a temporary file that is
    ``fsync``-ed and then renamed over the destination, so the destination is
    never left truncated or half-written. Line endings in *content* are
    preserved exactly.

    Args:
        file_path: Destination path.
        content: Text content to write.
        create_backup: Whether to create a backup before writing.
        encoding: Text encoding. Use ``utf-8-sig`` to re-emit a byte order
            mark for files that originally carried one.

    Returns:
        Path to the created backup, if any.

    Raises:
        FileOperationError: The write failed. Any backup taken beforehand is
            restored over the destination on a best-effort basis first.
    """
    path = Path(file_path)
    backup: Optional[Path] = None

    if create_backup and path.exists() and path.is_file():
        backup = _create_backup_internal(path)

    try:
        _atomic_write(path, content, encoding=encoding)
    except Exception:
        if backup and backup.exists():
            try:
                _restore_backup_internal(backup, path)
            except Exception:
                pass
        raise

    return backup

create_timestamped_backup

Python
create_timestamped_backup(file_path: PathLike) -> Path

Create a backup named <stem>.<timestamp>_<uuid>.backup<suffix>.

Unlike create_backup, the original suffix is kept last so the backup remains recognizable by extension (requirements.txt backs up to requirements.<timestamp>_<uuid>.backup.txt).

PARAMETER DESCRIPTION
file_path

File to copy.

TYPE: PathLike

RETURNS DESCRIPTION
Path

Path to the new backup file.

RAISES DESCRIPTION
FileOperationError

The source is missing or not a regular file, or the copy failed.

Source code in depkeeper/utils/filesystem.py
Python
def create_timestamped_backup(file_path: PathLike) -> Path:
    """Create a backup named ``<stem>.<timestamp>_<uuid>.backup<suffix>``.

    Unlike `create_backup`, the original suffix is kept last so the
    backup remains recognizable by extension (``requirements.txt`` backs up to
    ``requirements.<timestamp>_<uuid>.backup.txt``).

    Args:
        file_path: File to copy.

    Returns:
        Path to the new backup file.

    Raises:
        FileOperationError: The source is missing or not a regular file, or
            the copy failed.
    """
    path = Path(file_path)

    if not path.exists() or not path.is_file():
        raise FileOperationError(
            f"Cannot backup invalid file: {path}",
            file_path=str(path),
            operation="backup",
        )

    unique = uuid4().hex[:8]
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
    backup_name = f"{path.stem}.{timestamp}_{unique}.backup{path.suffix}"
    backup_path = path.with_name(backup_name)

    try:
        shutil.copy2(path, backup_path)
        logger.debug("Created timestamped backup: %s", backup_path)
        return backup_path
    except Exception as exc:
        raise FileOperationError(
            f"Failed to create backup: {exc}",
            file_path=str(path),
            operation="backup",
            original_error=exc,
        ) from exc

create_backup

Python
create_backup(file_path: PathLike) -> Path

Create a timestamped backup of an existing file.

PARAMETER DESCRIPTION
file_path

File to copy.

TYPE: PathLike

RETURNS DESCRIPTION
Path

Path to the new backup file.

RAISES DESCRIPTION
FileOperationError

The source does not exist, is not a regular file, or the copy failed.

Source code in depkeeper/utils/filesystem.py
Python
def create_backup(file_path: PathLike) -> Path:
    """Create a timestamped backup of an existing file.

    Args:
        file_path: File to copy.

    Returns:
        Path to the new backup file.

    Raises:
        FileOperationError: The source does not exist, is not a regular file,
            or the copy failed.
    """
    return _create_backup_internal(_validated_file(Path(file_path)))

restore_backup

Python
restore_backup(
    backup_path: PathLike,
    target_path: Optional[PathLike] = None,
) -> None

Restore a file from a backup created by create_backup.

When target_path is omitted the original name is recovered from the backup name by dropping the .backup extension and the trailing .<timestamp>_<uuid> segment.

PARAMETER DESCRIPTION
backup_path

Backup file to restore from.

TYPE: PathLike

target_path

Explicit destination. Required for backups whose name does not follow the generated convention.

TYPE: Optional[PathLike] DEFAULT: None

RAISES DESCRIPTION
FileOperationError

The backup is missing, the destination cannot be inferred, or the copy failed.

Source code in depkeeper/utils/filesystem.py
Python
def restore_backup(
    backup_path: PathLike,
    target_path: Optional[PathLike] = None,
) -> None:
    """Restore a file from a backup created by `create_backup`.

    When *target_path* is omitted the original name is recovered from the
    backup name by dropping the ``.backup`` extension and the trailing
    ``.<timestamp>_<uuid>`` segment.

    Args:
        backup_path: Backup file to restore from.
        target_path: Explicit destination. Required for backups whose name
            does not follow the generated convention.

    Raises:
        FileOperationError: The backup is missing, the destination cannot be
            inferred, or the copy failed.
    """
    backup = Path(backup_path)

    if not backup.exists():
        raise FileOperationError(
            f"Backup file not found: {backup}",
            file_path=str(backup),
            operation="restore",
        )

    if target_path is None:
        if not backup.name.endswith(".backup"):
            raise FileOperationError(
                f"Cannot infer restore target from backup: {backup}",
                file_path=str(backup),
                operation="restore",
            )

        base_name = backup.name[:-7]
        target = backup.parent / base_name.rsplit(".", 1)[0]
    else:
        target = Path(target_path)

    logger.debug("Restoring %s from backup %s", target, backup)
    _restore_backup_internal(backup, target)

find_requirements_files

Python
find_requirements_files(
    directory: PathLike = ".", *, recursive: bool = True
) -> List[Path]

Discover requirement files under a directory.

PARAMETER DESCRIPTION
directory

Root to search. A non-directory yields an empty list.

TYPE: PathLike DEFAULT: '.'

recursive

Search subdirectories as well. When False, patterns containing a path separator are dropped.

TYPE: bool DEFAULT: True

RETURNS DESCRIPTION
List[Path]

Sorted, de-duplicated list of matching paths.

Source code in depkeeper/utils/filesystem.py
Python
def find_requirements_files(
    directory: PathLike = ".",
    *,
    recursive: bool = True,
) -> List[Path]:
    """Discover requirement files under a directory.

    Args:
        directory: Root to search. A non-directory yields an empty list.
        recursive: Search subdirectories as well. When ``False``, patterns
            containing a path separator are dropped.

    Returns:
        Sorted, de-duplicated list of matching paths.
    """
    root = Path(directory).resolve()
    if not root.is_dir():
        return []

    patterns = REQUIREMENT_FILE_PATTERNS["requirements"]

    if not recursive:
        patterns = [p for p in patterns if "/" not in p]

    matches: List[Path] = []

    for pattern in patterns:
        iterator = root.rglob(pattern) if recursive else root.glob(pattern)
        matches.extend(iterator)

    return sorted(set(matches))

validate_path

Python
validate_path(
    path: PathLike, *, base_dir: Optional[PathLike] = None
) -> Path

Resolve a path and optionally confine it to a base directory.

Resolution is non-strict, so paths that do not exist yet are accepted. Passing base_dir turns this into a path-traversal guard for values that originate outside the process.

PARAMETER DESCRIPTION
path

Path to resolve. ~ is expanded; relative paths are taken against the current working directory.

TYPE: PathLike

base_dir

When given, the resolved path must sit inside it.

TYPE: Optional[PathLike] DEFAULT: None

RETURNS DESCRIPTION
Path

The absolute, resolved path.

RAISES DESCRIPTION
FileOperationError

The resolved path escapes base_dir.

Source code in depkeeper/utils/filesystem.py
Python
def validate_path(
    path: PathLike,
    *,
    base_dir: Optional[PathLike] = None,
) -> Path:
    """Resolve a path and optionally confine it to a base directory.

    Resolution is non-strict, so paths that do not exist yet are accepted.
    Passing *base_dir* turns this into a path-traversal guard for values that
    originate outside the process.

    Args:
        path: Path to resolve. ``~`` is expanded; relative paths are taken
            against the current working directory.
        base_dir: When given, the resolved path must sit inside it.

    Returns:
        The absolute, resolved path.

    Raises:
        FileOperationError: The resolved path escapes *base_dir*.
    """
    p = Path(path).expanduser()

    if not p.is_absolute():
        p = Path.cwd() / p

    resolved = p.resolve(strict=False)

    if base_dir is not None:
        base = Path(base_dir).expanduser()

        if not base.is_absolute():
            base = Path.cwd() / base

        base = base.resolve(strict=False)

        try:
            resolved.relative_to(base)
        except ValueError as exc:
            raise FileOperationError(
                f"Path outside allowed base directory: {resolved}",
                file_path=str(path),
                operation="validate",
                original_error=exc,
            ) from exc

    return resolved

Console

Every helper accepts a stderr keyword. print_error defaults to stderr=True; the others default to stdout. Commands emitting machine-readable payloads must pass stderr=True for all status output.

print_success

Python
print_success(
    message: str,
    *,
    prefix: str = "[OK]",
    stderr: bool = False
) -> None

Print a success message.

PARAMETER DESCRIPTION
message

Message body.

TYPE: str

prefix

Label rendered before the message.

TYPE: str DEFAULT: '[OK]'

stderr

Write to stderr instead of stdout. Use this whenever stdout carries machine-readable output.

TYPE: bool DEFAULT: False

Source code in depkeeper/utils/console.py
Python
def print_success(
    message: str, *, prefix: str = "[OK]", stderr: bool = False
) -> None:
    """Print a success message.

    Args:
        message: Message body.
        prefix: Label rendered before the message.
        stderr: Write to stderr instead of stdout. Use this whenever stdout
            carries machine-readable output.
    """
    _get_console(stderr=stderr).print(f"{prefix} {message}", style="success")

print_warning

Python
print_warning(
    message: str,
    *,
    prefix: str = "[WARNING]",
    stderr: bool = False
) -> None

Print a warning message.

PARAMETER DESCRIPTION
message

Message body.

TYPE: str

prefix

Label rendered before the message.

TYPE: str DEFAULT: '[WARNING]'

stderr

Write to stderr instead of stdout. Use this whenever stdout carries machine-readable output.

TYPE: bool DEFAULT: False

Source code in depkeeper/utils/console.py
Python
def print_warning(
    message: str, *, prefix: str = "[WARNING]", stderr: bool = False
) -> None:
    """Print a warning message.

    Args:
        message: Message body.
        prefix: Label rendered before the message.
        stderr: Write to stderr instead of stdout. Use this whenever stdout
            carries machine-readable output.
    """
    _get_console(stderr=stderr).print(f"{prefix} {message}", style="warning")

print_error

Python
print_error(
    message: str,
    *,
    prefix: str = "[ERROR]",
    stderr: bool = True
) -> None

Print an error message.

Errors default to stderr so they never corrupt machine-readable stdout.

PARAMETER DESCRIPTION
message

Message body.

TYPE: str

prefix

Label rendered before the message.

TYPE: str DEFAULT: '[ERROR]'

stderr

Write to stderr (default) or stdout.

TYPE: bool DEFAULT: True

Source code in depkeeper/utils/console.py
Python
def print_error(message: str, *, prefix: str = "[ERROR]", stderr: bool = True) -> None:
    """Print an error message.

    Errors default to stderr so they never corrupt machine-readable stdout.

    Args:
        message: Message body.
        prefix: Label rendered before the message.
        stderr: Write to stderr (default) or stdout.
    """
    _get_console(stderr=stderr).print(f"{prefix} {message}", style="error")

print_table

Python
print_table(
    data: List[Dict[str, Any]],
    *,
    headers: Optional[List[str]] = None,
    title: Optional[str] = None,
    caption: Optional[str] = None,
    column_styles: Optional[
        Dict[str, Dict[str, Any]]
    ] = None,
    row_styler: Optional[
        Callable[[Dict[str, Any]], Optional[str]]
    ] = None,
    show_row_lines: bool = False,
    stderr: bool = False
) -> None

Render structured data as a Rich table.

PARAMETER DESCRIPTION
data

List of row dictionaries.

TYPE: List[Dict[str, Any]]

headers

Column order. Defaults to keys of the first row.

TYPE: Optional[List[str]] DEFAULT: None

title

Optional table title.

TYPE: Optional[str] DEFAULT: None

caption

Optional table caption.

TYPE: Optional[str] DEFAULT: None

column_styles

Per-column style configuration.

TYPE: Optional[Dict[str, Dict[str, Any]]] DEFAULT: None

row_styler

Optional callback returning a row style.

TYPE: Optional[Callable[[Dict[str, Any]], Optional[str]]] DEFAULT: None

show_row_lines

Whether to draw horizontal lines between rows.

TYPE: bool DEFAULT: False

stderr

Render to stderr instead of stdout.

TYPE: bool DEFAULT: False

Source code in depkeeper/utils/console.py
Python
def print_table(
    data: List[Dict[str, Any]],
    *,
    headers: Optional[List[str]] = None,
    title: Optional[str] = None,
    caption: Optional[str] = None,
    column_styles: Optional[Dict[str, Dict[str, Any]]] = None,
    row_styler: Optional[Callable[[Dict[str, Any]], Optional[str]]] = None,
    show_row_lines: bool = False,
    stderr: bool = False,
) -> None:
    """Render structured data as a Rich table.

    Args:
        data: List of row dictionaries.
        headers: Column order. Defaults to keys of the first row.
        title: Optional table title.
        caption: Optional table caption.
        column_styles: Per-column style configuration.
        row_styler: Optional callback returning a row style.
        show_row_lines: Whether to draw horizontal lines between rows.
        stderr: Render to stderr instead of stdout.
    """
    if not data:
        return

    if headers is None:
        headers = list(data[0].keys())

    table = Table(
        title=title,
        caption=caption,
        show_header=True,
        header_style="bold",
        show_lines=show_row_lines,
    )

    column_styles = column_styles or {}
    for header in headers:
        config = column_styles.get(header, {})
        table.add_column(
            header,
            style=config.get("style"),
            justify=config.get("justify", "default"),
            no_wrap=config.get("no_wrap", False),
            width=config.get("width"),
            overflow=config.get("overflow", "fold"),
        )

    for row in data:
        values = [str(row.get(h, "")) for h in headers]
        style = row_styler(row) if row_styler else None
        table.add_row(*values, style=style)

    _get_console(stderr=stderr).print(table)

get_raw_console

Python
get_raw_console(*, stderr: bool = False) -> Console

Return the underlying Rich Console instance.

PARAMETER DESCRIPTION
stderr

Return the stderr-bound console instead of the stdout one.

TYPE: bool DEFAULT: False

Source code in depkeeper/utils/console.py
Python
def get_raw_console(*, stderr: bool = False) -> Console:
    """Return the underlying Rich Console instance.

    Args:
        stderr: Return the stderr-bound console instead of the stdout one.
    """
    return _get_console(stderr=stderr)

reconfigure_console

Python
reconfigure_console() -> None

Discard the memoized stdout and stderr consoles.

Color support is probed once per stream when a console is first built, so call this after changing NO_COLOR or redirecting a stream at runtime (tests rely on it for isolation).

Source code in depkeeper/utils/console.py
Python
def reconfigure_console() -> None:
    """Discard the memoized stdout and stderr consoles.

    Color support is probed once per stream when a console is first built, so
    call this after changing ``NO_COLOR`` or redirecting a stream at runtime
    (tests rely on it for isolation).
    """
    with _console_lock:
        _consoles.clear()

colorize_update_type

Python
colorize_update_type(update_type: str) -> str

Wrap an update-type label in Rich markup colored by severity.

PARAMETER DESCRIPTION
update_type

Update classification, e.g. "major" (see get_update_type).

TYPE: str

RETURNS DESCRIPTION
str

Rich markup string, or update_type unchanged when the label has no

str

assigned color.

Source code in depkeeper/utils/console.py
Python
def colorize_update_type(update_type: str) -> str:
    """Wrap an update-type label in Rich markup colored by severity.

    Args:
        update_type: Update classification, e.g. ``"major"`` (see
            `get_update_type`).

    Returns:
        Rich markup string, or *update_type* unchanged when the label has no
        assigned color.
    """
    color_map = {
        "major": "red",
        "minor": "yellow",
        "patch": "green",
        "new": "cyan",
        "downgrade": "red",
        "update": "yellow",
    }

    color = color_map.get(update_type.lower())
    return f"[{color}]{update_type}[/{color}]" if color else update_type

confirm

Python
confirm(message: str, *, default: bool = False) -> bool

Prompt the user for a yes/no confirmation on stdout.

Input handling:

  • y / yes -> True
  • n / no -> False
  • empty or unrecognized input -> default
  • Ctrl+C / EOF -> False

Unrecognized input falls back to default rather than re-prompting, so a non-interactive caller can never be trapped in a loop.

PARAMETER DESCRIPTION
message

Prompt message shown to the user.

TYPE: str

default

Choice used when the user presses Enter or types something unrecognized.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
bool

True if confirmed, False otherwise.

Source code in depkeeper/utils/console.py
Python
def confirm(message: str, *, default: bool = False) -> bool:
    """Prompt the user for a yes/no confirmation on stdout.

    Input handling:

    - ``y`` / ``yes`` -> ``True``
    - ``n`` / ``no`` -> ``False``
    - empty or unrecognized input -> *default*
    - ``Ctrl+C`` / EOF -> ``False``

    Unrecognized input falls back to *default* rather than re-prompting, so a
    non-interactive caller can never be trapped in a loop.

    Args:
        message: Prompt message shown to the user.
        default: Choice used when the user presses Enter or types something
            unrecognized.

    Returns:
        ``True`` if confirmed, ``False`` otherwise.
    """
    console = _get_console()
    suffix = " [Y/n]: " if default else " [y/N]: "
    console.print(f"{message}{suffix}", end="", style="info", markup=False)

    try:
        response = input().strip().lower()
    except (KeyboardInterrupt, EOFError):
        console.print()
        return False

    if not response:
        return default

    if response in ("y", "yes"):
        return True
    if response in ("n", "no"):
        return False

    return default

Logging

setup_logging reconfigures the process

It clears handlers on the shared depkeeper logger, installs its own, and sets propagate = False. Embedding depkeeper means its records will not reach your root logger unless you reconfigure afterwards. Tests must snapshot and restore that logger.

setup_logging

Python
setup_logging(
    *,
    level: int = INFO,
    verbose: bool = False,
    stream: Optional[IO[str]] = None
) -> None

Configure logging for the depkeeper logger hierarchy.

Safe to call multiple times: existing handlers are replaced under a process-wide lock rather than accumulated.

Side effects

Reconfigures the shared depkeeper logger for the whole process and sets propagate = False on it, so records no longer reach the root logger. Test suites that capture log output must snapshot and restore that logger's handlers, level and propagate flag.

PARAMETER DESCRIPTION
level

Logging level (e.g., logging.INFO, logging.DEBUG).

TYPE: int DEFAULT: INFO

verbose

Enable verbose formatting with timestamps and logger names.

TYPE: bool DEFAULT: False

stream

Output stream; defaults to sys.stderr.

TYPE: Optional[IO[str]] DEFAULT: None

Source code in depkeeper/utils/logger.py
Python
def setup_logging(
    *,
    level: int = logging.INFO,
    verbose: bool = False,
    stream: Optional[IO[str]] = None,
) -> None:
    """Configure logging for the ``depkeeper`` logger hierarchy.

    Safe to call multiple times: existing handlers are replaced under a
    process-wide lock rather than accumulated.

    Side effects:
        Reconfigures the shared ``depkeeper`` logger for the whole process and
        sets ``propagate = False`` on it, so records no longer reach the root
        logger. Test suites that capture log output must snapshot and restore
        that logger's handlers, level and ``propagate`` flag.

    Args:
        level: Logging level (e.g., ``logging.INFO``, ``logging.DEBUG``).
        verbose: Enable verbose formatting with timestamps and logger names.
        stream: Output stream; defaults to ``sys.stderr``.
    """
    global _logging_configured

    with _lock:
        root_logger = logging.getLogger("depkeeper")
        root_logger.handlers.clear()
        root_logger.setLevel(level)

        handler = logging.StreamHandler(stream or sys.stderr)
        handler.setLevel(level)

        fmt = LOG_VERBOSE_FORMAT if verbose else LOG_DEFAULT_FORMAT
        formatter = ColoredFormatter(
            fmt,
            datefmt=LOG_DATE_FORMAT,
            use_color=not os.environ.get("NO_COLOR"),
        )
        handler.setFormatter(formatter)

        root_logger.addHandler(handler)
        root_logger.propagate = False
        _logging_configured = True

get_logger

Python
get_logger(name: Optional[str] = None) -> Logger

Return a logger within the depkeeper namespace.

Bare names are prefixed with depkeeper. so every logger inherits the configuration applied by setup_logging.

PARAMETER DESCRIPTION
name

Logger name, e.g. "parser" or __name__.

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
Logger

A logger under the depkeeper hierarchy. When logging has not been

Logger

configured, a logging.NullHandler is attached so importing

Logger

depkeeper as a library never emits "no handlers" warnings.

Source code in depkeeper/utils/logger.py
Python
def get_logger(name: Optional[str] = None) -> logging.Logger:
    """Return a logger within the ``depkeeper`` namespace.

    Bare names are prefixed with ``depkeeper.`` so every logger inherits the
    configuration applied by `setup_logging`.

    Args:
        name: Logger name, e.g. ``"parser"`` or ``__name__``.

    Returns:
        A logger under the ``depkeeper`` hierarchy. When logging has not been
        configured, a `logging.NullHandler` is attached so importing
        depkeeper as a library never emits "no handlers" warnings.
    """
    if not name or name == "depkeeper":
        logger = logging.getLogger("depkeeper")
    elif name.startswith("depkeeper."):
        logger = logging.getLogger(name)
    else:
        logger = logging.getLogger(f"depkeeper.{name}")

    if not logger.handlers and (not logger.parent or not logger.parent.handlers):
        logger.addHandler(logging.NullHandler())

    return logger

disable_logging

Python
disable_logging() -> None

Silence all depkeeper logging output and reset the configured flag.

Source code in depkeeper/utils/logger.py
Python
def disable_logging() -> None:
    """Silence all depkeeper logging output and reset the configured flag."""
    global _logging_configured

    with _lock:
        root_logger = logging.getLogger("depkeeper")
        root_logger.handlers.clear()
        root_logger.addHandler(logging.NullHandler())
        root_logger.setLevel(logging.NOTSET)
        _logging_configured = False

is_logging_configured

Python
is_logging_configured() -> bool

Return whether setup_logging has run since the last reset.

Source code in depkeeper/utils/logger.py
Python
def is_logging_configured() -> bool:
    """Return whether `setup_logging` has run since the last reset."""
    return _logging_configured

Exceptions

DepKeeperError

Python
DepKeeperError(
    message: str,
    details: Optional[Mapping[str, Any]] = None,
)

Bases: Exception

Base exception for all depkeeper errors.

All depkeeper-specific exceptions should inherit from this class. It supports structured metadata via details for richer error reporting and debugging.

PARAMETER DESCRIPTION
message

Human-readable error message.

TYPE: str

details

Optional structured metadata describing the error.

TYPE: Optional[Mapping[str, Any]] DEFAULT: None

Source code in depkeeper/exceptions.py
Python
def __init__(
    self,
    message: str,
    details: Optional[Mapping[str, Any]] = None,
) -> None:
    self.message: str = message
    # Internally normalize to a mutable dict
    self.details: MutableMapping[str, Any] = dict(details) if details else {}
    super().__init__(message)

ParseError

Python
ParseError(
    message: str,
    *,
    line_number: Optional[int] = None,
    line_content: Optional[str] = None,
    file_path: Optional[str] = None
)

Bases: DepKeeperError

Raised when a requirements file cannot be parsed.

PARAMETER DESCRIPTION
message

Error description.

TYPE: str

line_number

Line number where parsing failed.

TYPE: Optional[int] DEFAULT: None

line_content

Raw content of the problematic line.

TYPE: Optional[str] DEFAULT: None

file_path

Path to the file being parsed.

TYPE: Optional[str] DEFAULT: None

Source code in depkeeper/exceptions.py
Python
def __init__(
    self,
    message: str,
    *,
    line_number: Optional[int] = None,
    line_content: Optional[str] = None,
    file_path: Optional[str] = None,
) -> None:
    details: MutableMapping[str, Any] = {}
    _add_if(details, "line", line_number)
    _add_if(details, "content", line_content)
    _add_if(details, "file", file_path)

    super().__init__(message, details)

    self.line_number = line_number
    self.line_content = line_content
    self.file_path = file_path

ConfigError

Python
ConfigError(
    message: str,
    *,
    config_path: Optional[str] = None,
    option: Optional[str] = None
)

Bases: DepKeeperError

Raised when a configuration file is invalid or cannot be loaded.

PARAMETER DESCRIPTION
message

Human-readable description of the configuration problem.

TYPE: str

config_path

Path to the configuration file that caused the error.

TYPE: Optional[str] DEFAULT: None

option

The specific configuration option that is invalid, if applicable.

TYPE: Optional[str] DEFAULT: None

Source code in depkeeper/exceptions.py
Python
def __init__(
    self,
    message: str,
    *,
    config_path: Optional[str] = None,
    option: Optional[str] = None,
) -> None:
    details: MutableMapping[str, Any] = {}
    _add_if(details, "config_path", config_path)
    _add_if(details, "option", option)

    super().__init__(message, details)

    self.config_path = config_path
    self.option = option

FileOperationError

Python
FileOperationError(
    message: str,
    *,
    file_path: Optional[str] = None,
    operation: Optional[str] = None,
    original_error: Optional[Exception] = None
)

Bases: DepKeeperError

Raised when file system operations fail.

PARAMETER DESCRIPTION
message

Error description.

TYPE: str

file_path

Path to the file involved.

TYPE: Optional[str] DEFAULT: None

operation

Operation being performed (read/write/delete).

TYPE: Optional[str] DEFAULT: None

original_error

Original exception that triggered this error.

TYPE: Optional[Exception] DEFAULT: None

Source code in depkeeper/exceptions.py
Python
def __init__(
    self,
    message: str,
    *,
    file_path: Optional[str] = None,
    operation: Optional[str] = None,
    original_error: Optional[Exception] = None,
) -> None:
    details: MutableMapping[str, Any] = {}
    _add_if(details, "path", file_path)
    _add_if(details, "operation", operation)
    _add_if(
        details,
        "original_error",
        str(original_error) if original_error else None,
    )

    super().__init__(message, details)

    self.file_path = file_path
    self.operation = operation
    self.original_error = original_error

NetworkError

Python
NetworkError(
    message: str,
    *,
    url: Optional[str] = None,
    status_code: Optional[int] = None,
    response_body: Optional[str] = None
)

Bases: DepKeeperError

Raised when HTTP or network operations fail.

PARAMETER DESCRIPTION
message

Error description.

TYPE: str

url

URL being accessed.

TYPE: Optional[str] DEFAULT: None

status_code

HTTP status code, if available.

TYPE: Optional[int] DEFAULT: None

response_body

Raw response body, truncated for safety.

TYPE: Optional[str] DEFAULT: None

Source code in depkeeper/exceptions.py
Python
def __init__(
    self,
    message: str,
    *,
    url: Optional[str] = None,
    status_code: Optional[int] = None,
    response_body: Optional[str] = None,
) -> None:
    details: MutableMapping[str, Any] = {}
    _add_if(details, "url", url)
    _add_if(details, "status_code", status_code)

    if response_body is not None:
        details["response"] = _truncate(response_body)

    super().__init__(message, details)

    self.url = url
    self.status_code = status_code
    self.response_body = response_body

PyPIError

Python
PyPIError(
    message: str,
    *,
    package_name: Optional[str] = None,
    **kwargs: Any
)

Bases: NetworkError

Raised for failures related to the PyPI API.

PARAMETER DESCRIPTION
message

Error description.

TYPE: str

package_name

Name of the package involved.

TYPE: Optional[str] DEFAULT: None

**kwargs

Additional arguments forwarded to NetworkError.

TYPE: Any DEFAULT: {}

Source code in depkeeper/exceptions.py
Python
def __init__(
    self,
    message: str,
    *,
    package_name: Optional[str] = None,
    **kwargs: Any,
) -> None:
    super().__init__(message, **kwargs)

    self.package_name = package_name
    if package_name is not None:
        self.details["package"] = package_name

Recipes

Just the recommendation for one package

Python
import asyncio
from depkeeper.core import PyPIDataStore, VersionChecker
from depkeeper.utils import HTTPClient


async def recommend(name: str, current: str) -> str | None:
    async with HTTPClient() as http:
        checker = VersionChecker(data_store=PyPIDataStore(http))
        pkg = await checker.get_package_info(name, current)
        return pkg.recommended_version


print(asyncio.run(recommend("urllib3", "1.26.0")))   # '1.26.20'

Detect conflicts without touching any file

Python
import asyncio
from depkeeper.core import DependencyAnalyzer, PyPIDataStore, VersionChecker
from depkeeper.models import Requirement
from depkeeper.utils import HTTPClient


async def conflicts(requirements: list[Requirement]):
    async with HTTPClient() as http:
        store = PyPIDataStore(http)
        packages = await VersionChecker(data_store=store).check_packages(requirements)
        result = await DependencyAnalyzer(data_store=store).resolve_and_annotate_conflicts(packages)
        return result.get_conflicts()

Silence depkeeper's logging in an embedding application

Python
from depkeeper.utils import disable_logging

disable_logging()