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})
@dataclass
class EnvSubcommand(_EnvSubcommand):
44@dataclass
45class EnvSubcommand(_EnvSubcommand):
46    """Base class for environment subcommands."""
47    name: str = _get_name_field(required=True)

Base class for environment subcommands.

EnvSubcommand(name: str = None)
name: str = None
subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvSubcommand'
Inherited Members
_EnvSubcommand
run
@dataclass
class EnvActivate(EnvSubcommand):
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.

EnvActivate(name: str = None)
def run(self) -> None:
54    def run(self) -> None:
55        Environment(self.name).activate()

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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvActivate'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvFreeze(EnvSubcommand):
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.

EnvFreeze(name: str = None)
def run(self) -> None:
62    def run(self) -> None:
63        Environment(self.name).freeze()

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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvFreeze'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvInstall(EnvSubcommand):
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.

EnvInstall( name: str = None, packages: list[str] = <factory>, requirements: list[str] = <factory>, distros: list[str] = <factory>, upgrade: bool = False, editable: Optional[str] = None)
packages: list[str]
requirements: list[str]
distros: list[str]
upgrade: bool = False
editable: Optional[str] = None
def run(self) -> None:
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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvInstall'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvList(_EnvSubcommand):
82@dataclass
83class EnvList(_EnvSubcommand, command_name='list'):
84    """List all environments."""
85
86    def run(self) -> None:
87        Environment.list()

List all environments.

def run(self) -> None:
86    def run(self) -> None:
87        Environment.list()

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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvList'
@dataclass
class EnvNew(_EnvSubcommand):
 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.

EnvNew( name: Optional[str] = None, seed: bool = False, python: Optional[str] = None, force: bool = False)
name: Optional[str] = None
seed: bool = False
python: Optional[str] = None
force: bool = False
def run(self) -> None:
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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvNew'
@dataclass
class EnvRemove(EnvSubcommand):
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.

EnvRemove(name: str = None)
def run(self) -> None:
119    def run(self) -> None:
120        assert self.name is not None
121        Environment(self.name).remove()

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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvRemove'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvShow(EnvSubcommand):
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.

EnvShow(name: str = None, list_packages: bool = False)
list_packages: bool = False
def run(self) -> None:
129    def run(self) -> None:
130        Environment(self.name).show(list_packages=self.list_packages)

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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvShow'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvSync(_EnvSubcommand):
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.

EnvSync( name: Optional[str] = None, requirements: list[str] = <factory>, distros: list[str] = <factory>)
name: Optional[str] = None
requirements: list[str]
distros: list[str]
def run(self) -> None:
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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvSync'
@dataclass
class EnvTemplate(EnvSubcommand):
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.

EnvTemplate( name: str = None, templates: list[pathlib.Path] = <factory>, suffix: Optional[str] = None, extra_vars: list[str] = <factory>)
templates: list[pathlib.Path]
suffix: Optional[str] = None
extra_vars: list[str]
def run(self) -> None:
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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvTemplate'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvUninstall(EnvSubcommand):
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.

EnvUninstall( name: str = None, packages: list[str] = <factory>, requirements: list[str] = <factory>, distros: list[str] = <factory>)
packages: list[str]
requirements: list[str]
distros: list[str]
def run(self) -> None:
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.

subcommand_field_name: ClassVar[Optional[str]] = None
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvUninstall'
Inherited Members
EnvSubcommand
name
@dataclass
class EnvCmd(fancy_dataclass.cli.CLIDataclass):
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.

subcommand_field_name: ClassVar[Optional[str]] = 'subcommand'
subcommand_dest_name: ClassVar[str] = '_subcommand_EnvCmd'