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.
- snk_cli/__about__.py +4 -0
- snk_cli/__init__.py +5 -0
- snk_cli/cli.py +231 -0
- snk_cli/config/__init__.py +1 -0
- snk_cli/config/config.py +219 -0
- snk_cli/config/utils.py +41 -0
- snk_cli/dynamic_typer.py +249 -0
- snk_cli/options/__init__.py +1 -0
- snk_cli/options/option.py +18 -0
- snk_cli/options/utils.py +117 -0
- snk_cli/subcommands/__init__.py +5 -0
- snk_cli/subcommands/config.py +64 -0
- snk_cli/subcommands/env.py +211 -0
- snk_cli/subcommands/profile.py +60 -0
- snk_cli/subcommands/run.py +471 -0
- snk_cli/subcommands/script.py +134 -0
- snk_cli/subcommands/utils.py +175 -0
- snk_cli/testing.py +19 -0
- snk_cli/utils.py +149 -0
- snk_cli/validate.py +66 -0
- snk_cli/workflow.py +177 -0
- snk_cli-0.0.1.dist-info/METADATA +29 -0
- snk_cli-0.0.1.dist-info/RECORD +25 -0
- snk_cli-0.0.1.dist-info/WHEEL +4 -0
- snk_cli-0.0.1.dist-info/licenses/LICENSE.txt +9 -0
snk_cli/dynamic_typer.py
ADDED
|
@@ -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
|
snk_cli/options/utils.py
ADDED
|
@@ -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,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...")
|