milieux.cli.env
1from argparse import RawDescriptionHelpFormatter 2from dataclasses import dataclass, field 3from pathlib import Path 4from typing import Any, Optional, Union 5 6from fancy_dataclass.cli import CLIDataclass 7 8from milieux.env import TEMPLATE_ENV_VARS, Environment 9from milieux.errors import NoSuchTemplateError, UserInputError 10from milieux.utils import NonemptyPrompt 11 12 13def _get_name_field(required: bool) -> Any: 14 # make 'name' a positional argument 15 metadata = {'args': ['name'], 'help': 'name of environment'} 16 if not required: 17 metadata['nargs'] = '?' 18 return field(default=None, metadata=metadata) 19 20_packages_field: Any = field( 21 default_factory=list, 22 metadata={'nargs': '+', 'args': ['-p', '--packages'], 'help': 'list of packages to install into the environment (can optionally include constraints, e.g. "numpy>=1.25")'} 23) 24 25_requirements_field: Any = field( 26 default_factory=list, 27 metadata={'nargs': '+', 'args': ['-r', '--requirements'], 'help': 'requirements file(s) listing packages'} 28) 29 30_distros_field: Any = field( 31 default_factory=list, 32 metadata={'nargs': '+', 'args': ['-d', '--distros'], 'help': 'distro name(s) providing packages'} 33) 34 35 36@dataclass 37class _EnvSubcommand(CLIDataclass): 38 39 def run(self) -> None: 40 raise NotImplementedError 41 42 43@dataclass 44class EnvSubcommand(_EnvSubcommand): 45 """Base class for environment subcommands.""" 46 name: str = _get_name_field(required=True) 47 48 49@dataclass 50class EnvActivate(EnvSubcommand, command_name='activate'): 51 """Activate an environment.""" 52 53 def run(self) -> None: 54 Environment(self.name).activate() 55 56 57@dataclass 58class EnvFreeze(EnvSubcommand, command_name='freeze'): 59 """List installed packages in an environment.""" 60 61 def run(self) -> None: 62 Environment(self.name).freeze() 63 64 65@dataclass 66class EnvInstall(EnvSubcommand, command_name='install'): 67 """Install packages into an environment.""" 68 packages: list[str] = _packages_field 69 requirements: list[str] = _requirements_field 70 distros: list[str] = _distros_field 71 upgrade: bool = field(default=False, metadata={'help': 'allow package upgrades'}) 72 editable: Optional[str] = field( 73 default=None, 74 metadata={'args': ['-e', '--editable'], 'help': 'do an editable install of a single local file'} 75 ) 76 77 def run(self) -> None: 78 Environment(self.name).install(packages=self.packages, requirements=self.requirements, distros=self.distros, upgrade=self.upgrade, editable=self.editable) 79 80 81@dataclass 82class EnvList(_EnvSubcommand, command_name='list'): 83 """List all environments.""" 84 85 def run(self) -> None: 86 Environment.list() 87 88 89@dataclass 90class EnvNew(_EnvSubcommand, command_name='new'): 91 """Create a new environment.""" 92 name: Optional[str] = _get_name_field(required=False) 93 seed: bool = field( 94 default=False, 95 metadata={'help': 'install "seed" packages (e.g. `pip`) into environment'} 96 ) 97 python: Optional[str] = field( 98 default=None, 99 metadata={'args': ['-p', '--python'], 'help': 'Python interpreter for the environment'} 100 ) 101 force: bool = field( 102 default=False, 103 metadata={ 104 'args': ['-f', '--force'], 105 'help': 'force overwrite of environment if it exists' 106 } 107 ) 108 109 def run(self) -> None: 110 name = self.name or NonemptyPrompt.ask('Name of environment') 111 Environment.new(name, seed=self.seed, python=self.python, force=self.force) 112 113 114@dataclass 115class EnvRemove(EnvSubcommand, command_name='remove'): 116 """Remove an environment.""" 117 118 def run(self) -> None: 119 assert self.name is not None 120 Environment(self.name).remove() 121 122 123@dataclass 124class EnvShow(EnvSubcommand, command_name='show'): 125 """Show info about an environment.""" 126 list_packages: bool = field(default=False, metadata={'help': 'include list of installed packages'}) 127 128 def run(self) -> None: 129 Environment(self.name).show(list_packages=self.list_packages) 130 131 132@dataclass 133class EnvSync(_EnvSubcommand, 134 command_name='sync', 135 formatter_class=RawDescriptionHelpFormatter, 136 help_descr_brief='sync dependencies for an environment' 137): 138 """Sync dependencies for an environment. 139 140NOTE: it is strongly advised to sync from a set of *locked* dependencies. 141Run `milieux distro lock` to create one.""" 142 name: Optional[str] = _get_name_field(required=True) 143 requirements: list[str] = _requirements_field 144 distros: list[str] = _distros_field 145 146 def run(self) -> None: 147 assert self.name is not None 148 Environment(self.name).sync(requirements=self.requirements, distros=self.distros) 149 150 151_env_template_descr_brief = 'render one or more jinja templates, filling in variables from an environment' 152_env_template_descr = f'{_env_template_descr_brief.capitalize()}.\n\nThe following variables from the environment may be used in {{{{ENV_VARIABLE}}}}\nexpressions within your template:\n' 153_env_template_descr += '\n'.join(f'\t{key}: {val}' for (key, val) in TEMPLATE_ENV_VARS.items()) 154_env_template_descr += '\n\nExtra variables may be provided via the --extra-vars argument.' 155 156@dataclass 157class EnvTemplate(EnvSubcommand, 158 command_name='template', 159 formatter_class=RawDescriptionHelpFormatter, 160 help_descr_brief=_env_template_descr_brief, 161 help_descr=_env_template_descr, 162): 163 """Render a template, filling in variables from an environment.""" 164 templates: list[Path] = field( 165 default_factory=list, 166 metadata={'args': ['-t', '--templates'], 'required': True, 'nargs': '+', 'help': 'jinja template(s) to render'} 167 ) 168 suffix: Optional[str] = field( 169 default=None, 170 metadata={'help': 'suffix to replace template file extensions for output'} 171 ) 172 extra_vars: list[str] = field( 173 default_factory=list, 174 metadata={'nargs': '+', 'help': 'extra variables to pass to template, format is: "VAR1=VALUE1 VAR2=VALUE2 ..."'} 175 ) 176 177 def __post_init__(self) -> None: 178 # parse extra_vars 179 extra_vars = {} 180 for tok in self.extra_vars: 181 if '=' in tok: 182 [key, val] = tok.split('=', maxsplit=1) 183 if key in extra_vars: 184 raise UserInputError(f'Duplicate variable {key!r} in --extra-vars') 185 extra_vars[key] = val 186 else: 187 raise UserInputError(f'Invalid VARIABLE=VALUE string: {tok}') 188 self._extra_vars = extra_vars 189 190 def run(self) -> None: 191 assert self.name is not None 192 if (not self.suffix) and (len(self.templates) > 1): 193 raise UserInputError('When rendering multiple templates, you must set a --suffix for output files') 194 for template in self.templates: 195 if not template.is_file(): 196 raise NoSuchTemplateError(template) 197 env = Environment(self.name) 198 for template in self.templates: 199 env.render_template(template=template, suffix=self.suffix, extra_vars=self._extra_vars) 200 201 202@dataclass 203class EnvUninstall(EnvSubcommand, command_name='uninstall'): 204 """Uninstall packages from an environment.""" 205 packages: list[str] = _packages_field 206 requirements: list[str] = _requirements_field 207 distros: list[str] = _distros_field 208 209 def run(self) -> None: 210 Environment(self.name).uninstall(packages=self.packages, requirements=self.requirements, distros=self.distros) 211 212 213@dataclass 214class EnvCmd(CLIDataclass, command_name='env'): 215 """Manage environments.""" 216 217 subcommand: Union[ 218 EnvActivate, 219 EnvFreeze, 220 EnvInstall, 221 EnvList, 222 EnvNew, 223 EnvRemove, 224 EnvShow, 225 EnvSync, 226 EnvTemplate, 227 EnvUninstall, 228 ] = field(metadata={'subcommand': True})
44@dataclass 45class EnvSubcommand(_EnvSubcommand): 46 """Base class for environment subcommands.""" 47 name: str = _get_name_field(required=True)
Base class for environment subcommands.
Inherited Members
50@dataclass 51class EnvActivate(EnvSubcommand, command_name='activate'): 52 """Activate an environment.""" 53 54 def run(self) -> None: 55 Environment(self.name).activate()
Activate an environment.
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
58@dataclass 59class EnvFreeze(EnvSubcommand, command_name='freeze'): 60 """List installed packages in an environment.""" 61 62 def run(self) -> None: 63 Environment(self.name).freeze()
List installed packages in an environment.
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
66@dataclass 67class EnvInstall(EnvSubcommand, command_name='install'): 68 """Install packages into an environment.""" 69 packages: list[str] = _packages_field 70 requirements: list[str] = _requirements_field 71 distros: list[str] = _distros_field 72 upgrade: bool = field(default=False, metadata={'help': 'allow package upgrades'}) 73 editable: Optional[str] = field( 74 default=None, 75 metadata={'args': ['-e', '--editable'], 'help': 'do an editable install of a single local file'} 76 ) 77 78 def run(self) -> None: 79 Environment(self.name).install(packages=self.packages, requirements=self.requirements, distros=self.distros, upgrade=self.upgrade, editable=self.editable)
Install packages into an environment.
78 def run(self) -> None: 79 Environment(self.name).install(packages=self.packages, requirements=self.requirements, distros=self.distros, upgrade=self.upgrade, editable=self.editable)
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
82@dataclass 83class EnvList(_EnvSubcommand, command_name='list'): 84 """List all environments.""" 85 86 def run(self) -> None: 87 Environment.list()
List all environments.
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
90@dataclass 91class EnvNew(_EnvSubcommand, command_name='new'): 92 """Create a new environment.""" 93 name: Optional[str] = _get_name_field(required=False) 94 seed: bool = field( 95 default=False, 96 metadata={'help': 'install "seed" packages (e.g. `pip`) into environment'} 97 ) 98 python: Optional[str] = field( 99 default=None, 100 metadata={'args': ['-p', '--python'], 'help': 'Python interpreter for the environment'} 101 ) 102 force: bool = field( 103 default=False, 104 metadata={ 105 'args': ['-f', '--force'], 106 'help': 'force overwrite of environment if it exists' 107 } 108 ) 109 110 def run(self) -> None: 111 name = self.name or NonemptyPrompt.ask('Name of environment') 112 Environment.new(name, seed=self.seed, python=self.python, force=self.force)
Create a new environment.
110 def run(self) -> None: 111 name = self.name or NonemptyPrompt.ask('Name of environment') 112 Environment.new(name, seed=self.seed, python=self.python, force=self.force)
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
115@dataclass 116class EnvRemove(EnvSubcommand, command_name='remove'): 117 """Remove an environment.""" 118 119 def run(self) -> None: 120 assert self.name is not None 121 Environment(self.name).remove()
Remove an environment.
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
124@dataclass 125class EnvShow(EnvSubcommand, command_name='show'): 126 """Show info about an environment.""" 127 list_packages: bool = field(default=False, metadata={'help': 'include list of installed packages'}) 128 129 def run(self) -> None: 130 Environment(self.name).show(list_packages=self.list_packages)
Show info about an environment.
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
133@dataclass 134class EnvSync(_EnvSubcommand, 135 command_name='sync', 136 formatter_class=RawDescriptionHelpFormatter, 137 help_descr_brief='sync dependencies for an environment' 138): 139 """Sync dependencies for an environment. 140 141NOTE: it is strongly advised to sync from a set of *locked* dependencies. 142Run `milieux distro lock` to create one.""" 143 name: Optional[str] = _get_name_field(required=True) 144 requirements: list[str] = _requirements_field 145 distros: list[str] = _distros_field 146 147 def run(self) -> None: 148 assert self.name is not None 149 Environment(self.name).sync(requirements=self.requirements, distros=self.distros)
Sync dependencies for an environment.
NOTE: it is strongly advised to sync from a set of locked dependencies.
Run milieux distro lock
to create one.
147 def run(self) -> None: 148 assert self.name is not None 149 Environment(self.name).sync(requirements=self.requirements, distros=self.distros)
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
157@dataclass 158class EnvTemplate(EnvSubcommand, 159 command_name='template', 160 formatter_class=RawDescriptionHelpFormatter, 161 help_descr_brief=_env_template_descr_brief, 162 help_descr=_env_template_descr, 163): 164 """Render a template, filling in variables from an environment.""" 165 templates: list[Path] = field( 166 default_factory=list, 167 metadata={'args': ['-t', '--templates'], 'required': True, 'nargs': '+', 'help': 'jinja template(s) to render'} 168 ) 169 suffix: Optional[str] = field( 170 default=None, 171 metadata={'help': 'suffix to replace template file extensions for output'} 172 ) 173 extra_vars: list[str] = field( 174 default_factory=list, 175 metadata={'nargs': '+', 'help': 'extra variables to pass to template, format is: "VAR1=VALUE1 VAR2=VALUE2 ..."'} 176 ) 177 178 def __post_init__(self) -> None: 179 # parse extra_vars 180 extra_vars = {} 181 for tok in self.extra_vars: 182 if '=' in tok: 183 [key, val] = tok.split('=', maxsplit=1) 184 if key in extra_vars: 185 raise UserInputError(f'Duplicate variable {key!r} in --extra-vars') 186 extra_vars[key] = val 187 else: 188 raise UserInputError(f'Invalid VARIABLE=VALUE string: {tok}') 189 self._extra_vars = extra_vars 190 191 def run(self) -> None: 192 assert self.name is not None 193 if (not self.suffix) and (len(self.templates) > 1): 194 raise UserInputError('When rendering multiple templates, you must set a --suffix for output files') 195 for template in self.templates: 196 if not template.is_file(): 197 raise NoSuchTemplateError(template) 198 env = Environment(self.name) 199 for template in self.templates: 200 env.render_template(template=template, suffix=self.suffix, extra_vars=self._extra_vars)
Render a template, filling in variables from an environment.
191 def run(self) -> None: 192 assert self.name is not None 193 if (not self.suffix) and (len(self.templates) > 1): 194 raise UserInputError('When rendering multiple templates, you must set a --suffix for output files') 195 for template in self.templates: 196 if not template.is_file(): 197 raise NoSuchTemplateError(template) 198 env = Environment(self.name) 199 for template in self.templates: 200 env.render_template(template=template, suffix=self.suffix, extra_vars=self._extra_vars)
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
203@dataclass 204class EnvUninstall(EnvSubcommand, command_name='uninstall'): 205 """Uninstall packages from an environment.""" 206 packages: list[str] = _packages_field 207 requirements: list[str] = _requirements_field 208 distros: list[str] = _distros_field 209 210 def run(self) -> None: 211 Environment(self.name).uninstall(packages=self.packages, requirements=self.requirements, distros=self.distros)
Uninstall packages from an environment.
210 def run(self) -> None: 211 Environment(self.name).uninstall(packages=self.packages, requirements=self.requirements, distros=self.distros)
Runs the main body of the program.
Subclasses should implement this to provide custom behavior.
If the class has a subcommand defined, and it is an instance of CLIDataclass
, the default implementation of run
will be to call the subcommand's own implementation.
Inherited Members
214@dataclass 215class EnvCmd(CLIDataclass, command_name='env'): 216 """Manage environments.""" 217 218 subcommand: Union[ 219 EnvActivate, 220 EnvFreeze, 221 EnvInstall, 222 EnvList, 223 EnvNew, 224 EnvRemove, 225 EnvShow, 226 EnvSync, 227 EnvTemplate, 228 EnvUninstall, 229 ] = field(metadata={'subcommand': True})
Manage environments.