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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from snk_cli.dynamic_typer import DynamicTyper
|
|
3
|
+
from ..workflow import Workflow
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.syntax import Syntax
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProfileApp(DynamicTyper):
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
workflow: Workflow,
|
|
13
|
+
):
|
|
14
|
+
self.workflow = workflow
|
|
15
|
+
self.register_command(self.list, help="List the profiles in the workflow.")
|
|
16
|
+
self.register_command(
|
|
17
|
+
self.show, help="Show the contents of a profile."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def list(
|
|
21
|
+
self,
|
|
22
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show profiles as paths."),
|
|
23
|
+
):
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
table = Table("Name", "CMD", show_header=True, show_lines=True)
|
|
27
|
+
if verbose:
|
|
28
|
+
table.add_column("Path")
|
|
29
|
+
for profile in self.workflow.profiles:
|
|
30
|
+
if verbose:
|
|
31
|
+
path = str(profile.resolve())
|
|
32
|
+
table.add_row(profile.stem, f"{self.workflow.name} profile show {profile.stem}", path)
|
|
33
|
+
else:
|
|
34
|
+
table.add_row(profile.stem, f"{self.workflow.name} profile show {profile.stem}")
|
|
35
|
+
console = Console()
|
|
36
|
+
console.print(table)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_profile_path(self, name: str) -> Path:
|
|
41
|
+
profile = [p for p in self.workflow.profiles if p.name == name or p.stem == name]
|
|
42
|
+
if not profile:
|
|
43
|
+
self.error(f"Profile {name} not found!")
|
|
44
|
+
return profile[0] / "config.yaml"
|
|
45
|
+
|
|
46
|
+
def show(
|
|
47
|
+
self,
|
|
48
|
+
name: str = typer.Argument(..., help="The name of the profile."),
|
|
49
|
+
pretty: bool = typer.Option(
|
|
50
|
+
False, "--pretty", "-p", help="Pretty print the profile."
|
|
51
|
+
),
|
|
52
|
+
):
|
|
53
|
+
profile_path = self._get_profile_path(name)
|
|
54
|
+
profile_file_text = profile_path.read_text()
|
|
55
|
+
if pretty:
|
|
56
|
+
syntax = Syntax(profile_file_text, "yaml")
|
|
57
|
+
console = Console()
|
|
58
|
+
console.print(syntax)
|
|
59
|
+
else:
|
|
60
|
+
typer.echo(profile_file_text)
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import typer
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
from snk_cli.dynamic_typer import DynamicTyper
|
|
7
|
+
from snk_cli.options.option import Option
|
|
8
|
+
from ..workflow import Workflow
|
|
9
|
+
from snk_cli.utils import (
|
|
10
|
+
parse_config_args,
|
|
11
|
+
dag_filetype_callback,
|
|
12
|
+
check_command_available
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from snk_cli.config.config import (
|
|
16
|
+
SnkConfig,
|
|
17
|
+
get_config_from_workflow_dir,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunApp(DynamicTyper):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
conda_prefix_dir: Path,
|
|
25
|
+
snk_config: SnkConfig,
|
|
26
|
+
singularity_prefix_dir: Path,
|
|
27
|
+
snakefile: Path,
|
|
28
|
+
workflow: Workflow,
|
|
29
|
+
logo: str,
|
|
30
|
+
verbose: bool,
|
|
31
|
+
dynamic_run_options: List[Option],
|
|
32
|
+
):
|
|
33
|
+
self.conda_prefix_dir = conda_prefix_dir
|
|
34
|
+
self.singularity_prefix_dir = singularity_prefix_dir
|
|
35
|
+
self.snk_config = snk_config
|
|
36
|
+
self.snakefile = snakefile
|
|
37
|
+
self.workflow = workflow
|
|
38
|
+
self.verbose = verbose
|
|
39
|
+
self.logo = logo
|
|
40
|
+
self.options = dynamic_run_options
|
|
41
|
+
|
|
42
|
+
self.register_command(
|
|
43
|
+
self.run,
|
|
44
|
+
dynamic_options=self.options,
|
|
45
|
+
help="Run the workflow.\n\nAll unrecognized arguments are passed onto Snakemake.",
|
|
46
|
+
context_settings={
|
|
47
|
+
"allow_extra_args": True,
|
|
48
|
+
"ignore_unknown_options": True,
|
|
49
|
+
"help_option_names": ["-h", "--help"],
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _print_snakemake_help(value: bool):
|
|
54
|
+
"""
|
|
55
|
+
Print the snakemake help and exit.
|
|
56
|
+
Args:
|
|
57
|
+
value (bool): If True, print the snakemake help and exit.
|
|
58
|
+
Side Effects:
|
|
59
|
+
Prints the snakemake help and exits.
|
|
60
|
+
Examples:
|
|
61
|
+
>>> CLI._print_snakemake_help(True)
|
|
62
|
+
"""
|
|
63
|
+
if value:
|
|
64
|
+
import snakemake
|
|
65
|
+
|
|
66
|
+
snakemake.main("-h")
|
|
67
|
+
|
|
68
|
+
def run(
|
|
69
|
+
self,
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
configfile: Path = typer.Option(
|
|
72
|
+
None,
|
|
73
|
+
"--config",
|
|
74
|
+
help="Path to snakemake config file. Overrides existing workflow configuration.",
|
|
75
|
+
exists=True,
|
|
76
|
+
dir_okay=False,
|
|
77
|
+
),
|
|
78
|
+
resource: List[Path] = typer.Option(
|
|
79
|
+
[],
|
|
80
|
+
"--resource",
|
|
81
|
+
"-r",
|
|
82
|
+
help="Additional resources to copy from workflow directory at run time.",
|
|
83
|
+
),
|
|
84
|
+
profile: Optional[str] = typer.Option(
|
|
85
|
+
None,
|
|
86
|
+
"--profile",
|
|
87
|
+
"-p",
|
|
88
|
+
help="Name of profile to use for configuring Snakemake.",
|
|
89
|
+
),
|
|
90
|
+
force: bool = typer.Option(
|
|
91
|
+
False,
|
|
92
|
+
"--force",
|
|
93
|
+
"-f",
|
|
94
|
+
help="Force the execution of workflow regardless of already created output.",
|
|
95
|
+
),
|
|
96
|
+
dry: bool = typer.Option(
|
|
97
|
+
False,
|
|
98
|
+
"--dry",
|
|
99
|
+
"-n",
|
|
100
|
+
help="Do not execute anything, and display what would be done.",
|
|
101
|
+
),
|
|
102
|
+
lock: bool = typer.Option(
|
|
103
|
+
False, "--lock", "-l", help="Lock the working directory."
|
|
104
|
+
),
|
|
105
|
+
dag: Optional[Path] = typer.Option(
|
|
106
|
+
None,
|
|
107
|
+
"--dag",
|
|
108
|
+
"-d",
|
|
109
|
+
help="Save directed acyclic graph to file. Must end in .pdf, .png or .svg",
|
|
110
|
+
callback=dag_filetype_callback,
|
|
111
|
+
),
|
|
112
|
+
cores: int = typer.Option(
|
|
113
|
+
None,
|
|
114
|
+
"--cores",
|
|
115
|
+
"-c",
|
|
116
|
+
help="Set the number of cores to use. If None will use all cores.",
|
|
117
|
+
),
|
|
118
|
+
no_conda: bool = typer.Option(
|
|
119
|
+
False,
|
|
120
|
+
"--no-conda",
|
|
121
|
+
help="Do not use conda environments.",
|
|
122
|
+
),
|
|
123
|
+
keep_resources: bool = typer.Option(
|
|
124
|
+
False,
|
|
125
|
+
"--keep-resources",
|
|
126
|
+
help="Keep resources after pipeline completes.",
|
|
127
|
+
),
|
|
128
|
+
keep_snakemake: bool = typer.Option(
|
|
129
|
+
False,
|
|
130
|
+
"--keep-snakemake",
|
|
131
|
+
help="Keep .snakemake folder after pipeline completes.",
|
|
132
|
+
),
|
|
133
|
+
verbose: Optional[bool] = typer.Option(
|
|
134
|
+
False,
|
|
135
|
+
"--verbose",
|
|
136
|
+
"-v",
|
|
137
|
+
help="Run workflow in verbose mode.",
|
|
138
|
+
),
|
|
139
|
+
help_snakemake: Optional[bool] = typer.Option(
|
|
140
|
+
False,
|
|
141
|
+
"--help-snakemake",
|
|
142
|
+
"-hs",
|
|
143
|
+
help="Print the snakemake help and exit.",
|
|
144
|
+
is_eager=True,
|
|
145
|
+
callback=_print_snakemake_help,
|
|
146
|
+
show_default=False,
|
|
147
|
+
),
|
|
148
|
+
):
|
|
149
|
+
"""
|
|
150
|
+
Run the workflow.
|
|
151
|
+
Args:
|
|
152
|
+
configfile (Path): Path to snakemake config file. Overrides existing config and defaults.
|
|
153
|
+
resource (List[Path]): Additional resources to copy to workdir at run time.
|
|
154
|
+
keep_resources (bool): Keep resources.
|
|
155
|
+
cleanup_snakemake (bool): Keep .snakemake folder.
|
|
156
|
+
cores (int): Set the number of cores to use. If None will use all cores.
|
|
157
|
+
verbose (bool): Run workflow in verbose mode.
|
|
158
|
+
help_snakemake (bool): Print the snakemake help and exit.
|
|
159
|
+
Side Effects:
|
|
160
|
+
Runs the workflow.
|
|
161
|
+
Examples:
|
|
162
|
+
>>> CLI.run(target='my_target', configfile=Path('/path/to/config.yaml'), resource=[Path('/path/to/resource')], verbose=True)
|
|
163
|
+
"""
|
|
164
|
+
import snakemake
|
|
165
|
+
import shutil
|
|
166
|
+
import sys
|
|
167
|
+
|
|
168
|
+
self.verbose = verbose
|
|
169
|
+
args = []
|
|
170
|
+
if self.snk_config.additional_snakemake_args:
|
|
171
|
+
if verbose:
|
|
172
|
+
self.log(
|
|
173
|
+
f"Using additional snakemake args: {' '.join(self.snk_config.additional_snakemake_args)}",
|
|
174
|
+
color=typer.colors.MAGENTA
|
|
175
|
+
)
|
|
176
|
+
args.extend(self.snk_config.additional_snakemake_args)
|
|
177
|
+
if not cores:
|
|
178
|
+
cores = "all"
|
|
179
|
+
args.extend(
|
|
180
|
+
[
|
|
181
|
+
"--rerun-incomplete",
|
|
182
|
+
f"--cores={cores}",
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
if self.singularity_prefix_dir and "--use-singularity" in ctx.args:
|
|
186
|
+
# only set prefix if --use-singularity is explicitly called
|
|
187
|
+
args.append(f"--singularity-prefix={self.singularity_prefix_dir}")
|
|
188
|
+
if verbose:
|
|
189
|
+
self.log(f"Using singularity prefix: {self.singularity_prefix_dir}", color=typer.colors.MAGENTA)
|
|
190
|
+
if not self.snakefile.exists():
|
|
191
|
+
raise ValueError("Could not find Snakefile") # this should occur at install
|
|
192
|
+
else:
|
|
193
|
+
args.append(f"--snakefile={self.snakefile}")
|
|
194
|
+
|
|
195
|
+
if not configfile:
|
|
196
|
+
configfile = get_config_from_workflow_dir(self.workflow.path)
|
|
197
|
+
if configfile:
|
|
198
|
+
args.append(f"--configfile={configfile}")
|
|
199
|
+
|
|
200
|
+
if profile:
|
|
201
|
+
found_profile = [p for p in self.workflow.profiles if profile == p.name]
|
|
202
|
+
if found_profile:
|
|
203
|
+
profile = found_profile[0]
|
|
204
|
+
args.append(f"--profile={profile}")
|
|
205
|
+
|
|
206
|
+
# Set up conda frontend
|
|
207
|
+
conda_found = check_command_available("conda")
|
|
208
|
+
if not conda_found and verbose:
|
|
209
|
+
typer.secho(
|
|
210
|
+
"Conda not found! Install conda to use environments.\n",
|
|
211
|
+
fg=typer.colors.MAGENTA,
|
|
212
|
+
err=True,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if conda_found and self.snk_config.conda and not no_conda:
|
|
216
|
+
args.extend(
|
|
217
|
+
[
|
|
218
|
+
"--use-conda",
|
|
219
|
+
f"--conda-prefix={self.conda_prefix_dir}",
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
if not check_command_available("mamba"):
|
|
223
|
+
if verbose:
|
|
224
|
+
typer.secho(
|
|
225
|
+
"Could not find mamba, using conda instead...",
|
|
226
|
+
fg=typer.colors.MAGENTA,
|
|
227
|
+
err=True,
|
|
228
|
+
)
|
|
229
|
+
args.append("--conda-frontend=conda")
|
|
230
|
+
else:
|
|
231
|
+
args.append("--conda-frontend=mamba")
|
|
232
|
+
|
|
233
|
+
if verbose:
|
|
234
|
+
args.insert(0, "--verbose")
|
|
235
|
+
|
|
236
|
+
if force:
|
|
237
|
+
args.append("--forceall")
|
|
238
|
+
|
|
239
|
+
if dry:
|
|
240
|
+
args.append("--dryrun")
|
|
241
|
+
|
|
242
|
+
if not lock:
|
|
243
|
+
args.append("--nolock")
|
|
244
|
+
targets_and_or_snakemake, config_dict_list = parse_config_args(
|
|
245
|
+
ctx.args, options=self.options
|
|
246
|
+
)
|
|
247
|
+
targets_and_or_snakemake = [
|
|
248
|
+
t.replace("--snake-", "-") for t in targets_and_or_snakemake
|
|
249
|
+
]
|
|
250
|
+
args.extend(targets_and_or_snakemake)
|
|
251
|
+
configs = []
|
|
252
|
+
for config_dict in config_dict_list:
|
|
253
|
+
for key, value in config_dict.items():
|
|
254
|
+
configs.append(f"{key}={value}")
|
|
255
|
+
|
|
256
|
+
if configs:
|
|
257
|
+
args.extend(["--config", *configs])
|
|
258
|
+
if verbose:
|
|
259
|
+
typer.secho(f"snakemake {' '.join(args)}\n", fg=typer.colors.MAGENTA, err=True)
|
|
260
|
+
if not keep_snakemake and Path(".snakemake").exists():
|
|
261
|
+
keep_snakemake = True
|
|
262
|
+
try:
|
|
263
|
+
self.snk_config.add_resources(resource, self.workflow.path)
|
|
264
|
+
except FileNotFoundError as e:
|
|
265
|
+
self.error(str(e))
|
|
266
|
+
with self._copy_resources(
|
|
267
|
+
self.snk_config.resources,
|
|
268
|
+
cleanup=not keep_resources,
|
|
269
|
+
symlink_resources=self.snk_config.symlink_resources,
|
|
270
|
+
):
|
|
271
|
+
if dag:
|
|
272
|
+
return self._save_dag(snakemake_args=args, filename=dag)
|
|
273
|
+
try:
|
|
274
|
+
snakemake.parse_config = parse_config_monkeypatch
|
|
275
|
+
snakemake.main(args)
|
|
276
|
+
except SystemExit as e:
|
|
277
|
+
status = int(str(e))
|
|
278
|
+
if status:
|
|
279
|
+
sys.exit(status)
|
|
280
|
+
if not keep_snakemake and Path(".snakemake").exists():
|
|
281
|
+
typer.secho("Cleaning up '.snakemake' folder... use --keep-snakemake to keep.", fg=typer.colors.YELLOW, err=True)
|
|
282
|
+
shutil.rmtree(".snakemake")
|
|
283
|
+
|
|
284
|
+
def _save_dag(self, snakemake_args: List[str], filename: Path):
|
|
285
|
+
from contextlib import redirect_stdout
|
|
286
|
+
import snakemake
|
|
287
|
+
import subprocess
|
|
288
|
+
import io
|
|
289
|
+
|
|
290
|
+
snakemake_args.append("--dag")
|
|
291
|
+
|
|
292
|
+
fileType = filename.suffix.lstrip(".")
|
|
293
|
+
|
|
294
|
+
# Create a file-like object to redirect the stdout
|
|
295
|
+
snakemake_output = io.StringIO()
|
|
296
|
+
# Use redirect_stdout to redirect stdout to the file-like object
|
|
297
|
+
with redirect_stdout(snakemake_output):
|
|
298
|
+
# Capture the output of snakemake.main(args) using a try-except block
|
|
299
|
+
try:
|
|
300
|
+
snakemake.parse_config = parse_config_monkeypatch
|
|
301
|
+
snakemake.main(snakemake_args)
|
|
302
|
+
except SystemExit: # Catch SystemExit exception to prevent termination
|
|
303
|
+
pass
|
|
304
|
+
try:
|
|
305
|
+
snakemake_output = snakemake_output.getvalue()
|
|
306
|
+
if "snakemake_dag" not in snakemake_output:
|
|
307
|
+
self.error("Could not generate dag!", exit=True)
|
|
308
|
+
# discard everything before digraph snakemake_dag
|
|
309
|
+
filtered_lines = (
|
|
310
|
+
"digraph snakemake_dag" + snakemake_output.split("snakemake_dag")[1]
|
|
311
|
+
)
|
|
312
|
+
echo_process = subprocess.Popen(
|
|
313
|
+
["echo", filtered_lines], stdout=subprocess.PIPE
|
|
314
|
+
)
|
|
315
|
+
dot_process = subprocess.Popen(
|
|
316
|
+
["dot", f"-T{fileType}"],
|
|
317
|
+
stdin=echo_process.stdout,
|
|
318
|
+
stdout=subprocess.PIPE,
|
|
319
|
+
)
|
|
320
|
+
with open(filename, "w") as output_file:
|
|
321
|
+
if self.verbose:
|
|
322
|
+
typer.secho(f"Saving dag to {filename}", fg=typer.colors.MAGENTA, err=True)
|
|
323
|
+
subprocess.run(["cat"], stdin=dot_process.stdout, stdout=output_file)
|
|
324
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
325
|
+
typer.echo("dot command not found!", fg=typer.colors.RED, err=True)
|
|
326
|
+
raise typer.Exit(1)
|
|
327
|
+
|
|
328
|
+
@contextmanager
|
|
329
|
+
def _copy_resources(
|
|
330
|
+
self, resources: List[Path], cleanup: bool, symlink_resources: bool = False
|
|
331
|
+
):
|
|
332
|
+
"""
|
|
333
|
+
Copy resources to the current working directory.
|
|
334
|
+
Args:
|
|
335
|
+
resources (List[Path]): A list of paths to the resources to copy.
|
|
336
|
+
cleanup (bool): If True, the resources will be removed after the function exits.
|
|
337
|
+
Side Effects:
|
|
338
|
+
Copies the resources to the current working directory.
|
|
339
|
+
Returns:
|
|
340
|
+
Generator: A generator object.
|
|
341
|
+
Examples:
|
|
342
|
+
>>> with CLI.copy_resources(resources, cleanup=True):
|
|
343
|
+
... # do something
|
|
344
|
+
"""
|
|
345
|
+
import os
|
|
346
|
+
import shutil
|
|
347
|
+
|
|
348
|
+
copied_resources = []
|
|
349
|
+
|
|
350
|
+
def copy_resource(src: Path, dst: Path, symlink: bool = False):
|
|
351
|
+
if self.verbose:
|
|
352
|
+
typer.secho(
|
|
353
|
+
f" - Copying resource '{src}' to '{dst}'",
|
|
354
|
+
fg=typer.colors.MAGENTA,
|
|
355
|
+
err=True,
|
|
356
|
+
)
|
|
357
|
+
target_is_directory = src.is_dir()
|
|
358
|
+
if symlink:
|
|
359
|
+
os.symlink(src, dst, target_is_directory=target_is_directory)
|
|
360
|
+
elif target_is_directory:
|
|
361
|
+
shutil.copytree(src, dst)
|
|
362
|
+
else:
|
|
363
|
+
shutil.copy(src, dst)
|
|
364
|
+
|
|
365
|
+
def remove_resource(resource: Path):
|
|
366
|
+
if resource.is_symlink():
|
|
367
|
+
resource.unlink()
|
|
368
|
+
elif resource.is_dir():
|
|
369
|
+
shutil.rmtree(resource)
|
|
370
|
+
else:
|
|
371
|
+
os.remove(resource)
|
|
372
|
+
|
|
373
|
+
resources_folder = self.workflow.path / "resources"
|
|
374
|
+
if resources_folder.exists():
|
|
375
|
+
resources.insert(0, Path("resources"))
|
|
376
|
+
if self.verbose and resources:
|
|
377
|
+
typer.secho(
|
|
378
|
+
f"Copying {len(resources)} resources to working directory...",
|
|
379
|
+
fg=typer.colors.MAGENTA,
|
|
380
|
+
err=True,
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
for resource in resources:
|
|
384
|
+
abs_path = self.workflow.path / resource
|
|
385
|
+
destination = Path(".") / resource.name
|
|
386
|
+
if not destination.exists():
|
|
387
|
+
# make sure you don't delete files that are already there...
|
|
388
|
+
copy_resource(abs_path, destination, symlink=symlink_resources)
|
|
389
|
+
copied_resources.append(destination)
|
|
390
|
+
elif self.verbose:
|
|
391
|
+
typer.secho(
|
|
392
|
+
f" - Resource '{resource.name}' already exists! Skipping...",
|
|
393
|
+
fg=typer.colors.MAGENTA,
|
|
394
|
+
err=True,
|
|
395
|
+
)
|
|
396
|
+
yield
|
|
397
|
+
finally:
|
|
398
|
+
if not cleanup:
|
|
399
|
+
return
|
|
400
|
+
for copied_resource in copied_resources:
|
|
401
|
+
if copied_resource.exists():
|
|
402
|
+
if self.verbose:
|
|
403
|
+
typer.secho(
|
|
404
|
+
f"Deleting '{copied_resource.name}' resource...",
|
|
405
|
+
fg=typer.colors.MAGENTA,
|
|
406
|
+
err=True,
|
|
407
|
+
)
|
|
408
|
+
remove_resource(copied_resource)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def parse_config_monkeypatch(args):
|
|
412
|
+
"""Monkeypatch the parse_config function from snakemake."""
|
|
413
|
+
import yaml
|
|
414
|
+
import snakemake
|
|
415
|
+
import re
|
|
416
|
+
|
|
417
|
+
class NoDatesSafeLoader(yaml.SafeLoader):
|
|
418
|
+
@classmethod
|
|
419
|
+
def remove_implicit_resolver(cls, tag_to_remove):
|
|
420
|
+
"""
|
|
421
|
+
Remove implicit resolvers for a particular tag
|
|
422
|
+
|
|
423
|
+
Takes care not to modify resolvers in super classes.
|
|
424
|
+
|
|
425
|
+
We want to load datetimes as strings, not dates, because we
|
|
426
|
+
go on to serialise as json which doesn't have the advanced types
|
|
427
|
+
of yaml, and leads to incompatibilities down the track.
|
|
428
|
+
"""
|
|
429
|
+
if "yaml_implicit_resolvers" not in cls.__dict__:
|
|
430
|
+
cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy()
|
|
431
|
+
|
|
432
|
+
for first_letter, mappings in cls.yaml_implicit_resolvers.items():
|
|
433
|
+
cls.yaml_implicit_resolvers[first_letter] = [
|
|
434
|
+
(tag, regexp) for tag, regexp in mappings if tag != tag_to_remove
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
NoDatesSafeLoader.remove_implicit_resolver("tag:yaml.org,2002:timestamp")
|
|
438
|
+
|
|
439
|
+
def _yaml_safe_load(s):
|
|
440
|
+
"""Load yaml string safely."""
|
|
441
|
+
s = s.replace(": None", ": null")
|
|
442
|
+
return yaml.load(s, Loader=NoDatesSafeLoader)
|
|
443
|
+
|
|
444
|
+
parsers = [int, float, snakemake._bool_parser, _yaml_safe_load, str]
|
|
445
|
+
config = dict()
|
|
446
|
+
if args.config is not None:
|
|
447
|
+
valid = re.compile(r"[a-zA-Z_]\w*$")
|
|
448
|
+
for entry in args.config:
|
|
449
|
+
key, val = snakemake.parse_key_value_arg(
|
|
450
|
+
entry,
|
|
451
|
+
errmsg="Invalid config definition: Config entries have to be defined as name=value pairs.",
|
|
452
|
+
)
|
|
453
|
+
if not valid.match(key):
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"Invalid config definition: Config entry must start with a valid identifier."
|
|
456
|
+
)
|
|
457
|
+
v = None
|
|
458
|
+
if val == "" or val == "None":
|
|
459
|
+
snakemake.update_config(config, {key: v})
|
|
460
|
+
continue
|
|
461
|
+
for parser in parsers:
|
|
462
|
+
try:
|
|
463
|
+
v = parser(val)
|
|
464
|
+
# avoid accidental interpretation as function
|
|
465
|
+
if not callable(v):
|
|
466
|
+
break
|
|
467
|
+
except:
|
|
468
|
+
pass
|
|
469
|
+
assert v is not None
|
|
470
|
+
snakemake.update_config(config, {key: v})
|
|
471
|
+
return config
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
from typing import List
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..dynamic_typer import DynamicTyper
|
|
9
|
+
from .utils import create_snakemake_workflow
|
|
10
|
+
from ..workflow import Workflow
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from snakemake.deployment.conda import Conda, Env
|
|
14
|
+
from snk_cli.config.config import get_config_from_workflow_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScriptApp(DynamicTyper):
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
workflow: Workflow,
|
|
21
|
+
conda_prefix_dir: Path,
|
|
22
|
+
snakemake_config,
|
|
23
|
+
snakefile: Path,
|
|
24
|
+
):
|
|
25
|
+
self.workflow = workflow
|
|
26
|
+
self.conda_prefix_dir = conda_prefix_dir
|
|
27
|
+
self.snakemake_config = snakemake_config
|
|
28
|
+
self.snakefile = snakefile
|
|
29
|
+
self.configfile = get_config_from_workflow_dir(self.workflow.path)
|
|
30
|
+
self.register_command(self.list, help="List the scripts in the workflow.")
|
|
31
|
+
self.register_command(
|
|
32
|
+
self.show, help="Show the contents of a script."
|
|
33
|
+
)
|
|
34
|
+
self.register_command(
|
|
35
|
+
self.run,
|
|
36
|
+
help=f"""
|
|
37
|
+
Run a script from the workflow.
|
|
38
|
+
\n\nThe executor for the script is inferred from the suffix.
|
|
39
|
+
\n\nExample: {self.workflow.name} script run --env=python script.py
|
|
40
|
+
\n\nTo pass help to the script, use `-- --help`.""",
|
|
41
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def list(
|
|
45
|
+
self,
|
|
46
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show script as paths."),
|
|
47
|
+
):
|
|
48
|
+
from rich.console import Console
|
|
49
|
+
from rich.table import Table
|
|
50
|
+
table = Table("Name", "CMD", "File", show_header=True, show_lines=True)
|
|
51
|
+
for script in self.workflow.scripts:
|
|
52
|
+
# address relative to cwd
|
|
53
|
+
table.add_row(script.stem, f"{self.workflow.name} script show {script.stem}", str(script.resolve()) if verbose else script.name)
|
|
54
|
+
console = Console()
|
|
55
|
+
console.print(table)
|
|
56
|
+
|
|
57
|
+
def _get_script_path(self, name: str) -> Path:
|
|
58
|
+
script = [e for e in self.workflow.scripts if e.name == name or e.stem == name]
|
|
59
|
+
if not script:
|
|
60
|
+
self.error(f"Script {name} not found!")
|
|
61
|
+
return script[0]
|
|
62
|
+
|
|
63
|
+
def _get_conda_env_path(self, name: str) -> Path:
|
|
64
|
+
env = [e for e in self.workflow.environments if e.stem == name]
|
|
65
|
+
if not env:
|
|
66
|
+
self.error(f"Environment {name} not found!")
|
|
67
|
+
return env[0]
|
|
68
|
+
|
|
69
|
+
def _shellcmd(self, env_address: str, cmd: str) -> str:
|
|
70
|
+
if sys.platform.lower().startswith("win"):
|
|
71
|
+
return Conda().shellcmd_win(env_address, cmd)
|
|
72
|
+
return Conda().shellcmd(env_address, cmd)
|
|
73
|
+
|
|
74
|
+
def show(
|
|
75
|
+
self,
|
|
76
|
+
name: str = typer.Argument(..., help="The name of the script."),
|
|
77
|
+
pretty: bool = typer.Option(
|
|
78
|
+
False, "--pretty", "-p", help="Pretty print the script."
|
|
79
|
+
),
|
|
80
|
+
):
|
|
81
|
+
script_path = self._get_script_path(name)
|
|
82
|
+
code = script_path.read_text()
|
|
83
|
+
if pretty:
|
|
84
|
+
code = Syntax(code, script_path.suffix[1:])
|
|
85
|
+
console = Console()
|
|
86
|
+
console.print(code)
|
|
87
|
+
else:
|
|
88
|
+
typer.echo(code)
|
|
89
|
+
|
|
90
|
+
def _get_executor(self, suffix: str) -> str:
|
|
91
|
+
if suffix == "py":
|
|
92
|
+
return "python"
|
|
93
|
+
elif suffix == "R":
|
|
94
|
+
return "Rscript"
|
|
95
|
+
elif suffix == "sh":
|
|
96
|
+
return "bash"
|
|
97
|
+
elif suffix == "pl":
|
|
98
|
+
return "perl"
|
|
99
|
+
elif suffix == "Rmd" or suffix == "rmd" or suffix == "Rhtml":
|
|
100
|
+
return "Rscript -e 'rmarkdown::render(\"{script_path}\")'"
|
|
101
|
+
elif suffix == "ipynb":
|
|
102
|
+
return "jupyter nbconvert --to notebook --execute --inplace --ExecutePreprocessor.timeout=-1 {script_path}"
|
|
103
|
+
elif suffix == "Rnw":
|
|
104
|
+
return "Rscript -e 'knitr::knit(\"{script_path}\")'"
|
|
105
|
+
else:
|
|
106
|
+
self.error(f"Unknown script suffix: {suffix}!")
|
|
107
|
+
|
|
108
|
+
def run(
|
|
109
|
+
self,
|
|
110
|
+
env: str = typer.Option(None, help="The name of the environment to run script in."),
|
|
111
|
+
name: str = typer.Argument(..., help="The name of the script."),
|
|
112
|
+
args: List[str] = typer.Argument(None, help="Arguments to pass to the script."),
|
|
113
|
+
):
|
|
114
|
+
script_path = self._get_script_path(name)
|
|
115
|
+
executor = self._get_executor(script_path.suffix[1:])
|
|
116
|
+
cmd = [executor, f'"{script_path}"'] + args
|
|
117
|
+
if env:
|
|
118
|
+
env_path = self._get_conda_env_path(env)
|
|
119
|
+
workflow = create_snakemake_workflow(
|
|
120
|
+
self.snakefile,
|
|
121
|
+
config=self.snakemake_config,
|
|
122
|
+
configfiles=[self.configfile] if self.configfile else None,
|
|
123
|
+
use_conda=True,
|
|
124
|
+
conda_prefix=self.conda_prefix_dir.resolve(),
|
|
125
|
+
)
|
|
126
|
+
env = Env(workflow, env_file=env_path.resolve())
|
|
127
|
+
env.create()
|
|
128
|
+
cmd = self._shellcmd(env.address, " ".join(cmd))
|
|
129
|
+
else:
|
|
130
|
+
cmd = " ".join(cmd)
|
|
131
|
+
user_shell = os.environ.get("SHELL", "/bin/bash")
|
|
132
|
+
subprocess.run(cmd, shell=True, env=os.environ.copy(), executable=user_shell)
|
|
133
|
+
|
|
134
|
+
|