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 ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2024-present Wytamma Wirth <wytamma.wirth@me.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.0.1"
snk_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2024-present Wytamma Wirth <wytamma.wirth@me.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ from .cli import CLI
5
+ from .validate import validate_config
snk_cli/cli.py ADDED
@@ -0,0 +1,231 @@
1
+ import inspect
2
+ import platform
3
+
4
+ import typer
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import os
8
+
9
+
10
+ from snakemake import SNAKEFILE_CHOICES
11
+ from art import text2art
12
+
13
+ from snk_cli.dynamic_typer import DynamicTyper
14
+ from snk_cli.subcommands import EnvApp, ConfigApp, RunApp, ScriptApp, ProfileApp
15
+
16
+ from snk_cli.config.config import (
17
+ SnkConfig,
18
+ load_workflow_snakemake_config,
19
+ )
20
+ from snk_cli.options.utils import build_dynamic_cli_options
21
+ from snk_cli.workflow import Workflow
22
+
23
+
24
+ class CLI(DynamicTyper):
25
+ """
26
+ Constructor for the dynamic Snk CLI class.
27
+ Args:
28
+ workflow_dir_path (Path): Path to the workflow directory.
29
+ Side Effects:
30
+ Initializes the CLI class.
31
+ Examples:
32
+ >>> CLI(Path('/path/to/workflow'))
33
+ """
34
+
35
+ def __init__(self, workflow_dir_path: Path = None, *, pipeline_dir_path: Path = None, snk_config: SnkConfig = None) -> None:
36
+ if pipeline_dir_path is not None:
37
+ # raise a deprecation warning
38
+ import warnings
39
+ warnings.warn(
40
+ "The `pipeline_dir_path` argument is deprecated and will be removed in a future release. Use `workflow_dir_path` instead.",
41
+ DeprecationWarning,
42
+ )
43
+ workflow_dir_path = pipeline_dir_path
44
+ if workflow_dir_path is None:
45
+ # get the calling frame (the frame of the function that called this function)
46
+ calling_frame = inspect.currentframe().f_back
47
+ # get the file path from the calling frame
48
+ workflow_dir_path = Path(calling_frame.f_globals["__file__"])
49
+ else:
50
+ workflow_dir_path = Path(workflow_dir_path)
51
+ if workflow_dir_path.is_file():
52
+ workflow_dir_path = workflow_dir_path.parent
53
+ self.workflow = Workflow(path=workflow_dir_path)
54
+ self.snakemake_config = load_workflow_snakemake_config(workflow_dir_path)
55
+ if snk_config is None:
56
+ self.snk_config = SnkConfig.from_workflow_dir(
57
+ workflow_dir_path, create_if_not_exists=True
58
+ )
59
+ else:
60
+ self.snk_config = snk_config
61
+ if self.snk_config.version:
62
+ self.version = self.snk_config.version
63
+ else:
64
+ self.version = self.workflow.version
65
+ self.options = build_dynamic_cli_options(self.snakemake_config, self.snk_config)
66
+ self.snakefile = self._find_snakefile()
67
+ self.conda_prefix_dir = self.workflow.conda_prefix_dir
68
+ self.singularity_prefix_dir = self.workflow.singularity_prefix_dir
69
+ self.name = self.workflow.name
70
+ self.verbose = False
71
+ if (
72
+ platform.system() == "Darwin"
73
+ and platform.processor() == "arm"
74
+ and not os.environ.get("CONDA_SUBDIR")
75
+ ):
76
+ os.environ["CONDA_SUBDIR"] = "osx-64"
77
+
78
+ # dynamically create the logo
79
+ self.logo = self._create_logo(
80
+ tagline=self.snk_config.tagline, font=self.snk_config.font
81
+ )
82
+ callback = self._create_callback()
83
+ callback.__doc__ = self.logo
84
+
85
+ # registration
86
+ self.register_callback(
87
+ callback,
88
+ invoke_without_command=True,
89
+ context_settings={"help_option_names": ["-h", "--help"]},
90
+ )
91
+ self.register_command(self.info, help="Show information about the workflow.")
92
+
93
+ run_app = RunApp(
94
+ conda_prefix_dir=self.conda_prefix_dir,
95
+ snk_config=self.snk_config,
96
+ singularity_prefix_dir=self.singularity_prefix_dir,
97
+ snakefile=self.snakefile,
98
+ workflow=self.workflow,
99
+ verbose=self.verbose,
100
+ logo=self.logo,
101
+ dynamic_run_options=self.options,
102
+ )
103
+ # Subcommands
104
+ self.register_command(
105
+ run_app,
106
+ name="run",
107
+ )
108
+ self.register_command(
109
+ ConfigApp(
110
+ workflow=self.workflow,
111
+ options=self.options,
112
+ ),
113
+ name="config",
114
+ )
115
+ if self.workflow.environments:
116
+ self.register_group(
117
+ EnvApp(
118
+ workflow=self.workflow,
119
+ conda_prefix_dir=self.conda_prefix_dir,
120
+ snakemake_config=self.snakemake_config,
121
+ snakefile=self.snakefile,
122
+ ),
123
+ name="env",
124
+ help="Access the workflow conda environments.",
125
+ )
126
+ if self.workflow.scripts:
127
+ self.register_group(
128
+ ScriptApp(
129
+ workflow=self.workflow,
130
+ conda_prefix_dir=self.conda_prefix_dir,
131
+ snakemake_config=self.snakemake_config,
132
+ snakefile=self.snakefile,
133
+ ),
134
+ name="script",
135
+ help="Access the workflow scripts.",
136
+ )
137
+ if self.workflow.profiles:
138
+ self.register_group(
139
+ ProfileApp(
140
+ workflow=self.workflow,
141
+ ),
142
+ name="profile",
143
+ help="Access the workflow profiles.",
144
+ )
145
+
146
+ def _print_pipline_version(self, ctx: typer.Context, value: bool):
147
+ if value:
148
+ typer.echo(self.version)
149
+ raise typer.Exit()
150
+
151
+ def _print_pipline_path(self, ctx: typer.Context, value: bool):
152
+ if value:
153
+ typer.echo(self.workflow.path)
154
+ raise typer.Exit()
155
+
156
+ def _create_callback(self):
157
+ def callback(
158
+ ctx: typer.Context,
159
+ version: Optional[bool] = typer.Option(
160
+ None,
161
+ "-v",
162
+ "--version",
163
+ help="Show the workflow version and exit.",
164
+ is_eager=True,
165
+ callback=self._print_pipline_version,
166
+ show_default=False,
167
+ ),
168
+ path: Optional[bool] = typer.Option(
169
+ None,
170
+ "-p",
171
+ "--path",
172
+ help="Show the workflow path and exit.",
173
+ is_eager=True,
174
+ callback=self._print_pipline_path,
175
+ show_default=False,
176
+ ),
177
+ ):
178
+ if ctx.invoked_subcommand is None:
179
+ typer.echo(f"{ctx.get_help()}")
180
+
181
+ return callback
182
+
183
+ def _create_logo(
184
+ self, tagline="A Snakemake workflow CLI generated with snk", font="small"
185
+ ):
186
+ """
187
+ Create a logo for the CLI.
188
+ Args:
189
+ font (str): The font to use for the logo.
190
+ Returns:
191
+ str: The logo.
192
+ Examples:
193
+ >>> CLI._create_logo()
194
+ """
195
+ if self.snk_config.art:
196
+ art = self.snk_config.art
197
+ else:
198
+ logo = self.snk_config.logo if self.snk_config.logo else self.name
199
+ art = text2art(logo, font=font)
200
+ doc = f"""\b{art}\b{tagline}"""
201
+ return doc
202
+
203
+ def _find_snakefile(self):
204
+ """
205
+ Search possible snakefile locations.
206
+ Returns:
207
+ Path: The path to the snakefile.
208
+ Examples:
209
+ >>> CLI._find_snakefile()
210
+ """
211
+ for path in SNAKEFILE_CHOICES:
212
+ if (self.workflow.path / path).exists():
213
+ return self.workflow.path / path
214
+ raise FileNotFoundError("Snakefile not found!")
215
+
216
+ def info(self):
217
+ """
218
+ Display information about current workflow install.
219
+ Returns:
220
+ str: A JSON string containing information about the current workflow install.
221
+ Examples:
222
+ >>> CLI.info()
223
+ """
224
+ import json
225
+
226
+ info_dict = {}
227
+ info_dict["name"] = self.workflow.path.name
228
+ info_dict["version"] = self.version
229
+ info_dict["workflow_dir_path"] = str(self.workflow.path)
230
+ typer.echo(json.dumps(info_dict, indent=2))
231
+
@@ -0,0 +1 @@
1
+ from .config import SnkConfig
@@ -0,0 +1,219 @@
1
+ from pathlib import Path
2
+ from typing import List, Optional
3
+ import snakemake
4
+ from dataclasses import dataclass, field
5
+ from .utils import get_version_from_config
6
+ import yaml
7
+
8
+
9
+ class SnkConfigError(Exception):
10
+ """Base class for all SNK config exceptions"""
11
+
12
+ class InvalidSnkConfigError(SnkConfigError, ValueError):
13
+ """Thrown if the given SNK config appears to have an invalid format."""
14
+
15
+ class MissingSnkConfigError(SnkConfigError, FileNotFoundError):
16
+ """Thrown if the given SNK config file cannot be found."""
17
+
18
+ @dataclass
19
+ class SnkConfig:
20
+ """
21
+ A dataclass for storing Snakemake workflow configuration.
22
+ """
23
+
24
+ art: str = None
25
+ logo: str = None
26
+ tagline: str = "A Snakemake workflow CLI generated with Snk"
27
+ font: str = "small"
28
+ version: Optional[str] = None
29
+ conda: bool = True
30
+ resources: List[Path] = field(default_factory=list)
31
+ skip_missing: bool = False # skip any missing cli options (i.e. those not in the snk file)
32
+ additional_snakemake_args: List[str] = field(default_factory=list)
33
+ cli: dict = field(default_factory=dict)
34
+ symlink_resources: bool = False
35
+ _snk_config_path: Path = None
36
+
37
+ @classmethod
38
+ def from_path(cls, snk_config_path: Path):
39
+ """
40
+ Load and validate Snk config from file.
41
+ Args:
42
+ snk_config_path (Path): Path to the SNK config file.
43
+ Returns:
44
+ SnkConfig: A SnkConfig object.
45
+ Raises:
46
+ FileNotFoundError: If the SNK config file is not found.
47
+ Examples:
48
+ >>> SnkConfig.from_path(Path("snk.yaml"))
49
+ SnkConfig(art=None, logo=None, tagline='A Snakemake workflow CLI generated with Snk', font='small', resources=[], annotations={}, symlink_resources=False, _snk_config_path=PosixPath('snk.yaml'))
50
+ """
51
+ if not snk_config_path.exists():
52
+ raise MissingSnkConfigError(
53
+ f"Could not find SNK config file: {snk_config_path}"
54
+ ) from FileNotFoundError
55
+ # raise error if file is empty
56
+ if snk_config_path.stat().st_size == 0:
57
+ raise InvalidSnkConfigError(f"SNK config file is empty: {snk_config_path}") from ValueError
58
+
59
+ snk_config_dict = snakemake.load_configfile(snk_config_path)
60
+ snk_config_dict["version"] = get_version_from_config(snk_config_path, snk_config_dict)
61
+ if "annotations" in snk_config_dict:
62
+ # TODO: remove annotations in the future
63
+ snk_config_dict["cli"] = snk_config_dict["annotations"]
64
+ del snk_config_dict["annotations"]
65
+ if "conda_required" in snk_config_dict:
66
+ # TODO: remove conda_required in the future
67
+ snk_config_dict["conda"] = snk_config_dict["conda_required"]
68
+ del snk_config_dict["conda_required"]
69
+ snk_config = cls(**snk_config_dict)
70
+ snk_config.resources = [
71
+ snk_config_path.parent / resource for resource in snk_config.resources
72
+ ]
73
+ snk_config.validate_resources(snk_config.resources)
74
+ snk_config._snk_config_path = snk_config_path
75
+ return snk_config
76
+
77
+ @classmethod
78
+ def from_workflow_dir(
79
+ cls, workflow_dir_path: Path, create_if_not_exists: bool = False
80
+ ):
81
+ """
82
+ Load and validate SNK config from workflow directory.
83
+ Args:
84
+ workflow_dir_path (Path): Path to the workflow directory.
85
+ create_if_not_exists (bool): Whether to create a SNK config file if one does not exist.
86
+ Returns:
87
+ SnkConfig: A SnkConfig object.
88
+ Raises:
89
+ FileNotFoundError: If the SNK config file is not found.
90
+ Examples:
91
+ >>> SnkConfig.from_workflow_dir(Path("workflow"))
92
+ SnkConfig(art=None, logo=None, tagline='A Snakemake workflow CLI generated with Snk', font='small', resources=[], annotations={}, symlink_resources=False, _snk_config_path=PosixPath('workflow/snk.yaml'))
93
+ """
94
+ if (workflow_dir_path / "snk.yaml").exists():
95
+ return cls.from_path(workflow_dir_path / "snk.yaml")
96
+ elif (workflow_dir_path / ".snk").exists():
97
+ import warnings
98
+
99
+ warnings.warn(
100
+ "Use of .snk will be deprecated in the future. Please use snk.yaml instead.",
101
+ DeprecationWarning,
102
+ )
103
+ return cls.from_path(workflow_dir_path / ".snk")
104
+ elif create_if_not_exists:
105
+ snk_config = cls(_snk_config_path=workflow_dir_path / "snk.yaml")
106
+ return snk_config
107
+ else:
108
+ raise FileNotFoundError(
109
+ f"Could not find SNK config file in workflow directory: {workflow_dir_path}"
110
+ )
111
+
112
+ def validate_resources(self, resources):
113
+ """
114
+ Validate resources.
115
+ Args:
116
+ resources (List[Path]): List of resources to validate.
117
+ Raises:
118
+ FileNotFoundError: If a resource is not found.
119
+ Notes:
120
+ This function does not modify the resources list.
121
+ Examples:
122
+ >>> SnkConfig.validate_resources([Path("resource1.txt"), Path("resource2.txt")])
123
+ """
124
+ for resource in resources:
125
+ if not resource.exists():
126
+ raise FileNotFoundError(f"Could not find resource: {resource}")
127
+
128
+ def add_resources(self, resources: List[Path], workflow_dir_path: Path = None):
129
+ """
130
+ Add resources to the SNK config.
131
+ Args:
132
+ resources (List[Path]): List of resources to add.
133
+ workflow_dir_path (Path): Path to the workflow directory.
134
+ Returns:
135
+ None
136
+ Side Effects:
137
+ Adds the resources to the SNK config.
138
+ Examples:
139
+ >>> snk_config = SnkConfig()
140
+ >>> snk_config.add_resources([Path("resource1.txt"), Path("resource2.txt")], Path("workflow"))
141
+ """
142
+ processed = []
143
+ for resource in resources:
144
+ if workflow_dir_path and not resource.is_absolute():
145
+ resource = workflow_dir_path / resource
146
+ processed.append(resource)
147
+ self.validate_resources(processed)
148
+ self.resources.extend(processed)
149
+
150
+ def to_yaml(self, path: Path) -> None:
151
+ """
152
+ Write SNK config to YAML file.
153
+ Args:
154
+ path (Path): Path to write the YAML file to.
155
+ Returns:
156
+ None
157
+ Side Effects:
158
+ Writes the SNK config to the specified path.
159
+ Examples:
160
+ >>> snk_config = SnkConfig()
161
+ >>> snk_config.to_yaml(Path("snk.yaml"))
162
+ """
163
+ config_dict = {k: v for k, v in vars(self).items() if not k.startswith("_")}
164
+ with open(path, "w") as f:
165
+ yaml.dump(config_dict, f)
166
+
167
+ def save(self) -> None:
168
+ """
169
+ Save SNK config.
170
+ Args:
171
+ path (Path): Path to write the YAML file to.
172
+ Returns:
173
+ None
174
+ Side Effects:
175
+ Writes the SNK config to the path specified by _snk_config_path.
176
+ Examples:
177
+ >>> snk_config = SnkConfig()
178
+ >>> snk_config.save()
179
+ """
180
+ self.to_yaml(self._snk_config_path)
181
+
182
+
183
+ def get_config_from_workflow_dir(workflow_dir_path: Path):
184
+ """
185
+ Get the config file from a workflow directory.
186
+ Args:
187
+ workflow_dir_path (Path): Path to the workflow directory.
188
+ Returns:
189
+ Path: Path to the config file, or None if not found.
190
+ Examples:
191
+ >>> get_config_from_workflow_dir(Path("workflow"))
192
+ PosixPath('workflow/config.yaml')
193
+ """
194
+ for path in [
195
+ Path("config") / "config.yaml",
196
+ Path("config") / "config.yml",
197
+ "config.yaml",
198
+ "config.yml",
199
+ ]:
200
+ if (workflow_dir_path / path).exists():
201
+ return workflow_dir_path / path
202
+ return None
203
+
204
+
205
+ def load_workflow_snakemake_config(workflow_dir_path: Path):
206
+ """
207
+ Load the Snakemake config from a workflow directory.
208
+ Args:
209
+ workflow_dir_path (Path): Path to the workflow directory.
210
+ Returns:
211
+ dict: The Snakemake config.
212
+ Examples:
213
+ >>> load_workflow_snakemake_config(Path("workflow"))
214
+ {'inputs': {'data': 'data.txt'}, 'outputs': {'results': 'results.txt'}}
215
+ """
216
+ workflow_config_path = get_config_from_workflow_dir(workflow_dir_path)
217
+ if not workflow_config_path or not workflow_config_path.exists():
218
+ return {}
219
+ return snakemake.load_configfile(workflow_config_path)
@@ -0,0 +1,41 @@
1
+ from pathlib import Path
2
+ from snakemake import load_configfile
3
+
4
+ def get_version_from_config(config_path: Path, config_dict: dict = None) -> str:
5
+ """
6
+ Get the version from config. If dict not provided, load from file.
7
+ If the version is a path to a __about__ file, load the version from the file.
8
+ Path must be relative to the config file.
9
+ Args:
10
+ config_path (Path): Path to the config file.
11
+ config_dict (dict): Config dict.
12
+ Returns:
13
+ str: Version.
14
+ Examples:
15
+ >>> get_version_from_config_dict({"version": "0.1.0"})
16
+ '0.1.0'
17
+ """
18
+ if not config_dict:
19
+ config_dict = load_configfile(config_path)
20
+
21
+ if "version" not in config_dict:
22
+ return None
23
+ if config_dict["version"] is None:
24
+ return None
25
+ version = str(config_dict["version"])
26
+ if "__about__.py" in version:
27
+ # load version from __about__.py
28
+ about_path = config_path.parent / version
29
+ if not about_path.exists():
30
+ raise FileNotFoundError(
31
+ f"Could not find version file: {about_path}"
32
+ )
33
+ about = {}
34
+ exec(about_path.read_text(), about)
35
+ try:
36
+ version = about["__version__"]
37
+ except KeyError as e:
38
+ raise KeyError(
39
+ f"Could not find __version__ in file: {about_path}"
40
+ ) from e
41
+ return version