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.')
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.
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.
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.
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).
57 def exists(self) -> bool: 58 """Returns True if the distro exists.""" 59 return self._path.exists()
Returns True if the distro exists.
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.
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.
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.
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.
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.
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.
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.