Skip to content

Python API

Use depkeeper programmatically in your Python scripts and tools. This page documents the core modules, data models, and utility functions available for integration.


Overview

Core pipeline:

Step Module Input Output
1 RequirementsParser requirements file List[Requirement]
2 VersionChecker List[Requirement] List[Package]
3 DependencyAnalyzer List[Package] ResolutionResult
4 Apply / Report ResolutionResult Updated file or report

PyPIDataStore backs both VersionChecker and DependencyAnalyzer, and is itself backed by HTTPClient.

depkeeper provides a Python API for:

  • Parsing requirements.txt files into structured Requirement objects
  • Checking package versions against PyPI with strict major version boundaries
  • Resolving dependency conflicts through iterative analysis
  • Formatting and displaying results

All core modules share a single PyPIDataStore instance to ensure each package is fetched at most once per process.


Quick Start

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

async def check_requirements():
    # Parse requirements file
    parser = RequirementsParser()
    requirements = parser.parse_file("requirements.txt")

    # Check versions
    async with HTTPClient() as http:
        store = PyPIDataStore(http)
        checker = VersionChecker(data_store=store)
        packages = await checker.check_packages(requirements)

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

asyncio.run(check_requirements())

Core Modules

RequirementsParser

Stateful parser for pip-style requirements files. Supports all PEP 440/508 syntax including version specifiers, extras, environment markers, include directives (-r), constraint files (-c), VCS URLs, editable installs, and hash verification.

The parser maintains internal state across multiple parse_file calls:

  • Include stack -- tracks the chain of -r directives to detect circular dependencies
  • Constraint map -- stores requirements loaded via -c directives

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

Python
from depkeeper.core import RequirementsParser

parser = RequirementsParser()

# Parse from file
requirements = parser.parse_file("requirements.txt")

# Parse from string
content = """
requests>=2.28.0
flask==2.3.0
-r base.txt
"""
requirements = parser.parse_string(content, source_file_path="inline")

# Access constraint files loaded via -c
constraints = parser.get_constraints()

# Reset state before reusing
parser.reset()

Methods

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 :meth:reset to clear state before reusing the parser on an unrelated set of files.

Example::

Text Only
>>> parser = RequirementsParser()
>>> reqs = parser.parse_file("requirements.txt")
>>> len(reqs)
42
>>> parser.get_constraints()
{'django': <Requirement django==3.2>}
>>> parser.reset()

Initialise the parser with empty state.

Source code in depkeeper/core/parser.py
Python
def __init__(self) -> None:
    """Initialise 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] = {}

Functions

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 :class: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 :exc: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 :attr:_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 :class: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.

Example::

Text Only
>>> parser = RequirementsParser()
>>> reqs = parser.parse_file("requirements/prod.txt")
>>> [r.name for r in reqs if r.editable]
['my-local-package']
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
    :class:`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 :exc:`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 :attr:`_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 :class:`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.

    Example::

        >>> parser = RequirementsParser()
        >>> reqs = parser.parse_file("requirements/prod.txt")
        >>> [r.name for r in reqs if r.editable]
        ['my-local-package']
    """
    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 :meth: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 :attr:_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 :class: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
    :meth:`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 :attr:`_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 :class:`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']
    """
    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):
            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)
  • -e <url-or-path> → editable :class:Requirement
  • pkg==1.0 --hash sha256:... → :class:Requirement with hashes
  • Standard PEP 508 specs → :class: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.

Example::

Text Only
>>> parser = RequirementsParser()
>>> parser.parse_line("requests>=2.25.0", 1)
<Requirement requests>=2.25.0>
>>> parser.parse_line("# comment", 2) is None
True
>>> parser.parse_line("-r base.txt", 3)  # returns List[Requirement]
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)
    - ``-e <url-or-path>`` → editable :class:`Requirement`
    - ``pkg==1.0 --hash sha256:...`` → :class:`Requirement` with hashes
    - Standard PEP 508 specs → :class:`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.

    Example::

        >>> parser = RequirementsParser()
        >>> parser.parse_line("requests>=2.25.0", 1)
        <Requirement requests>=2.25.0>
        >>> parser.parse_line("# comment", 2) is None
        True
        >>> parser.parse_line("-r base.txt", 3)  # returns List[Requirement]
    """
    stripped_line = line_text.strip()

    # Blank lines and pure comments are skipped
    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

    # 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] = re.findall(r"--hash[=\s]+(\S+)", requirement_spec)
    if hash_values:
        # Remove all --hash tokens from the spec
        requirement_spec = " ".join(
            token
            for token in requirement_spec.split()
            if not token.startswith(HASH_DIRECTIVE)
        )

    # ── 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 normalised package names to their constraint

Dict[str, Requirement]

class:Requirement objects.

Example::

Text Only
>>> parser = RequirementsParser()
>>> parser.parse_file("requirements.txt")  # includes -c constraints.txt
>>> constraints = parser.get_constraints()
>>> constraints.get("django")
<Requirement django==3.2>
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 normalised package names to their constraint
        :class:`Requirement` objects.

    Example::

        >>> parser = RequirementsParser()
        >>> parser.parse_file("requirements.txt")  # includes -c constraints.txt
        >>> constraints = parser.get_constraints()
        >>> constraints.get("django")
        <Requirement django==3.2>
    """
    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.

Example::

Text Only
>>> parser = RequirementsParser()
>>> parser.parse_file("projectA/requirements.txt")
>>> parser.reset()
>>> parser.parse_file("projectB/requirements.txt")  # clean slate
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.

    Example::

        >>> parser = RequirementsParser()
        >>> parser.parse_file("projectA/requirements.txt")
        >>> parser.reset()
        >>> parser.parse_file("projectB/requirements.txt")  # clean slate
    """
    self._included_files_stack = []
    self._constraint_requirements = {}

VersionChecker

Async package version checker with strict major version boundary enforcement. Recommendations never cross major version boundaries -- if the current version is 2.x.x, the recommended version will always be 2.y.z, never 3.0.0.

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

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

async def main():
    async with HTTPClient() as http:
        store = PyPIDataStore(http)
        checker = VersionChecker(data_store=store)

        # Check single package
        pkg = await checker.get_package_info("requests", current_version="2.28.0")
        print(f"Latest: {pkg.latest_version}")
        print(f"Recommended: {pkg.recommended_version}")

        # Check multiple packages concurrently
        packages = await checker.check_packages(requirements)

Constructor Parameters

Parameter Type Default Description
data_store PyPIDataStore Required Shared PyPI metadata cache
infer_version_from_constraints bool True Infer current version from range constraints like >=2.0

Methods

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.

Example::

Text Only
>>> async with HTTPClient() as http:
...     store   = PyPIDataStore(http)
...     checker = VersionChecker(data_store=store)
...     pkg     = await checker.get_package_info("flask", current_version="2.0.0")
...     print(pkg.recommended_version)
'2.3.3'  # Never 3.x.x, even if 3.0.0 is available
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

Functions

get_package_info async

Python
get_package_info(name: str, current_version: Optional[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 :meth: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

RETURNS DESCRIPTION
A

class:Package with latest_version,

TYPE: Package

Package

recommended_version, and metadata fields populated.

RAISES DESCRIPTION
PyPIError

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

Example::

Text Only
>>> pkg = await checker.get_package_info("requests", current_version="2.25.0")
>>> pkg.latest_version
'2.31.0'
>>> pkg.recommended_version
'2.31.0'  # Stays in major version 2
Source code in depkeeper/core/checker.py
Python
async def get_package_info(
    self,
    name: str,
    current_version: Optional[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 :meth:`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.

    Returns:
        A :class:`Package` with ``latest_version``,
        ``recommended_version``, and metadata fields populated.

    Raises:
        PyPIError: The package does not exist on PyPI or the API
            returned an unexpected status.

    Example::

        >>> pkg = await checker.get_package_info("requests", current_version="2.25.0")
        >>> pkg.latest_version
        '2.31.0'
        >>> pkg.recommended_version
        '2.31.0'  # Stays in major version 2
    """
    try:
        pkg_data = await self.data_store.get_package_data(name)
    except PyPIError:
        # Package not found or API error — return unavailable stub
        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)

check_packages async

Python
check_packages(requirements: List[Requirement]) -> List[Package]

Check multiple packages concurrently.

For each requirement, extracts the current version (via :meth:extract_current_version) and calls :meth: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 :class:Package objects, one per requirement.

Example::

Text Only
>>> requirements = parser.parse_file("requirements.txt")
>>> packages = await checker.check_packages(requirements)
>>> [p.name for p in packages if p.recommended_version]
['flask', 'requests', 'click']
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
    :meth:`extract_current_version`) and calls :meth:`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 :class:`Package` objects, one per requirement.

    Example::

        >>> requirements = parser.parse_file("requirements.txt")
        >>> packages = await checker.check_packages(requirements)
        >>> [p.name for p in packages if p.recommended_version]
        ['flask', 'requests', 'click']
    """
    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 :attr: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 :class:Requirement.

TYPE: Requirement

RETURNS DESCRIPTION
Optional[str]

The inferred version string, or None when inference is not

Optional[str]

possible.

Example::

Text Only
>>> req1 = Requirement(name="flask", specs=[("==", "2.0.0")], ...)
>>> checker.extract_current_version(req1)
'2.0.0'

>>> req2 = Requirement(name="flask", specs=[(">=", "2.0"), ("<", "3")], ...)
>>> checker.extract_current_version(req2)
'2.0'

>>> req3 = Requirement(name="flask", specs=[], ...)
>>> checker.extract_current_version(req3) is None
True
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 :attr:`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 :class:`Requirement`.

    Returns:
        The inferred version string, or ``None`` when inference is not
        possible.

    Example::

        >>> req1 = Requirement(name="flask", specs=[("==", "2.0.0")], ...)
        >>> checker.extract_current_version(req1)
        '2.0.0'

        >>> req2 = Requirement(name="flask", specs=[(">=", "2.0"), ("<", "3")], ...)
        >>> checker.extract_current_version(req2)
        '2.0'

        >>> req3 = Requirement(name="flask", specs=[], ...)
        >>> checker.extract_current_version(req3) is None
        True
    """
    if not req.specs:
        return None

    # Exact pin: treat as the current version
    if len(req.specs) == 1 and req.specs[0][0] == "==":
        return req.specs[0][1]

    if not self.infer_version_from_constraints:
        return None

    # Infer from range lower-bound operators
    for operator, version in req.specs:
        if operator in (">=", ">", "~="):
            return version

    return None

PyPIDataStore

Async-safe, per-process cache for PyPI package metadata. Each unique package name triggers at most one HTTP request to /pypi/{pkg}/json. A semaphore limits concurrent outbound fetches, and double-checked locking prevents duplicate requests when multiple coroutines request the same package simultaneously.

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

async def main():
    async with HTTPClient() as http:
        store = PyPIDataStore(http, concurrent_limit=10)

        # Fetch package data
        data = await store.get_package_data("requests")
        print(f"Latest: {data.latest_version}")
        print(f"All versions: {data.all_versions[:5]}")

        # Prefetch multiple packages concurrently
        await store.prefetch_packages(["flask", "click", "jinja2"])

        # Get cached data (no network call)
        cached = store.get_cached_package("flask")

        # Get dependencies for a specific version
        deps = await store.get_version_dependencies("flask", "2.3.0")

Constructor Parameters

Parameter Type Default Description
http_client HTTPClient Required Pre-configured async HTTP client
concurrent_limit int 10 Maximum concurrent PyPI fetches

Methods

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

Each unique (normalised) package name triggers at most one HTTP request to /pypi/{pkg}/json. A :class:asyncio.Semaphore limits concurrent outbound fetches, and a double-checked lock inside the semaphore prevents thundering-herd duplicates when several coroutines request the same package simultaneously.

PARAMETER DESCRIPTION
http_client

A pre-configured :class: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

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:
    self.http_client = http_client
    self._semaphore = asyncio.Semaphore(concurrent_limit)

    # Primary cache: normalised 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]] = {}

Functions

get_package_data async

Python
get_package_data(name: str) -> PyPIPackageData

Fetch (or return cached) metadata for name.

Uses double-checked locking: the first check is lock-free; if the package is missing a second check runs inside the semaphore so that only one coroutine actually performs the HTTP call.

PARAMETER DESCRIPTION
name

PyPI package name (any casing / underscore style).

TYPE: str

RETURNS DESCRIPTION
A

class:PyPIPackageData populated from the latest PyPI

TYPE: PyPIPackageData

PyPIPackageData

JSON response.

RAISES DESCRIPTION
PyPIError

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

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*.

    Uses double-checked locking: the first check is lock-free; if the
    package is missing a second check runs *inside* the semaphore so
    that only one coroutine actually performs the HTTP call.

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

    Returns:
        A :class:`PyPIPackageData` populated from the latest PyPI
        JSON response.

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

    Example::

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

    # Fast path — already cached (no lock needed)
    if normalized in self._package_data:
        return self._package_data[normalized]

    async with self._semaphore:
        # Second check — another coroutine may have populated while we waited
        if normalized in self._package_data:
            return self._package_data[normalized]

        data = await self._fetch_from_pypi(name)
        pkg_data = self._parse_package_data(name, data)
        self._package_data[normalized] = pkg_data
        return pkg_data

prefetch_packages async

Python
prefetch_packages(names: List[str]) -> None

Concurrently warm the cache for a batch of packages.

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", "scipy"])
# 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.

    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", "scipy"])
        # subsequent get_package_data calls for these return instantly
    """
    await asyncio.gather(
        *(self.get_package_data(name) for name in names),
        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 :class:PyPIPackageData (latest_dependencies or dependencies_cache).
  3. A targeted /pypi/{name}/{version}/json fetch, guarded by the semaphore and a second cache check.
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
       :class:`PyPIPackageData` (``latest_dependencies`` or
       ``dependencies_cache``).
    3. A targeted ``/pypi/{name}/{version}/json`` fetch, guarded by
       the semaphore and a second cache check.

    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 ──────────────────────────
    if cache_key in self._version_deps_cache:
        return self._version_deps_cache[cache_key]

    # ── 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 (double-checked) ────────────────────
    async with self._semaphore:
        if cache_key in self._version_deps_cache:
            return self._version_deps_cache[cache_key]

        deps = await self._fetch_version_dependencies(name, version)
        self._version_deps_cache[cache_key] = deps

        # Back-fill the package-level cache so future reads skip this path
        if pkg_data:
            pkg_data.dependencies_cache[version] = deps

        return deps

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 :class:PyPIPackageData, or None if the

Optional[PyPIPackageData]

package has not been fetched yet.

Example::

Text Only
>>> store.get_cached_package("flask")  # after a prior fetch
PyPIPackageData(name='flask', latest_version='3.0.0', ...)
>>> store.get_cached_package("unknown")
None
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 :class:`PyPIPackageData`, or ``None`` if the
        package has not been fetched yet.

    Example::

        >>> store.get_cached_package("flask")  # after a prior fetch
        PyPIPackageData(name='flask', latest_version='3.0.0', ...)
        >>> store.get_cached_package("unknown")
        None
    """
    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 [].

Example::

Text Only
>>> store.get_versions("flask")
['3.0.0', '2.3.3', '2.3.2', ...]
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 ``[]``.

    Example::

        >>> store.get_versions("flask")
        ['3.0.0', '2.3.3', '2.3.2', ...]
    """
    pkg = self.get_cached_package(name)
    return pkg.all_versions if pkg else []

PyPIPackageData

Immutable-by-convention snapshot of one PyPI package, populated by PyPIDataStore. Contains version lists, Python compatibility data, and dependency caches.

Attributes

Attribute Type Description
name str Normalized package name
latest_version Optional[str] Latest version on PyPI
latest_requires_python Optional[str] Python requirement for latest version
latest_dependencies List[str] Base dependencies of latest version
all_versions List[str] Stable versions, newest first
parsed_versions List[Tuple[str, Version]] Parsed version objects, descending
python_requirements Dict[str, Optional[str]] Version to requires_python mapping
dependencies_cache Dict[str, List[str]] Per-version dependency lists

Methods

Method Returns Description
get_versions_in_major(major) List[str] Stable versions sharing a given major number
is_python_compatible(version, python_version) bool Check if a package version supports a Python version
get_python_compatible_versions(python_version, major=None) List[str] Stable versions compatible with a Python version

DependencyAnalyzer

Resolves dependency conflicts with strict major version boundary enforcement. The analyzer builds a dependency graph, detects version conflicts, and iteratively adjusts recommendations until a conflict-free set is found or the iteration limit is reached.

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

async def main():
    async with HTTPClient() as http:
        store = PyPIDataStore(http)
        analyzer = DependencyAnalyzer(data_store=store)

        # Resolve conflicts
        result = await analyzer.resolve_and_annotate_conflicts(packages)

        # Check results
        for name, resolution in result.resolved_versions.items():
            print(f"{name}: {resolution.original} -> {resolution.resolved}")
            print(f"  Status: {resolution.status}")

        # Summary
        print(result.summary())

Methods

Detect and resolve version conflicts with strict major version boundaries.

This analyzer enhances the base conflict resolution by ensuring that no package is ever upgraded or downgraded across major version boundaries during conflict resolution. This prevents breaking changes from being inadvertently introduced.

The analyzer works exclusively through a :class: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.

Example::

Text Only
>>> async with HTTPClient() as http:
...     store    = PyPIDataStore(http)
...     analyzer = DependencyAnalyzer(data_store=store)
...     result   = await analyzer.resolve_and_annotate_conflicts(pkgs)
...     print(result.summary())
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)

Functions

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 :data:_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. Annotate each :class:Package with its final version and any unresolved :class:Conflict objects.
  2. Return a :class:ResolutionResult with complete details.
PARAMETER DESCRIPTION
packages

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

TYPE: List[Package]

RETURNS DESCRIPTION
ResolutionResult

class:ResolutionResult containing the final version for each

ResolutionResult

package, conflict details, and resolution statistics.

Example::

Text Only
>>> result = await analyzer.resolve_and_annotate_conflicts(pkgs)
>>> print(result.summary())
>>> for pkg_name, info in result.resolved_versions.items():
...     if info.was_changed():
...         print(f"{pkg_name}: {info.original} → {info.resolved}")
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 :data:`_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. Annotate each :class:`Package` with its final version and any
       unresolved :class:`Conflict` objects.
    5. Return a :class:`ResolutionResult` with complete details.

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

    Returns:
        :class:`ResolutionResult` containing the final version for each
        package, conflict details, and resolution statistics.

    Example::

        >>> result = await analyzer.resolve_and_annotate_conflicts(pkgs)
        >>> print(result.summary())
        >>> for pkg_name, info in result.resolved_versions.items():
        ...     if info.was_changed():
        ...         print(f"{pkg_name}: {info.original} → {info.resolved}")
    """
    # ── initialise 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]] = {}

    # Track original proposed versions for comparison
    original_versions: Dict[str, Optional[str]] = {}

    for pkg in packages:
        # Use recommended version (which already respects major boundaries)
        proposed = pkg.recommended_version or pkg.current_version
        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 (with deduplication)
        for conflict in cross_conflicts:
            conflicts_list = conflict_tracking.setdefault(
                conflict.target_package, []
            )

            # Deduplicate using conflict signature
            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,
        )

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

    for pkg in packages:
        original = original_versions.get(pkg.name)
        resolved = update_set.get(pkg.name)
        conflicts = conflict_tracking.get(pkg.name, [])

        # Determine resolution status
        status = self._determine_status(pkg, original, resolved, conflicts)

        # Find compatible alternative if there are conflicts
        # (constrained to current major version only)
        compatible_alt = None
        if conflicts:
            # Get current major version to constrain search
            current_major = _get_major_version(pkg.current_version)

            if current_major is not None:
                # Only look within current major
                available = self.data_store.get_versions(pkg.name)
                available_in_major = [
                    v for v in available if _get_major_version(v) == current_major
                ]

                conflict_set = ConflictSet(pkg.name)
                for c in conflicts:
                    conflict_set.add_conflict(c)

                compatible_alt = self.find_compatible_version(
                    conflict_set, available_in_major, pkg.current_version
                )

            packages_with_conflicts += 1

        # Update the Package object itself
        if resolved and resolved != pkg.recommended_version:
            # Only update recommended_version if resolution changed it
            pkg.recommended_version = (
                resolved if resolved != pkg.current_version else pkg.current_version
            )
        if conflicts:
            pkg.set_conflicts(conflicts, resolved_version=compatible_alt)

        # Store resolution details
        resolved_versions[pkg.name] = PackageResolution(
            name=pkg.name,
            original=original,
            resolved=resolved,
            status=status,
            conflicts=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,
    )

ResolutionResult

Complete result of dependency conflict resolution, returned by DependencyAnalyzer.resolve_and_annotate_conflicts().

Attributes

Attribute Type Description
resolved_versions Dict[str, PackageResolution] Package name to resolution details
total_packages int Total packages analyzed
packages_with_conflicts int Packages that have conflicts
iterations_used int Resolution iterations performed
converged bool Whether resolution reached a stable state

Methods

Method Returns Description
get_changed_packages() List[PackageResolution] Packages whose version was changed
get_conflicts() List[PackageResolution] Packages with unresolved conflicts
summary() str Human-readable resolution summary

PackageResolution

Resolution details for a single package within a ResolutionResult.

Attributes

Attribute Type Description
name str Package name (normalized)
original Optional[str] Initially proposed version
resolved Optional[str] Final version after resolution
status ResolutionStatus Why this version was chosen
conflicts List[Conflict] Conflicts affecting this package
compatible_alternative Optional[str] Best alternative version, if any

Resolution Statuses

Status Description
KEPT_RECOMMENDED Original recommendation was conflict-free
UPGRADED Successfully upgraded to a newer version
DOWNGRADED Had to downgrade due to conflicts
KEPT_CURRENT No safe upgrade found; stayed at current
CONSTRAINED Version was constrained by another package

Models

Requirement

Represents a single requirement line from a requirements file.

Python
from depkeeper.models import Requirement

req = Requirement(
    name="requests",
    specs=[(">=", "2.28.0"), ("<", "3.0.0")],
    extras=["security"],
    markers="python_version >= '3.8'",
)

# Convert to string
print(req.to_string())  # requests[security]>=2.28.0,<3.0.0; python_version >= '3.8'

# Update version (replaces all specifiers with ==new_version)
updated = req.update_version("2.31.0")
print(updated)  # requests[security]==2.31.0; python_version >= '3.8'

Attributes

Attribute Type Description
name str Canonical package name
specs List[Tuple[str, str]] Version specifiers (operator, version)
extras List[str] Optional extras to install
markers Optional[str] Environment marker expression (PEP 508)
url Optional[str] Direct URL or VCS source
editable bool Whether this is an editable install (-e)
hashes List[str] Hash values for verification
comment Optional[str] Inline comment without the # prefix
line_number int Original line number in the source file
raw_line Optional[str] Original unmodified line text

Methods

Method Returns Description
to_string(include_hashes=True, include_comment=True) str Render canonical requirements.txt representation
update_version(new_version) str Return requirement string updated to a new version

Package

Represents a Python package with version state, update recommendations, and conflict tracking.

Python
from depkeeper.models import Package

pkg = Package(
    name="requests",
    current_version="2.28.0",
    latest_version="2.32.0",
    recommended_version="2.32.0",
)

# Check status
print(pkg.has_update())        # True
print(pkg.requires_downgrade)  # False
print(pkg.has_conflicts())     # False

# Serialization
print(pkg.to_json())

Attributes

Attribute Type Description
name str Normalized package name (PEP 503)
current_version Optional[str] Currently installed or specified version
latest_version Optional[str] Latest version on PyPI (informational)
recommended_version Optional[str] Safe upgrade version within major boundary
metadata Dict[str, Any] Package metadata from PyPI
conflicts List[Conflict] Dependency conflicts affecting this package

Properties and Methods

Member Type Description
current Optional[Version] Parsed current version
latest Optional[Version] Parsed latest version
recommended Optional[Version] Parsed recommended version
requires_downgrade bool True if recommended version is lower than current
has_update() bool True if recommended version is newer than current
has_conflicts() bool True if dependency conflicts exist
set_conflicts(conflicts, resolved_version=None) None Set conflicts and optionally update recommended version
get_conflict_summary() List[str] Short, user-friendly conflict summaries
get_conflict_details() List[str] Detailed conflict descriptions
get_status_summary() Tuple[str, str, str, Optional[str]] Status, installed, latest, recommended
to_json() Dict[str, Any] JSON-safe package representation

Conflict

Represents a dependency conflict between two packages. This is a frozen dataclass (immutable after creation).

Python
from depkeeper.models import Conflict

conflict = Conflict(
    source_package="flask",
    target_package="werkzeug",
    required_spec=">=2.0,<3.0",
    conflicting_version="3.0.0",
    source_version="2.3.0",
)

print(conflict.to_display_string())
# flask==2.3.0 requires werkzeug>=2.0,<3.0

Attributes

Attribute Type Description
source_package str Package declaring the dependency
target_package str Package being constrained
required_spec str Version specifier required by the source
conflicting_version str Version that violates the requirement
source_version Optional[str] Version of the source package

Methods

Method Returns Description
to_display_string() str Human-readable conflict description
to_short_string() str Compact conflict summary
to_json() Dict[str, Optional[str]] JSON-serializable representation

ConflictSet

Collection of conflicts affecting a single package. Provides utilities to find compatible versions.

Attributes

Attribute Type Description
package_name str Name of the affected package
conflicts List[Conflict] Conflicts associated with this package

Methods

Method Returns Description
add_conflict(conflict) None Add a conflict to the set
has_conflicts() bool True if any conflicts exist
get_max_compatible_version(available_versions) Optional[str] Highest version compatible with all conflicts

Utilities

HTTPClient

Async HTTP client with retry logic, rate limiting, concurrency control, and PyPI-specific error handling. Uses httpx with HTTP/2 support.

Python
from depkeeper.utils import HTTPClient

async def main():
    async with HTTPClient(timeout=30, max_retries=3) as http:
        # GET request
        response = await http.get("https://pypi.org/pypi/requests/json")

        # GET with JSON parsing
        data = await http.get_json("https://pypi.org/pypi/requests/json")

        # Batch concurrent JSON fetches
        results = await http.batch_get_json([
            "https://pypi.org/pypi/flask/json",
            "https://pypi.org/pypi/click/json",
        ])

Constructor Parameters

Parameter Type Default Description
timeout int 30 Request timeout in seconds
max_retries int 3 Maximum retry attempts
rate_limit_delay float 0.0 Minimum delay between requests
verify_ssl bool True Verify SSL certificates
user_agent Optional[str] Auto-generated Custom User-Agent header
max_concurrency int 10 Maximum concurrent requests

Methods

Method Returns Description
get(url) httpx.Response GET request with retry logic
post(url) httpx.Response POST request with retry logic
get_json(url) Dict[str, Any] GET and parse JSON response
batch_get_json(urls) Dict[str, Dict[str, Any]] Concurrent JSON fetches
close() None Close the HTTP client

Console Utilities

User-facing output helpers built on the Rich library.

Python
from depkeeper.utils import (
    print_success,
    print_error,
    print_warning,
    print_table,
    confirm,
    get_raw_console,
)

# Status messages
print_success("Operation completed!")
print_error("Something went wrong")
print_warning("Proceed with caution")

# Rich table from list of dicts
print_table(
    data=[
        {"Name": "requests", "Version": "2.31.0"},
        {"Name": "flask", "Version": "2.3.3"},
    ],
    title="Packages",
)

# User confirmation prompt
if confirm("Apply 5 updates?", default=False):
    print("Applying...")

# Access underlying Rich Console
console = get_raw_console()

Functions

Function Parameters Description
print_success(message, prefix="[OK]") str Print a styled success message
print_error(message, prefix="[ERROR]") str Print a styled error message
print_warning(message, prefix="[WARNING]") str Print a styled warning message
print_table(data, headers=None, title=None, ...) List[Dict] Render data as a Rich table
confirm(message, default=False) str, bool Prompt for yes/no confirmation
get_raw_console() Return the underlying Rich Console instance
reconfigure_console() None Reset the global console (useful after changing NO_COLOR)
colorize_update_type(update_type) str Return Rich-markup colored update type label

Filesystem Utilities

Safe file I/O helpers with backup, restore, and path validation support.

Python
from depkeeper.utils import (
    safe_read_file,
    safe_write_file,
    create_backup,
    restore_backup,
    create_timestamped_backup,
    find_requirements_files,
    validate_path,
)

# Read a file safely (with size limit)
content = safe_read_file("requirements.txt")

# Write with automatic backup
backup_path = safe_write_file("requirements.txt", new_content, create_backup=True)

# Find all requirements files in a directory
files = find_requirements_files(".", recursive=True)

# Validate a path stays within a base directory
resolved = validate_path("../requirements.txt", base_dir="/project")

Functions

Function Returns Description
safe_read_file(file_path, max_size=None, encoding="utf-8") str Read a text file with optional size limit
safe_write_file(file_path, content, create_backup=True) Optional[Path] Atomic write with optional backup; returns backup path
create_backup(file_path) Path Create a timestamped backup of a file
restore_backup(backup_path, target_path=None) None Restore a file from a backup
create_timestamped_backup(file_path) Path Create a backup with {stem}.{timestamp}.backup{suffix} format
find_requirements_files(directory=".", recursive=True) List[Path] Find requirements files in a directory
validate_path(path, base_dir=None) Path Resolve and validate a path; raises FileOperationError if outside base_dir

Logging Utilities

Centralized logging setup for depkeeper.

Python
from depkeeper.utils import (
    get_logger,
    setup_logging,
    disable_logging,
    is_logging_configured,
)

# Get a named logger
logger = get_logger("my_module")
logger.info("Processing started")

# Configure logging level
setup_logging(verbosity=2)  # DEBUG level

# Check if logging has been configured
if not is_logging_configured():
    setup_logging(verbosity=0)

# Suppress all logging output
disable_logging()

Functions

Function Returns Description
get_logger(name=None) logging.Logger Get a named logger under the depkeeper namespace
setup_logging(verbosity=0) None Configure logging level (0=WARNING, 1=INFO, 2=DEBUG)
is_logging_configured() bool Check if logging has already been set up
disable_logging() None Suppress all depkeeper log output

Version Utilities

Helpers for classifying version changes using PEP 440 parsing.

Python
from depkeeper.utils import get_update_type

get_update_type("1.0.0", "2.0.0")   # "major"
get_update_type("1.0.0", "1.1.0")   # "minor"
get_update_type("1.0.0", "1.0.1")   # "patch"
get_update_type(None, "1.0.0")       # "new"
get_update_type("1.0.0", "1.0.0")   # "same"
get_update_type("2.0.0", "1.0.0")   # "downgrade"

Functions

Function Returns Description
get_update_type(current_version, target_version) str Classify the update type between two versions

Return values: "major", "minor", "patch", "new", "same", "downgrade", "update", "unknown"


Exceptions

All exceptions inherit from DepKeeperError and support structured metadata via the details attribute.

Exception hierarchy:

  • DepKeeperError (base)
    • ParseError
    • NetworkError
      • PyPIError
    • FileOperationError
Exception Description Key Attributes
DepKeeperError Base exception for all depkeeper errors message, details
ParseError Requirements file parsing failures line_number, line_content, file_path
NetworkError HTTP or network operation failures url, status_code, response_body
PyPIError PyPI API-specific failures package_name (inherits NetworkError)
FileOperationError File system operation failures file_path, operation, original_error

Complete Example

Python
#!/usr/bin/env python3
"""Check and update dependencies programmatically."""

import asyncio

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


async def analyze_requirements(file_path: str):
    """Analyze a requirements file and report on updates."""

    # Step 1: Parse requirements
    parser = RequirementsParser()
    requirements = parser.parse_file(file_path)
    print(f"Found {len(requirements)} packages\n")

    async with HTTPClient() as http:
        # Step 2: Create shared data store
        store = PyPIDataStore(http)

        # Step 3: Check versions
        checker = VersionChecker(data_store=store)
        packages = await checker.check_packages(requirements)

        # Step 4: Resolve conflicts
        analyzer = DependencyAnalyzer(data_store=store)
        result = await analyzer.resolve_and_annotate_conflicts(packages)

        # Step 5: Report results
        print(f"Converged: {result.converged} ({result.iterations_used} iterations)")
        print(f"Conflicts: {result.packages_with_conflicts}\n")

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

    return packages


if __name__ == "__main__":
    asyncio.run(analyze_requirements("requirements.txt"))

Configuration

DepKeeperConfig

Dataclass representing a parsed and validated configuration file. All fields carry defaults, so an empty or missing configuration file produces a fully usable config object.

Python
from depkeeper.config import load_config, discover_config_file

# Auto-discover and load (depkeeper.toml or pyproject.toml)
config = load_config()

# Load from explicit path
config = load_config(Path("/project/depkeeper.toml"))

# Access values
print(config.check_conflicts)           # True
print(config.strict_version_matching)   # False

Functions

Configuration file loader for depkeeper.

Handles discovery, loading, parsing, and validation of configuration files. Supports two formats:

  • depkeeper.toml — settings under [depkeeper] table
  • pyproject.toml — settings under [tool.depkeeper] table

Discovery order:

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

Configuration precedence: defaults < config file < environment < CLI args.

Typical usage::

Text Only
config = load_config()  # Auto-discover
config = load_config(Path("custom.toml"))  # Explicit path

Example (depkeeper.toml)::

Text Only
[depkeeper]
check_conflicts = true
strict_version_matching = false

Functions

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()

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

    # 3. pyproject.toml with [tool.depkeeper] section
    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

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 :func:discover_config_file).

TYPE: Optional[Path] DEFAULT: None

RETURNS DESCRIPTION
Validated

class:DepKeeperConfig with values from file or defaults.

TYPE: DepKeeperConfig

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 :func:`discover_config_file`).

    Returns:
        Validated :class:`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)

    # Extract the depkeeper-specific section
    if resolved.name == "pyproject.toml":
        section = raw.get("tool", {}).get("depkeeper", {})
    else:
        # depkeeper.toml — settings live under [depkeeper]
        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

Class

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]

Functions

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,
    }

Exception

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

See Also