milieux.distro

  1from collections.abc import Sequence
  2from dataclasses import dataclass
  3from pathlib import Path
  4from subprocess import CalledProcessError
  5from typing import Annotated, Optional
  6
  7from typing_extensions import Doc, Self
  8
  9from milieux import logger
 10from milieux.config import get_config, update_command_with_index_url
 11from milieux.errors import DistroExistsError, InvalidDistroError, NoPackagesError, NoSuchDistroError, NoSuchRequirementsFileError
 12from milieux.utils import AnyPath, distro_sty, ensure_path, eprint, read_lines, run_command
 13
 14
 15def get_distro_base_dir() -> Path:
 16    """Checks if the configured distro directory exists, and if not, creates it."""
 17    cfg = get_config()
 18    return ensure_path(cfg.distro_dir_path)
 19
 20def get_requirements(requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[Sequence[str]] = None) -> list[str]:
 21    """Helper function to get requirements files, given a list of requirements files and/or distro names."""
 22    reqs = [str(req) for req in requirements] if requirements else []
 23    if distros:  # get requirements path from distro name
 24        reqs += [str(Distro(name).path) for name in distros]
 25    return reqs
 26
 27def get_packages(packages: Optional[Sequence[str]] = None, requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[Sequence[str]] = None) -> list[str]:
 28    """Given a list of packages and a list of requirements files, gets a list of all packages therein.
 29    Deduplicates any identical entries, and sorts alphabetically."""
 30    reqs = get_requirements(requirements, distros)
 31    if (not packages) and (not reqs):
 32        raise NoPackagesError('Must specify at least one package')
 33    pkgs: set[str] = set()
 34    if packages:
 35        pkgs.update(packages)
 36    if reqs:
 37        for req in reqs:
 38            try:
 39                pkgs.update(stripped for line in read_lines(req) if (stripped := line.strip()))
 40            except FileNotFoundError as e:
 41                raise NoSuchRequirementsFileError(str(req)) from e
 42    return sorted(pkgs)
 43
 44
 45@dataclass
 46class Distro:
 47    """Class for interacting with a distro (set of Python package requirements)."""
 48    name: Annotated[str, Doc('Name of distro')]
 49    dir_path: Annotated[Path, Doc('Path to distro directory')]
 50
 51    def __init__(self, name: str, dir_path: Optional[Path] = None) -> None:
 52        self.name = name
 53        self.dir_path = dir_path or get_distro_base_dir()
 54        self._path = self.dir_path / f'{name}.txt'
 55
 56    def exists(self) -> bool:
 57        """Returns True if the distro exists."""
 58        return self._path.exists()
 59
 60    @property
 61    def path(self) -> Path:
 62        """Gets the path to the distro (requirements file).
 63        If no such file exists, raises a NoSuchDistroError."""
 64        if not self.exists():
 65            raise NoSuchDistroError(self.name)
 66        return self._path
 67
 68    def get_packages(self) -> list[str]:
 69        """Gets the list of packages in the distro."""
 70        packages = []
 71        for line in read_lines(self.path):
 72            line = line.strip()
 73            if not line.startswith('#'):  # skip comments
 74                packages.append(line)
 75        return packages
 76
 77    def lock(self, annotate: bool = False) -> str:
 78        """Locks the packages in a distro to their pinned versions.
 79        Returns the output as a string."""
 80        logger.info(f'Locking dependencies for {distro_sty(self.name)} distro')
 81        cmd = ['uv', 'pip', 'compile', str(self.path)]
 82        update_command_with_index_url(cmd)
 83        if not annotate:
 84            cmd.append('--no-annotate')
 85        try:
 86            return run_command(cmd, check=True, text=True, capture_output=True).stdout
 87        except CalledProcessError as e:
 88            raise InvalidDistroError('\n' + e.stderr) from e
 89
 90    @classmethod
 91    def new(cls,
 92        name: str,
 93        packages: Optional[list[str]] = None,
 94        requirements: Optional[Sequence[AnyPath]] = None,
 95        distros: Optional[list[str]] = None,
 96        force: bool = False
 97    ) -> Self:
 98        """Creates a new distro."""
 99        packages = get_packages(packages, requirements, distros)
100        distro_base_dir = get_distro_base_dir()
101        distro_path = distro_base_dir / f'{name}.txt'
102        if distro_path.exists():
103            msg = f'Distro {distro_sty(name)} already exists'
104            if force:
105                logger.warning(f'{msg} -- overwriting')
106                distro_path.unlink()
107            else:
108                raise DistroExistsError(msg)
109        logger.info(f'Creating distro {distro_sty(name)}')
110        with open(distro_path, 'w') as f:
111            for pkg in packages:
112                print(pkg, file=f)
113        logger.info(f'Wrote {distro_sty(name)} requirements to {distro_path}')
114        return cls(name, distro_base_dir)
115
116    def remove(self) -> None:
117        """Deletes the distro."""
118        path = self.path
119        logger.info(f'Deleting {distro_sty(self.name)} distro')
120        path.unlink()
121        logger.info(f'Deleted {path}')
122
123    def show(self) -> None:
124        """Prints out the packages in the distro."""
125        eprint(f'Distro {distro_sty(self.name)} is located at: {self.path}')
126        eprint('──────────\n [bold]Packages[/]\n──────────')
127        for pkg in self.get_packages():
128            print(pkg)
129
130    # NOTE: due to a bug in mypy (https://github.com/python/mypy/issues/15047), this method must come last
131    @classmethod
132    def list(cls) -> None:
133        """Prints the list of existing distros."""
134        distro_base_dir = get_distro_base_dir()
135        eprint(f'Distro directory: {distro_base_dir}')
136        distros = sorted([p.stem for p in distro_base_dir.glob('*.txt') if p.is_file()])
137        if distros:
138            eprint('─────────\n [bold]Distros[/]\n─────────')
139            for distro in distros:
140                print(distro)
141        else:
142            eprint('No distros exist.')
def get_distro_base_dir() -> pathlib.Path:
16def get_distro_base_dir() -> Path:
17    """Checks if the configured distro directory exists, and if not, creates it."""
18    cfg = get_config()
19    return ensure_path(cfg.distro_dir_path)

Checks if the configured distro directory exists, and if not, creates it.

def get_requirements( requirements: Optional[Sequence[Union[str, pathlib.Path]]] = None, distros: Optional[Sequence[str]] = None) -> list[str]:
21def get_requirements(requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[Sequence[str]] = None) -> list[str]:
22    """Helper function to get requirements files, given a list of requirements files and/or distro names."""
23    reqs = [str(req) for req in requirements] if requirements else []
24    if distros:  # get requirements path from distro name
25        reqs += [str(Distro(name).path) for name in distros]
26    return reqs

Helper function to get requirements files, given a list of requirements files and/or distro names.

def get_packages( packages: Optional[Sequence[str]] = None, requirements: Optional[Sequence[Union[str, pathlib.Path]]] = None, distros: Optional[Sequence[str]] = None) -> list[str]:
28def get_packages(packages: Optional[Sequence[str]] = None, requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[Sequence[str]] = None) -> list[str]:
29    """Given a list of packages and a list of requirements files, gets a list of all packages therein.
30    Deduplicates any identical entries, and sorts alphabetically."""
31    reqs = get_requirements(requirements, distros)
32    if (not packages) and (not reqs):
33        raise NoPackagesError('Must specify at least one package')
34    pkgs: set[str] = set()
35    if packages:
36        pkgs.update(packages)
37    if reqs:
38        for req in reqs:
39            try:
40                pkgs.update(stripped for line in read_lines(req) if (stripped := line.strip()))
41            except FileNotFoundError as e:
42                raise NoSuchRequirementsFileError(str(req)) from e
43    return sorted(pkgs)

Given a list of packages and a list of requirements files, gets a list of all packages therein. Deduplicates any identical entries, and sorts alphabetically.

@dataclass
class Distro:
 46@dataclass
 47class Distro:
 48    """Class for interacting with a distro (set of Python package requirements)."""
 49    name: Annotated[str, Doc('Name of distro')]
 50    dir_path: Annotated[Path, Doc('Path to distro directory')]
 51
 52    def __init__(self, name: str, dir_path: Optional[Path] = None) -> None:
 53        self.name = name
 54        self.dir_path = dir_path or get_distro_base_dir()
 55        self._path = self.dir_path / f'{name}.txt'
 56
 57    def exists(self) -> bool:
 58        """Returns True if the distro exists."""
 59        return self._path.exists()
 60
 61    @property
 62    def path(self) -> Path:
 63        """Gets the path to the distro (requirements file).
 64        If no such file exists, raises a NoSuchDistroError."""
 65        if not self.exists():
 66            raise NoSuchDistroError(self.name)
 67        return self._path
 68
 69    def get_packages(self) -> list[str]:
 70        """Gets the list of packages in the distro."""
 71        packages = []
 72        for line in read_lines(self.path):
 73            line = line.strip()
 74            if not line.startswith('#'):  # skip comments
 75                packages.append(line)
 76        return packages
 77
 78    def lock(self, annotate: bool = False) -> str:
 79        """Locks the packages in a distro to their pinned versions.
 80        Returns the output as a string."""
 81        logger.info(f'Locking dependencies for {distro_sty(self.name)} distro')
 82        cmd = ['uv', 'pip', 'compile', str(self.path)]
 83        update_command_with_index_url(cmd)
 84        if not annotate:
 85            cmd.append('--no-annotate')
 86        try:
 87            return run_command(cmd, check=True, text=True, capture_output=True).stdout
 88        except CalledProcessError as e:
 89            raise InvalidDistroError('\n' + e.stderr) from e
 90
 91    @classmethod
 92    def new(cls,
 93        name: str,
 94        packages: Optional[list[str]] = None,
 95        requirements: Optional[Sequence[AnyPath]] = None,
 96        distros: Optional[list[str]] = None,
 97        force: bool = False
 98    ) -> Self:
 99        """Creates a new distro."""
100        packages = get_packages(packages, requirements, distros)
101        distro_base_dir = get_distro_base_dir()
102        distro_path = distro_base_dir / f'{name}.txt'
103        if distro_path.exists():
104            msg = f'Distro {distro_sty(name)} already exists'
105            if force:
106                logger.warning(f'{msg} -- overwriting')
107                distro_path.unlink()
108            else:
109                raise DistroExistsError(msg)
110        logger.info(f'Creating distro {distro_sty(name)}')
111        with open(distro_path, 'w') as f:
112            for pkg in packages:
113                print(pkg, file=f)
114        logger.info(f'Wrote {distro_sty(name)} requirements to {distro_path}')
115        return cls(name, distro_base_dir)
116
117    def remove(self) -> None:
118        """Deletes the distro."""
119        path = self.path
120        logger.info(f'Deleting {distro_sty(self.name)} distro')
121        path.unlink()
122        logger.info(f'Deleted {path}')
123
124    def show(self) -> None:
125        """Prints out the packages in the distro."""
126        eprint(f'Distro {distro_sty(self.name)} is located at: {self.path}')
127        eprint('──────────\n [bold]Packages[/]\n──────────')
128        for pkg in self.get_packages():
129            print(pkg)
130
131    # NOTE: due to a bug in mypy (https://github.com/python/mypy/issues/15047), this method must come last
132    @classmethod
133    def list(cls) -> None:
134        """Prints the list of existing distros."""
135        distro_base_dir = get_distro_base_dir()
136        eprint(f'Distro directory: {distro_base_dir}')
137        distros = sorted([p.stem for p in distro_base_dir.glob('*.txt') if p.is_file()])
138        if distros:
139            eprint('─────────\n [bold]Distros[/]\n─────────')
140            for distro in distros:
141                print(distro)
142        else:
143            eprint('No distros exist.')

Class for interacting with a distro (set of Python package requirements).

Distro(name: str, dir_path: Optional[pathlib.Path] = None)
52    def __init__(self, name: str, dir_path: Optional[Path] = None) -> None:
53        self.name = name
54        self.dir_path = dir_path or get_distro_base_dir()
55        self._path = self.dir_path / f'{name}.txt'
name: typing.Annotated[str, Doc('Name of distro')]
dir_path: typing.Annotated[pathlib.Path, Doc('Path to distro directory')]
def exists(self) -> bool:
57    def exists(self) -> bool:
58        """Returns True if the distro exists."""
59        return self._path.exists()

Returns True if the distro exists.

path: pathlib.Path
61    @property
62    def path(self) -> Path:
63        """Gets the path to the distro (requirements file).
64        If no such file exists, raises a NoSuchDistroError."""
65        if not self.exists():
66            raise NoSuchDistroError(self.name)
67        return self._path

Gets the path to the distro (requirements file). If no such file exists, raises a NoSuchDistroError.

def get_packages(self) -> list[str]:
69    def get_packages(self) -> list[str]:
70        """Gets the list of packages in the distro."""
71        packages = []
72        for line in read_lines(self.path):
73            line = line.strip()
74            if not line.startswith('#'):  # skip comments
75                packages.append(line)
76        return packages

Gets the list of packages in the distro.

def lock(self, annotate: bool = False) -> str:
78    def lock(self, annotate: bool = False) -> str:
79        """Locks the packages in a distro to their pinned versions.
80        Returns the output as a string."""
81        logger.info(f'Locking dependencies for {distro_sty(self.name)} distro')
82        cmd = ['uv', 'pip', 'compile', str(self.path)]
83        update_command_with_index_url(cmd)
84        if not annotate:
85            cmd.append('--no-annotate')
86        try:
87            return run_command(cmd, check=True, text=True, capture_output=True).stdout
88        except CalledProcessError as e:
89            raise InvalidDistroError('\n' + e.stderr) from e

Locks the packages in a distro to their pinned versions. Returns the output as a string.

@classmethod
def new( cls, name: str, packages: Optional[list[str]] = None, requirements: Optional[Sequence[Union[str, pathlib.Path]]] = None, distros: Optional[list[str]] = None, force: bool = False) -> Self:
 91    @classmethod
 92    def new(cls,
 93        name: str,
 94        packages: Optional[list[str]] = None,
 95        requirements: Optional[Sequence[AnyPath]] = None,
 96        distros: Optional[list[str]] = None,
 97        force: bool = False
 98    ) -> Self:
 99        """Creates a new distro."""
100        packages = get_packages(packages, requirements, distros)
101        distro_base_dir = get_distro_base_dir()
102        distro_path = distro_base_dir / f'{name}.txt'
103        if distro_path.exists():
104            msg = f'Distro {distro_sty(name)} already exists'
105            if force:
106                logger.warning(f'{msg} -- overwriting')
107                distro_path.unlink()
108            else:
109                raise DistroExistsError(msg)
110        logger.info(f'Creating distro {distro_sty(name)}')
111        with open(distro_path, 'w') as f:
112            for pkg in packages:
113                print(pkg, file=f)
114        logger.info(f'Wrote {distro_sty(name)} requirements to {distro_path}')
115        return cls(name, distro_base_dir)

Creates a new distro.

def remove(self) -> None:
117    def remove(self) -> None:
118        """Deletes the distro."""
119        path = self.path
120        logger.info(f'Deleting {distro_sty(self.name)} distro')
121        path.unlink()
122        logger.info(f'Deleted {path}')

Deletes the distro.

def show(self) -> None:
124    def show(self) -> None:
125        """Prints out the packages in the distro."""
126        eprint(f'Distro {distro_sty(self.name)} is located at: {self.path}')
127        eprint('──────────\n [bold]Packages[/]\n──────────')
128        for pkg in self.get_packages():
129            print(pkg)

Prints out the packages in the distro.

@classmethod
def list(cls) -> None:
132    @classmethod
133    def list(cls) -> None:
134        """Prints the list of existing distros."""
135        distro_base_dir = get_distro_base_dir()
136        eprint(f'Distro directory: {distro_base_dir}')
137        distros = sorted([p.stem for p in distro_base_dir.glob('*.txt') if p.is_file()])
138        if distros:
139            eprint('─────────\n [bold]Distros[/]\n─────────')
140            for distro in distros:
141                print(distro)
142        else:
143            eprint('No distros exist.')

Prints the list of existing distros.