Source code for jitx.run.dependencies

"""
Upgrade declared dependencies to the highest allowed (based on pyproject.toml specifiers)
without uninstalling anything.

Usage:
    python -m jitx dependencies                          # check for updates (no changes)
    python -m jitx dependencies --upgrade                # upgrade to latest allowed
    python -m jitx dependencies --no-dependency-check    # do neither

For use in other code:
    from jitx.run.dependencies import sync_venv
    sync_venv() # will default to check mode with no prereleases
"""

import subprocess
import sys
import time
import argparse
import importlib.metadata
from pathlib import Path
from collections.abc import Mapping, Sequence
import json
import os
import tempfile
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name

from .pyproject import PyProject

COOLDOWN_SECONDS_DEFAULT = 3600
TIMESTAMP_FILE_DEFAULT = Path(".venv_sync_last")


def _time_since_sync(timestamp_file: Path) -> float | None:
    if not timestamp_file.exists():
        return None
    return time.time() - timestamp_file.stat().st_mtime


def _bump_timestamp(timestamp_file: Path) -> None:
    timestamp_file.parent.mkdir(parents=True, exist_ok=True)
    if timestamp_file.exists():
        timestamp_file.touch()
    else:
        timestamp_file.write_text(
            "Tracks when dependencies were last checked. Delete to force a recheck."
        )


def _run_pip_command(
    requirements: list[str], *, allow_prereleases: bool, dry_run: bool
):
    """
    Runs the pip command upgrade with the given requirements.
    Dry-run mode will not make any changes but will report what would be changed ( used for check mode)
    The report is written to a temporary file to avoid mixing with other output.
    """
    report_file_path = None
    try:
        # Create a temporary file to hold the pip report
        with tempfile.NamedTemporaryFile(mode="w+", delete=False) as temp_file:
            report_file_path = temp_file.name

        cmd = [
            sys.executable,
            "-m",
            "pip",
            "install",
            "--upgrade",  # Upgrade packages
            "--no-input",
            *requirements,  # (the packages)
            "--report",
            report_file_path,  # file to write the report to
        ]

        if dry_run:
            cmd.append("--dry-run")  # No changes made, just report what would be done

        if allow_prereleases:
            cmd.append("--pre")

        cp = subprocess.run(cmd, text=True, check=False, capture_output=True)

        # Read the JSON from the temp file if it exists
        report_json = None
        if os.path.exists(report_file_path):
            with open(report_file_path, "rb") as f:
                report_json = json.load(f)
                if not isinstance(report_json, Mapping):
                    raise ValueError("Failed to parse pip's report.")

        return cp.returncode, report_json, cp.stderr

    except json.JSONDecodeError as e:
        print(f"Failed to parse pip's report: {e}", file=sys.stderr)
        raise
    finally:
        # Temp file clean up
        if report_file_path and os.path.exists(report_file_path):
            os.remove(report_file_path)


def _normalize_name(name: str) -> str:
    return canonicalize_name(name)


def _updates_from_report(
    installed_packages, dependencies: Sequence[Requirement]
) -> list[tuple[Requirement, str | None, str]]:
    target_versions = {
        _normalize_name(item.get("name")): item.get("version")
        for item in installed_packages
        if item.get("name") and item.get("version")
    }

    updates = []
    for dep in dependencies:
        dep_name = dep.name
        normalized_name = _normalize_name(dep_name)
        try:
            installed = importlib.metadata.version(dep_name)
        except importlib.metadata.PackageNotFoundError:
            installed = None

        target = target_versions.get(normalized_name)
        if target and installed != target:
            updates.append((dep, installed, target))
    return updates


def _parse_pip_report(report: Mapping) -> list | None:
    """Parses a the pip JSON report and returns a list of installed packages."""
    try:
        # Filter the report to include only installed packages
        installed_packages = [
            {"name": item["metadata"]["name"], "version": item["metadata"]["version"]}
            for item in report.get("install", [])
            if not item.get("is_direct", False) and not item.get("is_yanked", False)
        ]
        return installed_packages
    except KeyError as e:
        print(f"Failed to parse pip's report: {e}", file=sys.stderr)
        print("Failed to get dependency information.", file=sys.stderr)
        return None


[docs] def sync_venv( *, mode: str = "check", allow_prereleases: bool = False, include_optional_groups: Sequence[str] | None = None, editable_install: bool = False, ) -> None: """ - check -> dry-run and list dependencies that would update (respects cooldown). - update -> apply the updates. """ if mode is None: return pyproject = PyProject() if not pyproject: print("No pyproject.toml found.") return dependencies = pyproject.dependencies(include_optional_groups) if not dependencies: print("No dependencies declared.") return is_dry_run = mode == "check" if is_dry_run: tss = _time_since_sync(TIMESTAMP_FILE_DEFAULT) if tss is not None and tss <= COOLDOWN_SECONDS_DEFAULT: remaining_minutes = int(COOLDOWN_SECONDS_DEFAULT - tss) // 60 if remaining_minutes > 0: print( f"Dependencies recently checked, skipping. Will check again in {remaining_minutes} minutes." ) else: print( "Dependencies recently checked, skipping. Will check again in less than a minute." ) return rc, report_json, err = _run_pip_command( [str(d) for d in dependencies], allow_prereleases=allow_prereleases, dry_run=is_dry_run, ) if rc != 0 or not report_json: if err: print("Pip log output (stderr):", file=sys.stderr) print(err, file=sys.stderr) print("Failed to get dependency information.", file=sys.stderr) _bump_timestamp(TIMESTAMP_FILE_DEFAULT) return installed_packages = _parse_pip_report(report_json) if installed_packages is None: _bump_timestamp(TIMESTAMP_FILE_DEFAULT) return if is_dry_run: updates = _updates_from_report(installed_packages, dependencies) if not updates: print("All dependencies up to date.") else: print("Updates available:") for dep, installed, target in updates: if installed is None: print(f" {dep.name}: not installed → {target}") else: print(f" {dep.name}: {installed}{target}") else: # mode == "update" if not installed_packages: print("No new dependencies were installed or updated.") else: print("Dependencies updated:") for package in installed_packages: print(f" {package['name']}: {package['version']}") if editable_install: cp2 = subprocess.run( [sys.executable, "-m", "pip", "install", "-e", "."], text=True, check=False, ) if cp2.returncode != 0: print("Editable install failed.", file=sys.stderr) _bump_timestamp(TIMESTAMP_FILE_DEFAULT)
if __name__ == "__main__": parser = argparse.ArgumentParser() group = parser.add_mutually_exclusive_group() group.add_argument("--upgrade", action="store_true", help="Upgrade dependencies") group.add_argument( "--no-dependency-check", action="store_true", help="Dont check for dependencies nor upgrade them", ) parser.add_argument( "--allow-prereleases", action="store_true", help="Allow pre-release versions", ) parser.add_argument( "--check-include-group", action="append", default=None, help="Optional-dependency group(s) to include (repeatable)", ) parser.add_argument( "--editable-install", action="store_true", help="Reinstall the current project in editable mode after upgrading dependencies", ) args = parser.parse_args() if args.no_dependency_check: sys.exit() mode = "update" if args.upgrade else "check" sync_venv( mode=mode, allow_prereleases=args.allow_prereleases, include_optional_groups=args.check_include_group, editable_install=args.editable_install, )