snk-cli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ import typer
2
+ from typing import List, Callable
3
+ from inspect import signature, Parameter
4
+ from makefun import with_signature
5
+
6
+ from .options import Option
7
+ import sys
8
+
9
+
10
+ class DynamicTyper:
11
+ app: typer.Typer
12
+
13
+ def __init_subclass__(cls, **kwargs):
14
+ super().__init_subclass__(**kwargs)
15
+ cls.app = typer.Typer()
16
+
17
+ def __call__(self):
18
+ """
19
+ Invoke the CLI.
20
+ Side Effects:
21
+ Invokes the CLI.
22
+ Examples:
23
+ >>> CLI(Path('/path/to/workflow'))()
24
+ """
25
+ self.app()
26
+
27
+ def register_default_command(self, command: Callable, **command_kwargs) -> None:
28
+ """
29
+ Register a default command to the CLI.
30
+ Args:
31
+ command (Callable): The command to register.
32
+ Side Effects:
33
+ Registers the command to the CLI.
34
+ Examples:
35
+ >>> CLI.register_default_command(my_command)
36
+ """
37
+ from makefun import with_signature
38
+ from inspect import signature, Parameter
39
+
40
+ command_signature = signature(command)
41
+ params = list(command_signature.parameters.values())
42
+ has_ctx = any([p.name == "ctx" for p in params])
43
+ if not has_ctx:
44
+ params.insert(
45
+ 0,
46
+ Parameter(
47
+ "ctx",
48
+ kind=Parameter.POSITIONAL_OR_KEYWORD,
49
+ annotation=typer.Context,
50
+ ),
51
+ )
52
+ command_signature = command_signature.replace(parameters=params)
53
+
54
+ @with_signature(command_signature)
55
+ def wrapper(ctx: typer.Context, *args, **kwargs):
56
+ if ctx.invoked_subcommand is None:
57
+ if has_ctx:
58
+ return command(ctx, *args, **kwargs)
59
+ return command(*args, **kwargs)
60
+
61
+ self.register_callback(wrapper, invoke_without_command=True, **command_kwargs)
62
+
63
+ def register_command(
64
+ self, command: Callable, dynamic_options=None, **command_kwargs
65
+ ) -> None:
66
+ """
67
+ Register a command to the CLI.
68
+ Args:
69
+ command (Callable): The command to register.
70
+ Side Effects:
71
+ Registers the command to the CLI.
72
+ Examples:
73
+ >>> CLI.register_command(my_command)
74
+ """
75
+ if dynamic_options is not None:
76
+ command = self.add_dynamic_options(command, dynamic_options)
77
+ if isinstance(command, DynamicTyper):
78
+ self.app.registered_commands.extend(command.app.registered_commands)
79
+ else:
80
+ self.app.command(**command_kwargs)(command)
81
+
82
+ def register_callback(self, command: Callable, **command_kwargs) -> None:
83
+ """
84
+ Register a callback to the CLI.
85
+ Args:
86
+ command (Callable): The callback to register.
87
+ Side Effects:
88
+ Registers the callback to the CLI.
89
+ Examples:
90
+ >>> CLI.register_callback(my_callback)
91
+ """
92
+ self.app.callback(**command_kwargs)(command)
93
+
94
+ def register_group(self, group: "DynamicTyper", **command_kwargs) -> None:
95
+ """
96
+ Register a subcommand group group to the CLI.
97
+ Args:
98
+ group (DynamicTyper): The subcommand group to register.
99
+ Side Effects:
100
+ Registers the subcommand group to the CLI.
101
+ Examples:
102
+ >>> CLI.register_app(my_group)
103
+ """
104
+ self.app.add_typer(group.app, **command_kwargs)
105
+
106
+ def _create_cli_parameter(self, option: Option):
107
+ """
108
+ Creates a parameter for a CLI option.
109
+ Args:
110
+ option (Option): An Option object containing the option's name, type, required status, default value, and help message.
111
+ Returns:
112
+ Parameter: A parameter object for the CLI option.
113
+ Examples:
114
+ >>> option = Option(name='foo', type='int', required=True, default=0, help='A number')
115
+ >>> create_cli_parameter(option)
116
+ Parameter('foo', kind=Parameter.POSITIONAL_OR_KEYWORD, default=typer.Option(..., help='[CONFIG] A number'), annotation=int)
117
+ """
118
+ return Parameter(
119
+ option.name,
120
+ kind=Parameter.POSITIONAL_OR_KEYWORD,
121
+ default=typer.Option(
122
+ ... if option.required else option.default,
123
+ *[option.flag, option.short_flag] if option.short else [],
124
+ help=f"{option.help}",
125
+ rich_help_panel="Workflow Configuration",
126
+ hidden=option.hidden,
127
+ ),
128
+ annotation=option.type,
129
+ )
130
+
131
+ def check_if_option_passed_via_command_line(self, option: Option):
132
+ """
133
+ Check if an option is passed via the command line.
134
+ Args:
135
+ option (Option): An Option object containing the option's name, type, required status, default value, and help message.
136
+ Returns:
137
+ bool: Whether the option is passed via the command line.
138
+ """
139
+ if option.flag in sys.argv:
140
+ return True
141
+ elif option.type is bool and f"--no-{option.flag[2:]}" in sys.argv:
142
+ # Check for boolean flags like --foo/--no-foo
143
+ return True
144
+ elif option.short and option.short_flag in sys.argv:
145
+ return True
146
+ return False
147
+
148
+ def add_dynamic_options(self, func: Callable, options: List[Option]):
149
+ """
150
+ Function to add dynamic options to a command.
151
+ Args:
152
+ command (Callable): The command to which the dynamic options should be added.
153
+ options (List[dict]): A list of dictionaries containing the option's name, type, required status, default value, and help message.
154
+ Returns:
155
+ Callable: A function with the dynamic options added.
156
+ Examples:
157
+ >>> my_func = add_dynamic_options_to_function(my_func, [{'name': 'foo', 'type': 'int', 'required': True, 'default': 0, 'help': 'A number'}])
158
+ >>> my_func
159
+ <function my_func at 0x7f8f9f9f9f90>
160
+ """
161
+ func_sig = signature(func)
162
+ params = list(func_sig.parameters.values())
163
+ for op in options[::-1]:
164
+ params.insert(1, self._create_cli_parameter(op))
165
+ new_sig = func_sig.replace(parameters=params)
166
+
167
+ @with_signature(func_signature=new_sig, func_name=func.__name__)
168
+ def func_wrapper(*args, **kwargs):
169
+ """
170
+ Wraps a function with dynamic options.
171
+ Args:
172
+ *args: Variable length argument list.
173
+ **kwargs: Arbitrary keyword arguments.
174
+ Returns:
175
+ Callable: A wrapped function with the dynamic options added.
176
+ Notes:
177
+ This function is used in the `add_dynamic_options_to_function` function.
178
+ """
179
+ flat_config = None
180
+
181
+ if kwargs.get("configfile"):
182
+ from snakemake import load_configfile
183
+ from .utils import flatten
184
+
185
+ snakemake_config = load_configfile(kwargs["configfile"])
186
+ flat_config = flatten(snakemake_config)
187
+
188
+ for snk_cli_option in options:
189
+
190
+ def add_option_to_args():
191
+ kwargs["ctx"].args.extend([f"--{snk_cli_option.name}", kwargs[snk_cli_option.name]])
192
+
193
+ passed_via_command_line = self.check_if_option_passed_via_command_line(
194
+ snk_cli_option
195
+ )
196
+
197
+ if flat_config is None:
198
+ # If no config file is provided then all options should be added to the arguments
199
+ # later on we will check to see if they differ from any defaults
200
+ add_option_to_args()
201
+ elif passed_via_command_line:
202
+ # If an option is passed via the command line if should override the default
203
+ add_option_to_args()
204
+ elif flat_config and snk_cli_option.original_key not in flat_config:
205
+ # If a config file is provided and the snk_cli_option key isn't in it,
206
+ # add the snk_cli_option to the arguments
207
+ add_option_to_args()
208
+
209
+ kwargs = {
210
+ k: v for k, v in kwargs.items() if k in func_sig.parameters.keys()
211
+ }
212
+ return func(*args, **kwargs)
213
+
214
+ return func_wrapper
215
+
216
+ def error(self, msg, exit=True):
217
+ """
218
+ Logs an error message (red) and exits (optional).
219
+ Args:
220
+ msg (str): The error message to log.
221
+ exit (bool): Whether to exit after logging the error message.
222
+ """
223
+ typer.secho(msg, fg="red", err=True)
224
+ if exit:
225
+ raise typer.Exit(1)
226
+
227
+ def success(self, msg):
228
+ """
229
+ Logs a success message (green).
230
+ Args:
231
+ msg (str): The success message to log.
232
+ """
233
+ typer.secho(msg, fg="green")
234
+
235
+ def log(self, msg, color="yellow", stderr=True):
236
+ """
237
+ Logs a message (yellow).
238
+ Args:
239
+ msg (str): The message to log.
240
+ """
241
+ typer.secho(msg, fg=color, err=stderr)
242
+
243
+ def echo(self, msg):
244
+ """
245
+ Prints a message.
246
+ Args:
247
+ msg (str): The message to print.
248
+ """
249
+ typer.echo(msg)
@@ -0,0 +1 @@
1
+ from .option import Option # noqa: F401
@@ -0,0 +1,18 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Optional
3
+
4
+
5
+ @dataclass
6
+ class Option:
7
+ name: str
8
+ original_key: str
9
+ default: Any
10
+ updated: bool
11
+ help: str
12
+ type: Any
13
+ required: bool
14
+ short: Optional[str]
15
+ flag: str
16
+ short_flag: Optional[str]
17
+ hidden: bool = False
18
+ from_annotation: bool = False
@@ -0,0 +1,117 @@
1
+ from typing import List, Any
2
+ from ..config.config import SnkConfig
3
+ from ..utils import get_default_type, flatten
4
+ from .option import Option
5
+ from pathlib import Path
6
+
7
+ types = {
8
+ "int": int,
9
+ "integer": int,
10
+ "float": float,
11
+ "str": str,
12
+ "string": str,
13
+ "path": Path,
14
+ "bool": bool,
15
+ "boolean": bool,
16
+ "list": List[str],
17
+ "list[str]": List[str],
18
+ "list[path]": List[Path],
19
+ "list[int]": List[int],
20
+ }
21
+
22
+ def get_keys_from_annotation(annotations):
23
+ # Get the unique keys from the annotations
24
+ # preserving the order
25
+ keys = []
26
+ for key in annotations:
27
+ key = ":".join(key.split(":")[:-1])
28
+ if key not in keys:
29
+ keys.append(key)
30
+ return keys
31
+
32
+ def create_option_from_annotation(
33
+ annotation_key: str,
34
+ annotation_values: dict,
35
+ default_values: dict,
36
+ from_annotation: bool = False,
37
+ ) -> Option:
38
+ """
39
+ Create an Option object from a given annotation.
40
+ Args:
41
+ annotation_key: The key in the annotations.
42
+ annotation_values: The dictionary of annotation values.
43
+ default_values: default value from config.
44
+ Returns:
45
+ An Option object.
46
+ """
47
+ config_default = default_values.get(annotation_key, None)
48
+
49
+ default = annotation_values.get(f"{annotation_key}:default", config_default)
50
+ updated = False
51
+ if config_default is None or default != config_default:
52
+ updated = True
53
+ type = annotation_values.get(f"{annotation_key}:type", get_default_type(default))
54
+ assert (
55
+ type is not None
56
+ ), f"Type for {annotation_key} should be one of {', '.join(types.keys())}."
57
+ annotation_type = types.get(
58
+ type.lower(), List[str] if "list" in type.lower() else str
59
+ )
60
+ name = annotation_values.get(
61
+ f"{annotation_key}:name", annotation_key.replace(":", "_")
62
+ ).replace("-", "_")
63
+ short = annotation_values.get(f"{annotation_key}:short", None)
64
+ hidden = annotation_values.get(f"{annotation_key}:hidden", False)
65
+ return Option(
66
+ name=name,
67
+ original_key=annotation_key,
68
+ default=annotation_values.get(f"{annotation_key}:default", default),
69
+ updated=updated,
70
+ help=annotation_values.get(f"{annotation_key}:help", ""),
71
+ type=annotation_type,
72
+ required=annotation_values.get(f"{annotation_key}:required", False),
73
+ short=short,
74
+ flag=f"--{name.replace('_', '-')}",
75
+ short_flag=f"-{short}" if short else None,
76
+ hidden=hidden,
77
+ from_annotation=from_annotation,
78
+ )
79
+
80
+
81
+ def build_dynamic_cli_options(
82
+ snakemake_config: dict, snk_config: SnkConfig
83
+ ) -> List[dict]:
84
+ """
85
+ Builds a list of options from a snakemake config and a snk config.
86
+ Args:
87
+ snakemake_config (dict): A snakemake config.
88
+ snk_config (SnkConfig): A snk config.
89
+ Returns:
90
+ List[dict]: A list of options.
91
+ """
92
+ flat_config = flatten(snakemake_config)
93
+ flat_annotations = flatten(snk_config.cli)
94
+ annotation_keys = get_keys_from_annotation(flat_annotations)
95
+ options = {}
96
+
97
+ # For every parameter in the config, create an option from the corresponding annotation
98
+ for parameter in flat_config:
99
+ if parameter not in annotation_keys and snk_config.skip_missing:
100
+ continue
101
+ options[parameter] = create_option_from_annotation(
102
+ parameter,
103
+ flat_annotations,
104
+ default_values=flat_config,
105
+ )
106
+
107
+ # For every annotation not in config, create an option with default values
108
+ for key in annotation_keys:
109
+ if key not in options:
110
+ # in annotation but not in config
111
+ options[key] = create_option_from_annotation(
112
+ key,
113
+ flat_annotations,
114
+ default_values={},
115
+ from_annotation=True,
116
+ )
117
+ return list(options.values())
@@ -0,0 +1,5 @@
1
+ from .env import EnvApp
2
+ from .config import ConfigApp
3
+ from .run import RunApp
4
+ from .script import ScriptApp
5
+ from .profile import ProfileApp
@@ -0,0 +1,64 @@
1
+ from typing import List
2
+ import typer
3
+
4
+ from snk_cli.dynamic_typer import DynamicTyper
5
+ from snk_cli.options.option import Option
6
+ from ..workflow import Workflow
7
+
8
+
9
+ class ConfigApp(DynamicTyper):
10
+ def __init__(self, workflow: Workflow, options: List[Option]):
11
+ """
12
+ Initializes the ConfigApp class.
13
+ Args:
14
+ workflow (Workflow): The workflow to configure.
15
+ """
16
+ self.options = options
17
+ self.workflow = workflow
18
+ self.register_command(self.config, help="Show the workflow configuration.")
19
+
20
+ def config(
21
+ self, ctx: typer.Context, pretty: bool = typer.Option(False, "--pretty", "-p")
22
+ ):
23
+ """
24
+ Prints the configuration for the workflow.
25
+ Args:
26
+ pretty (bool, optional): Whether to print the configuration in a pretty format. Defaults to False.
27
+ Returns:
28
+ None
29
+ Examples:
30
+ >>> ConfigApp.show(pretty=True)
31
+ # Pretty printed configuration
32
+ """
33
+ import yaml
34
+ from collections import defaultdict
35
+ from rich.console import Console
36
+ from rich.syntax import Syntax
37
+ from snk_cli.utils import convert_key_to_snakemake_format
38
+
39
+ def deep_update(source, overrides):
40
+ for key, value in overrides.items():
41
+ if isinstance(value, dict):
42
+ if not isinstance(source.get(key), dict):
43
+ # If the existing value is not a dictionary, replace it with one
44
+ source[key] = {}
45
+ # Now we are sure that source[key] is a dictionary, so we can update it
46
+ deep_update(source[key], value)
47
+ else:
48
+ source[key] = value
49
+ return source
50
+
51
+ collapsed_data = defaultdict(dict)
52
+ config_dict = [
53
+ convert_key_to_snakemake_format(option.original_key, option.default)
54
+ for option in self.options
55
+ ]
56
+ for d in config_dict:
57
+ deep_update(collapsed_data, d)
58
+ yaml_str = yaml.dump(dict(collapsed_data))
59
+ if pretty:
60
+ syntax = Syntax(yaml_str, "yaml")
61
+ console = Console()
62
+ console.print(syntax)
63
+ else:
64
+ typer.echo(yaml_str)
@@ -0,0 +1,211 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from pathlib import Path
5
+ import sys
6
+ from typing import List, Optional
7
+ import typer
8
+
9
+ from snk_cli.dynamic_typer import DynamicTyper
10
+ from .utils import create_snakemake_workflow
11
+ from ..workflow import Workflow
12
+ from rich.console import Console
13
+ from rich.syntax import Syntax
14
+ from snakemake.deployment.conda import Conda, Env, CreateCondaEnvironmentException
15
+ from snk_cli.config.config import get_config_from_workflow_dir
16
+
17
+ from concurrent.futures import ProcessPoolExecutor
18
+
19
+ def get_num_cores(default=4):
20
+ try:
21
+ return os.cpu_count()
22
+ except:
23
+ return default
24
+
25
+ def create_conda_environment(args):
26
+ # unpack args
27
+ env_path_str, snakefile, snakemake_config, configfiles, conda_prefix_dir_str = args
28
+ # Reconstruct the snakemake_workflow configuration
29
+ conda_prefix_dir = Path(conda_prefix_dir_str).resolve()
30
+ snakemake_workflow = create_snakemake_workflow(
31
+ snakefile,
32
+ config=snakemake_config,
33
+ configfiles=configfiles,
34
+ use_conda=True,
35
+ conda_prefix=conda_prefix_dir,
36
+ )
37
+ env_path = Path(env_path_str).resolve()
38
+ env = Env(snakemake_workflow, env_file=env_path)
39
+ try:
40
+ env.create()
41
+ except CreateCondaEnvironmentException as e:
42
+ typer.secho(str(e), fg="red", err=True)
43
+ return 1
44
+ return 0
45
+
46
+
47
+ class EnvApp(DynamicTyper):
48
+ def __init__(
49
+ self,
50
+ workflow: Workflow,
51
+ conda_prefix_dir: Path,
52
+ snakemake_config,
53
+ snakefile: Path,
54
+ ):
55
+ self.workflow = workflow
56
+ self.conda_prefix_dir = conda_prefix_dir
57
+ self.snakemake_config = snakemake_config
58
+ self.snakefile = snakefile
59
+ self.configfile = get_config_from_workflow_dir(self.workflow.path)
60
+ self.snakemake_workflow = create_snakemake_workflow(
61
+ self.snakefile,
62
+ config=self.snakemake_config,
63
+ configfiles=[self.configfile] if self.configfile else None,
64
+ use_conda=True,
65
+ conda_prefix=self.conda_prefix_dir.resolve(),
66
+ )
67
+ self.register_command(self.list, help="List the environments in the workflow.")
68
+ self.register_command(self.show, help="Show the contents of an environment.")
69
+ self.register_command(
70
+ self.run, help="Run a command in one of the workflow environments."
71
+ )
72
+ self.register_command(
73
+ self.activate, help="Activate a workflow conda environment."
74
+ )
75
+ self.register_command(self.remove, help="Remove conda environments.")
76
+ self.register_command(self.create, help="Create conda environments.")
77
+
78
+ def list(
79
+ self,
80
+ verbose: bool = typer.Option(
81
+ False, "--verbose", "-v", help="Show conda paths."
82
+ ),
83
+ ):
84
+ from rich.console import Console
85
+ from rich.table import Table
86
+
87
+ table = Table("Name", "CMD", "Env", show_header=True, show_lines=True)
88
+ for env in self.workflow.environments:
89
+ conda = Env(self.snakemake_workflow, env_file=env.resolve())
90
+ # address relative to cwd
91
+ address = Path(conda.address)
92
+ if address.exists():
93
+ address = str(address) if verbose else f"[green]{address.name}[/green]"
94
+ cmd = f"{self.workflow.name} env activate {env.stem}"
95
+ else:
96
+ address = ""
97
+ cmd = f"{self.workflow.name} env show {env.stem}"
98
+ table.add_row(
99
+ env.stem, cmd, address
100
+ )
101
+ console = Console()
102
+ console.print(table)
103
+
104
+ def _get_conda_env_path(self, name: str) -> Path:
105
+ env = [e for e in self.workflow.environments if e.stem == name]
106
+ if not env:
107
+ self.error(f"Environment {name} not found!")
108
+ return env[0]
109
+
110
+ def _shellcmd(self, env_address: str, cmd: str) -> str:
111
+ if sys.platform.lower().startswith("win"):
112
+ return Conda().shellcmd_win(env_address, cmd)
113
+ return Conda().shellcmd(env_address, cmd)
114
+
115
+ def show(
116
+ self,
117
+ name: str = typer.Argument(..., help="The name of the environment."),
118
+ pretty: bool = typer.Option(
119
+ False, "--pretty", "-p", help="Pretty print the environment."
120
+ ),
121
+ ):
122
+ env_path = self._get_conda_env_path(name)
123
+ env_file_text = env_path.read_text()
124
+ if pretty:
125
+ syntax = Syntax(env_file_text, "yaml")
126
+ console = Console()
127
+ console.print(syntax)
128
+ else:
129
+ typer.echo(env_file_text)
130
+
131
+ def run(
132
+ self,
133
+ name: str = typer.Argument(..., help="The name of the environment."),
134
+ verbose: bool = typer.Option(
135
+ False, "--verbose", "-v", help="Print the command to run."
136
+ ),
137
+ cmd: List[str] = typer.Argument(..., help="The command to run in environment."),
138
+ ):
139
+ env_path = self._get_conda_env_path(name)
140
+ env = Env(self.snakemake_workflow, env_file=env_path.resolve())
141
+ env.create()
142
+ cmd = self._shellcmd(env.address, " ".join(cmd))
143
+ if verbose:
144
+ self.log(cmd)
145
+ user_shell = os.environ.get("SHELL", "/bin/bash")
146
+ subprocess.run(cmd, shell=True, env=os.environ.copy(), executable=user_shell)
147
+
148
+ def remove(
149
+ self,
150
+ name: Optional[str] = typer.Argument(None, help="The name of the environment. If not provided, all environments will be deleted."),
151
+ force: bool = typer.Option(
152
+ False, "--force", "-f", help="Force deletion of the environments."
153
+ ),
154
+ ):
155
+ if name:
156
+ env_path = self._get_conda_env_path(name)
157
+ env = Env(self.snakemake_workflow, env_file=env_path.resolve())
158
+ path = Path(env.address)
159
+ if not path.exists():
160
+ self.error(f"Environment {name} not created!")
161
+ else:
162
+ path = self.conda_prefix_dir
163
+ if force or input(f"Delete {path}? [y/N] ").lower() == "y":
164
+ shutil.rmtree(path)
165
+ self.success(f"Deleted {path}!")
166
+
167
+ def create(
168
+ self,
169
+ names: Optional[List[str]] = typer.Argument(None, help="The names of the environments to create. If not provided, all environments will be created."),
170
+ max_workers: int = typer.Option(get_num_cores(), "--workers", "-w", help="Max number of envs to create in parallel.")
171
+ ):
172
+ if names:
173
+ env_paths = [self._get_conda_env_path(name) for name in names]
174
+ else:
175
+ env_paths = self.workflow.environments
176
+ env_args = [
177
+ (
178
+ path,
179
+ self.snakefile,
180
+ self.snakemake_config,
181
+ [self.configfile] if self.configfile else None,
182
+ self.conda_prefix_dir.resolve(),
183
+ )
184
+ for path in env_paths
185
+ ]
186
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
187
+ status_codes = executor.map(create_conda_environment, env_args)
188
+ if any(status_codes):
189
+ self.error("Failed to create all conda environments!")
190
+ if names:
191
+ self.success(f"Created environment{'s' if len(names) > 1 else ''} {' '.join(names)}!")
192
+ else:
193
+ self.success("All conda environments created!")
194
+
195
+ def activate(
196
+ self,
197
+ name: str = typer.Argument(..., help="The name of the environment."),
198
+ verbose: bool = typer.Option(
199
+ False, "--verbose", "-v", help="Print the activation command."
200
+ ),
201
+ ):
202
+ env_path = self._get_conda_env_path(name)
203
+ self.log(f"Activating {name} environment... (type 'exit' to deactivate)")
204
+ env = Env(self.snakemake_workflow, env_file=env_path.resolve())
205
+ env.create()
206
+ user_shell = os.environ.get("SHELL", "/bin/bash")
207
+ activate_cmd = self._shellcmd(env.address, user_shell)
208
+ if verbose:
209
+ self.log(activate_cmd)
210
+ subprocess.run(activate_cmd, shell=True, env=os.environ.copy(), executable=user_shell)
211
+ self.log(f"Exiting {name} environment...")