dso-core 0.8.2.post2__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.
Files changed (53) hide show
  1. dso/__init__.py +62 -0
  2. dso/_logging.py +15 -0
  3. dso/_metadata.py +3 -0
  4. dso/_util.py +226 -0
  5. dso/assets/__init__.py +0 -0
  6. dso/assets/open_sans.ttf +0 -0
  7. dso/compile_config.py +181 -0
  8. dso/create.py +108 -0
  9. dso/exec.py +146 -0
  10. dso/get_config.py +139 -0
  11. dso/hiyapyco.py +564 -0
  12. dso/init.py +45 -0
  13. dso/lint.py +279 -0
  14. dso/pandocfilter.py +98 -0
  15. dso/repro.py +26 -0
  16. dso/templates/__init__.py +0 -0
  17. dso/templates/folder/__init__.py +4 -0
  18. dso/templates/folder/default/dvc.yaml +0 -0
  19. dso/templates/folder/default/params.in.yaml +0 -0
  20. dso/templates/init/__init__.py +1 -0
  21. dso/templates/init/default/.dvc/.gitignore +3 -0
  22. dso/templates/init/default/.dvc/config +2 -0
  23. dso/templates/init/default/.dvcignore +0 -0
  24. dso/templates/init/default/.editorconfig +15 -0
  25. dso/templates/init/default/.gitattributes +2 -0
  26. dso/templates/init/default/.gitignore +41 -0
  27. dso/templates/init/default/.pre-commit-config.yaml +70 -0
  28. dso/templates/init/default/.ruff.toml +43 -0
  29. dso/templates/init/default/README.md +3 -0
  30. dso/templates/init/default/dvc.yaml +0 -0
  31. dso/templates/init/default/params.in.yaml +21 -0
  32. dso/templates/stage/__init__.py +1 -0
  33. dso/templates/stage/bash/.gitignore +4 -0
  34. dso/templates/stage/bash/README.md +3 -0
  35. dso/templates/stage/bash/dvc.yaml +13 -0
  36. dso/templates/stage/bash/input/.gitignore +0 -0
  37. dso/templates/stage/bash/output/.gitignore +0 -0
  38. dso/templates/stage/bash/params.in.yaml +0 -0
  39. dso/templates/stage/quarto/.gitignore +4 -0
  40. dso/templates/stage/quarto/README.md +3 -0
  41. dso/templates/stage/quarto/dvc.yaml +11 -0
  42. dso/templates/stage/quarto/input/.gitignore +0 -0
  43. dso/templates/stage/quarto/output/.gitignore +0 -0
  44. dso/templates/stage/quarto/params.in.yaml +0 -0
  45. dso/templates/stage/quarto/report/.gitignore +0 -0
  46. dso/templates/stage/quarto/src/.gitignore +2 -0
  47. dso/templates/stage/quarto/src/{{ stage_name }}.qmd +14 -0
  48. dso/watermark.py +192 -0
  49. dso_core-0.8.2.post2.dist-info/METADATA +863 -0
  50. dso_core-0.8.2.post2.dist-info/RECORD +53 -0
  51. dso_core-0.8.2.post2.dist-info/WHEEL +4 -0
  52. dso_core-0.8.2.post2.dist-info/entry_points.txt +3 -0
  53. dso_core-0.8.2.post2.dist-info/licenses/LICENSE +674 -0
dso/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ import logging
2
+ import os
3
+
4
+ import rich_click as click
5
+
6
+ from ._logging import log
7
+ from ._metadata import __version__
8
+ from .compile_config import cli as compile_config_cli
9
+ from .create import cli as create_cli
10
+ from .exec import cli as exec_cli
11
+ from .get_config import cli as get_config_cli
12
+ from .init import cli as init_cli
13
+ from .lint import cli as lint_cli
14
+ from .repro import cli as repro_cli
15
+ from .watermark import cli as watermark_cli
16
+
17
+ click.rich_click.USE_MARKDOWN = True
18
+
19
+
20
+ @click.group()
21
+ @click.option(
22
+ "-q",
23
+ "--quiet",
24
+ count=True,
25
+ help=(
26
+ "Reduce verbosity. `-q` disables info messages, `-qq` disables warnings. Errors messages cannot be disabled. "
27
+ "The same can be achieved by setting the env var `DSO_QUIET=1` or `DSO_QUIET=2`, respectively."
28
+ ),
29
+ default=int(os.environ.get("DSO_QUIET", 0)),
30
+ )
31
+ @click.option(
32
+ "-v",
33
+ "--verbose",
34
+ help=(
35
+ "Increase logging verbosity to include debug messages. "
36
+ "The same can be achieved by setting the env var `DSO_VERBOSE=1`."
37
+ ),
38
+ default=bool(int(os.environ.get("DSO_VERBOSE", 0))),
39
+ is_flag=True,
40
+ )
41
+ @click.version_option(version=__version__, prog_name="dso")
42
+ def cli(quiet: int, verbose: bool):
43
+ """Root command"""
44
+ if quiet >= 2:
45
+ log.setLevel(logging.ERROR)
46
+ os.environ["DSO_QUIET"] = "2"
47
+ elif quiet == 1:
48
+ log.setLevel(logging.WARNING)
49
+ os.environ["DSO_QUIET"] = "1"
50
+ elif verbose:
51
+ log.setLevel(logging.DEBUG)
52
+ os.environ["DSO_VERBOSE"] = "1"
53
+
54
+
55
+ cli.add_command(create_cli)
56
+ cli.add_command(init_cli)
57
+ cli.add_command(compile_config_cli)
58
+ cli.add_command(repro_cli)
59
+ cli.add_command(exec_cli)
60
+ cli.add_command(lint_cli)
61
+ cli.add_command(get_config_cli)
62
+ cli.add_command(watermark_cli)
dso/_logging.py ADDED
@@ -0,0 +1,15 @@
1
+ from logging import basicConfig, getLogger
2
+
3
+ from rich.console import Console
4
+ from rich.logging import RichHandler
5
+ from rich.traceback import install
6
+
7
+ console_stderr = Console(stderr=True)
8
+ console = Console(stderr=False)
9
+ log = getLogger("dso")
10
+ basicConfig(
11
+ level="INFO",
12
+ format="%(message)s",
13
+ handlers=[RichHandler(markup=True, console=console_stderr, show_path=False, show_time=True)],
14
+ )
15
+ install(show_locals=True)
dso/_metadata.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("dso-core")
dso/_util.py ADDED
@@ -0,0 +1,226 @@
1
+ import importlib
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from functools import cache
7
+ from importlib import resources
8
+ from importlib.abc import Traversable
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from git.repo import Repo
13
+ from jinja2 import StrictUndefined, Template
14
+ from rich.prompt import Confirm
15
+
16
+ from dso._logging import console, log
17
+
18
+ DEFAULT_BRANCH = "master"
19
+
20
+
21
+ def check_project_roots(paths: Sequence[Path]) -> Path:
22
+ """Check project roots for multiple paths and raise an error if they are ambiguous"""
23
+ try:
24
+ tmp_project_roots = {get_project_root(p) for p in paths}
25
+ except FileNotFoundError:
26
+ log.error("Not in a dso project (no .git directory found)")
27
+ sys.exit(1)
28
+ if len(tmp_project_roots) != 1:
29
+ log.error("Specified paths point to an ambiguous project root.")
30
+ sys.exit(1)
31
+ return tmp_project_roots.pop()
32
+
33
+
34
+ def _find_in_parent(start_directory: Path, file_or_folder: str, recurse_barrier: Path | None = None) -> Path | None:
35
+ """
36
+ Recursively walk up to the folder directory until we either find `file_or_folder` or reach the root.
37
+
38
+ If recurse_barrier is specified, we don't recurse past this level.
39
+
40
+ By using @cache this is efficient, even when called repeatedly.
41
+
42
+ If the root is reached without finding the file, None is returned.
43
+ """
44
+ return _find_in_parent_abs(
45
+ start_directory.absolute(), file_or_folder, recurse_barrier.absolute() if recurse_barrier is not None else None
46
+ )
47
+
48
+
49
+ @cache
50
+ def _find_in_parent_abs(start_directory: Path, file_or_folder: str, recurse_barrier: Path | None = None) -> Path | None:
51
+ """
52
+ Implementation of `_find_in_parent`, work only with absolute paths here.
53
+
54
+ This is to ensure @cache doesn't lead to wrong results when calling this from different working directories.
55
+ """
56
+ if start_directory == Path("/"):
57
+ return None
58
+ if recurse_barrier is not None:
59
+ if not start_directory.is_relative_to(recurse_barrier):
60
+ return None
61
+ if start_directory.is_file():
62
+ return _find_in_parent_abs(start_directory.parent, file_or_folder, recurse_barrier)
63
+ if (start_directory / file_or_folder).exists():
64
+ return start_directory / file_or_folder
65
+ else:
66
+ return _find_in_parent_abs(start_directory.parent, file_or_folder, recurse_barrier)
67
+
68
+
69
+ def get_project_root(start_directory: Path) -> Path:
70
+ """
71
+ Find the dso project root.
72
+
73
+ This is defined as the next parent directory that contains a `.git` directory.
74
+
75
+ Parameters
76
+ ----------
77
+ start_directory : Path
78
+ The directory to start the search from.
79
+
80
+ Returns
81
+ -------
82
+ The project root
83
+
84
+ Raises
85
+ ------
86
+ FileNotFoundError
87
+ If the .git folder is not found.
88
+ """
89
+ proj_root = _find_in_parent(start_directory, ".git")
90
+ if proj_root is None:
91
+ raise FileNotFoundError("Not within a dso project (No .git directory found)")
92
+ else:
93
+ # .parent, because proj_root points to the git directory
94
+ return proj_root.parent
95
+
96
+
97
+ def _get_template_path(template_type: Literal["init", "folder", "stage"], template_name: str) -> Traversable:
98
+ template_module = importlib.import_module(f"dso.templates.{template_type}")
99
+ return resources.files(template_module) / template_name
100
+
101
+
102
+ def _copy_with_render(source: Traversable, destination: Path, params: dict):
103
+ """Fill all placeholders in a file with jinja2 and save file to destination"""
104
+ with source.open() as f:
105
+ template = Template(f.read(), undefined=StrictUndefined)
106
+ rendered_content = template.render(params)
107
+ with destination.open("w") as file:
108
+ file.write(rendered_content)
109
+ # Non-empty files should have a terminal new-line to make the pre-commit hooks happy
110
+ if len(rendered_content):
111
+ file.write("\n")
112
+
113
+
114
+ def _instantiate_template(template_path: Traversable, target_dir: Path | str, **params) -> None:
115
+ """Copy a template folder to a target directory, filling all placeholder values."""
116
+ target_dir = Path(target_dir)
117
+
118
+ def _traverse_template(curr_path, subdir):
119
+ for p in curr_path.iterdir():
120
+ if p.is_file():
121
+ name_rendered = Template(p.name).render(params)
122
+ target_file = target_dir / subdir / name_rendered
123
+ if not target_file.exists():
124
+ _copy_with_render(p, target_file, params)
125
+ else:
126
+ (target_dir / subdir / p.name).mkdir(exist_ok=True)
127
+ _traverse_template(p, subdir / p.name)
128
+
129
+ _traverse_template(template_path, Path("."))
130
+
131
+
132
+ def _instantiate_with_repo(template: Traversable, target_dir: Path | str, **params) -> None:
133
+ """Create a git repo in a directory and render a template inside.
134
+
135
+ Creates an initial commit.
136
+ """
137
+ target_dir = Path(target_dir)
138
+ target_dir.mkdir(exist_ok=True)
139
+ log.info("Created project directory.")
140
+
141
+ _instantiate_template(template, target_dir, **params)
142
+ log.info("Created folder structure from template.")
143
+
144
+ if not (target_dir / ".git").exists():
145
+ Repo.init(target_dir)
146
+ repo = Repo(target_dir)
147
+ # set main as default branch
148
+ repo.git.checkout("-b", DEFAULT_BRANCH)
149
+ repo.git.symbolic_ref("HEAD", f"refs/heads/{DEFAULT_BRANCH}")
150
+ repo.git.add(A=True)
151
+ repo.index.commit("Initial commit generated by dso CLI tool.")
152
+ log.info("Initalized local git repo.")
153
+
154
+
155
+ def _git_list_files(dir: Path) -> list[Path]:
156
+ """
157
+ Recursively list all files in `dir` that are not .gitignored.
158
+
159
+ This lists both files that are tracked and untracked by git.
160
+ Source: https://stackoverflow.com/a/77197460/2340703
161
+ """
162
+ res = subprocess.run(
163
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"], cwd=dir, capture_output=True
164
+ )
165
+ if res.returncode:
166
+ sys.exit(res.returncode)
167
+ return [dir / Path(p) for p in res.stdout.decode("utf-8").strip().split("\n")]
168
+
169
+
170
+ def _read_dot_dso_json(dir: Path):
171
+ """
172
+ Read .dso.json from the project directory
173
+
174
+ The `.dso.json` file is file that can store project-specific settings and data generated by the dso CLI.
175
+ It is not intended to be edited by the user.
176
+
177
+ If the file doesn not exist it just means that is has not been generated yet. We return an empty dict in such a case.
178
+ """
179
+ project_root = get_project_root(dir)
180
+ dot_dso_json = project_root / ".dso.json"
181
+ if (dot_dso_json).exists():
182
+ with dot_dso_json.open("rb") as f:
183
+ return json.load(f)
184
+ else:
185
+ return {}
186
+
187
+
188
+ def _update_dot_dso_json(dir: Path, update_dict: dict):
189
+ """
190
+ Update the .dso.json with `update_dict`.
191
+
192
+ Only keys present in `update_dict` will be updated. All other keys will be left as they are.
193
+ """
194
+ project_root = get_project_root(dir)
195
+ dot_dso_json = project_root / ".dso.json"
196
+ config = _read_dot_dso_json(dir)
197
+ config.update(update_dict)
198
+
199
+ with dot_dso_json.open("w") as f:
200
+ json.dump(config, f)
201
+
202
+
203
+ def check_ask_pre_commit(dir: Path):
204
+ """
205
+ Check if pre-commit hooks are installed and asks to install them
206
+
207
+ If the user declines, info will be written to `.dso.json` to not ask again in the future.
208
+ """
209
+ config = _read_dot_dso_json(dir)
210
+ if config.get("check_ask_pre_commit", True):
211
+ project_root = get_project_root(dir)
212
+ hook = project_root / ".git" / "hooks" / "pre-commit"
213
+ if not hook.is_file() or "pre-commit" not in hook.read_text():
214
+ console.print("Pre-commit hooks are not installed in this project.")
215
+ console.print(
216
+ "This hooks will take care of running consistency checks and automatically syncing [bold]dvc."
217
+ )
218
+ if Confirm.ask("[bold]Do you want to install the pre-commit hooks now?"):
219
+ res = subprocess.run(["pre-commit", "install"], cwd=project_root)
220
+ if res.returncode:
221
+ log.error("Failed to install pre-commit hooks")
222
+ sys.exit(res.returncode)
223
+ else:
224
+ log.info("Pre-commit hooks installed successfully")
225
+ else:
226
+ _update_dot_dso_json(dir, {"check_ask_pre_commit": False})
dso/assets/__init__.py ADDED
File without changes
Binary file
dso/compile_config.py ADDED
@@ -0,0 +1,181 @@
1
+ import filecmp
2
+ import os.path
3
+ import shutil
4
+ import tempfile
5
+ from collections.abc import Collection, Sequence
6
+ from functools import partial
7
+ from io import TextIOWrapper
8
+ from pathlib import Path
9
+ from textwrap import dedent
10
+
11
+ import rich_click as click
12
+ from ruamel.yaml import YAML, yaml_object
13
+
14
+ from dso import hiyapyco
15
+
16
+ from ._logging import log
17
+ from ._util import _find_in_parent, check_project_roots
18
+
19
+ PARAMS_YAML_DISCLAIMER = dedent(
20
+ """\
21
+ ################################# !!! WARNING !!! #############################################
22
+ # #
23
+ # params.yaml is AUTOMATICALLY GENERATED. DO NOT EDIT BY HAND or your changes might be lost. #
24
+ # #
25
+ # Instead, edit `params.in.yaml` and compile the changes using `dso compile-config`. #
26
+ # If you do not wish to use this feature, simply delete `params.in.yaml` #
27
+ # and remove this notice #
28
+ ###############################################################################################
29
+ """
30
+ )
31
+
32
+
33
+ def _load_yaml_with_auto_adjusting_paths(yaml_stream: TextIOWrapper, destination: Path):
34
+ """
35
+ Load a yaml file and adjust paths for all !path objects based on a destination file
36
+
37
+ Parameters
38
+ ----------
39
+ yaml_path
40
+ Path of the yaml file to load
41
+ destination
42
+ Path to which the file shall be adjusted
43
+
44
+ Returns
45
+ -------
46
+ The output of ruamel.yaml.YAML.load_all after registering a class for !path
47
+ """
48
+ ruamel = YAML()
49
+
50
+ # The folder of the source config file
51
+ source = Path(yaml_stream.name).parent
52
+ if not destination.is_relative_to(source):
53
+ raise ValueError("Destination path can be the same as source, or a child thereof.")
54
+
55
+ @yaml_object(ruamel)
56
+ class AutoAdjustingPathWithLocation:
57
+ yaml_tag = "!path"
58
+
59
+ def __init__(self, path: str):
60
+ self.path = Path(path)
61
+ if not (source / self.path).exists():
62
+ # Warn, but do not fail (it could also be an output path to be populated by a dvc stage)
63
+ log.warning(f"Path {self.path} does not exist!")
64
+
65
+ def get_adjusted(self):
66
+ # not possible with pathlib, because pathlib requires the paths to be subpaths of each other
67
+ diff = Path(os.path.relpath(source / self.path, destination))
68
+ return diff
69
+
70
+ @classmethod
71
+ def to_yaml(cls, representer, node):
72
+ return representer.represent_str(str(node.get_adjusted()))
73
+
74
+ @classmethod
75
+ def from_yaml(cls, constructor, node):
76
+ return cls(node.value)
77
+
78
+ return ruamel.load_all(yaml_stream)
79
+
80
+
81
+ def _get_list_of_configs_to_compile(paths: Sequence[Path], project_root: Path):
82
+ """Find all files named params.in.yaml in `dir` and all subdirectories"""
83
+ # Get all configs that are children of the current working directory
84
+ all_configs = {x for dir in paths for x in dir.glob("**/params.in.yaml")}
85
+ for c in all_configs:
86
+ assert c.is_relative_to(project_root), "Config file not relative to project root"
87
+
88
+ # Now we still need to find all config.in.yaml files in any parent directory (to enable the hierarchical compilation).
89
+ # We can start with the input paths, as they are per definition a parent of all config files found.
90
+ for tmp_path in paths:
91
+ # Check each parent directory if it contains a "params.in.yaml" - If yes, add it to the list of all configs.
92
+ # We don't need to re-check the parents of added items, because their parent is per definition also a parent
93
+ # of a config that was already part of the list.
94
+ while (tmp_path := _find_in_parent(tmp_path.parent, "params.in.yaml", project_root)) is not None:
95
+ all_configs.add(tmp_path)
96
+ # we don't want to find the current config again, therefore .parent
97
+ tmp_path = tmp_path.parent
98
+
99
+ return all_configs
100
+
101
+
102
+ def _get_parent_configs(current_config: Path, all_configs: Collection[Path]) -> list[Path]:
103
+ """For a particular config file, find all config files that are parent to it in a list of config files.
104
+
105
+ The files are sorted from parent to child. The current_config is always the last item in the list.
106
+ """
107
+ parent_configs = []
108
+ for tmp_cfg in all_configs:
109
+ if current_config.is_relative_to(tmp_cfg.parent):
110
+ parent_configs.append(tmp_cfg)
111
+
112
+ # sort from parent to child (based on the number of path parts)
113
+ return sorted(parent_configs, key=lambda x: len(x.parts))
114
+
115
+
116
+ def compile_all_configs(paths: Sequence[Path]):
117
+ """Compile params.in.yaml into params.yaml using Jinja2 templating and resolving recursive templates.
118
+
119
+ paths:
120
+ One or multiple locations within the project. Can be files or directories -- instead of files, their
121
+ parent directory will be used. Will compile all params.in.yaml files in child directories
122
+ and the respective parent config files.
123
+ """
124
+ # If files are specified, use the respective parent dir
125
+ paths = [p.parent.resolve() if p.is_file() else p.resolve() for p in paths]
126
+
127
+ project_root = check_project_roots(paths)
128
+ log.info(f"Detected {project_root} as project root.")
129
+
130
+ all_configs = _get_list_of_configs_to_compile(paths, project_root)
131
+ log.info(f"Compiling a total of {len(all_configs)} config files.")
132
+
133
+ for config in all_configs:
134
+ # sorted sorts path from parent to child. This is what we want as hyapyco gives precedence to configs
135
+ # later in the list.
136
+ configs_to_merge = _get_parent_configs(config, all_configs)
137
+ conf = hiyapyco.load(
138
+ *[str(x) for x in configs_to_merge],
139
+ method=hiyapyco.METHOD_MERGE,
140
+ interpolate=True,
141
+ loader_callback=partial(_load_yaml_with_auto_adjusting_paths, destination=config.parent),
142
+ )
143
+ # an empty configuration should actually be an empty dictionary.
144
+ if conf is None:
145
+ conf = {}
146
+ # write config to "params.yaml" in same directory
147
+ out_file = config.parent / "params.yaml"
148
+
149
+ # Write to temporary file first and compare to previous params.yaml
150
+ # Only ask for confirmation, overwrite, and show log if they are different
151
+ with tempfile.NamedTemporaryFile() as tmpfile:
152
+ # dump to tempfile
153
+ with open(tmpfile.name, "w") as f:
154
+ f.write(PARAMS_YAML_DISCLAIMER)
155
+ f.write("\n")
156
+ ruamel = YAML()
157
+ ruamel.dump(conf, f)
158
+ # check for equivalience
159
+ if not out_file.exists() or not filecmp.cmp(f.name, out_file, shallow=False):
160
+ shutil.copy(tmpfile.name, out_file)
161
+ log.debug(f"Compiled ./{config.relative_to(project_root)} to {out_file.name}")
162
+ else:
163
+ log.debug(f"./{config.relative_to(project_root)} [green]is already up-to-date!")
164
+
165
+ log.info("[green]Configuration compiled successfully.")
166
+
167
+
168
+ @click.command(name="compile-config")
169
+ @click.argument("args", nargs=-1)
170
+ def cli(args):
171
+ """Compile params.in.yaml into params.yaml using Jinja2 templating and resolving recursive templates.
172
+
173
+ If passing no arguments, configs will be resolved for the current working directory (i.e. all parent configs,
174
+ and all configs in child directories). Alternatively a list of paths can be specified. In that case, all configs
175
+ related to these paths will be compiled (useful for using with pre-commit).
176
+ """
177
+ if not len(args):
178
+ paths = [Path.cwd()]
179
+ else:
180
+ paths = [Path(x) for x in args]
181
+ compile_all_configs(paths)
dso/create.py ADDED
@@ -0,0 +1,108 @@
1
+ """Creates a project folder structure"""
2
+
3
+ import sys
4
+ from os import getcwd
5
+ from pathlib import Path
6
+ from textwrap import dedent, indent
7
+
8
+ import questionary
9
+ import rich_click as click
10
+ from rich.prompt import Confirm, Prompt
11
+
12
+ from dso._logging import log
13
+ from dso._util import _get_template_path, _instantiate_template, get_project_root
14
+ from dso.compile_config import compile_all_configs
15
+
16
+ DEFAULT_BRANCH = "master"
17
+ # list of stage template with description - can be later populated also from external directories
18
+ STAGE_TEMPLATES = {
19
+ "bash": "Execute a simple bash snippet or call an external script (e.g. nextflow)",
20
+ "quarto": "Generate a report using quarto",
21
+ }
22
+ # Create help text for CLI listing all templates
23
+ STAGE_TEMPLATE_TEXT = "\n".join(f" * __{name}__: {description}" for name, description in STAGE_TEMPLATES.items())
24
+ CREATE_STAGE_HELP_TEXT = dedent(
25
+ f"""\
26
+ Create a new stage.
27
+
28
+ A stage can be in any subfolder of the projects. Stages shall not be nested.
29
+
30
+ Available templates: \n{indent(STAGE_TEMPLATE_TEXT, " " * 6)}
31
+ """
32
+ )
33
+
34
+
35
+ @click.option("--description")
36
+ @click.option("--template", type=click.Choice(list(STAGE_TEMPLATES)))
37
+ @click.argument("name", required=False)
38
+ @click.command("stage", help=CREATE_STAGE_HELP_TEXT)
39
+ def create_stage(name: str | None = None, template: str | None = None, description: str | None = None):
40
+ """Create a new stage."""
41
+ if template is None:
42
+ template = str(questionary.select("Choose a template:", choices=list(STAGE_TEMPLATES)).ask())
43
+
44
+ if name is None:
45
+ name = Prompt.ask('[bold]Please enter the name of the stage, e.g. "01_preprocessing"')
46
+
47
+ if description is None:
48
+ description = Prompt.ask("[bold]Please add a short description of the stage")
49
+
50
+ target_dir = Path(getcwd()) / name
51
+ if target_dir.exists():
52
+ log.error(f"[red]Couldn't create stage: Folder with name {target_dir} already exists!")
53
+ sys.exit(1)
54
+ target_dir.mkdir()
55
+
56
+ # stage dir, relative to project root
57
+ project_root = get_project_root(target_dir)
58
+ stage_path = target_dir.relative_to(project_root)
59
+
60
+ _instantiate_template(
61
+ _get_template_path("stage", template),
62
+ target_dir,
63
+ stage_name=name,
64
+ stage_description=description,
65
+ stage_path=stage_path,
66
+ )
67
+ log.info("[green]Stage created successfully.")
68
+ compile_all_configs([target_dir])
69
+
70
+
71
+ @click.argument("name", required=False)
72
+ @click.command("folder")
73
+ def create_folder(name: str | None = None):
74
+ """Create a new folder. A folder can contain subfolders or stages.
75
+
76
+ Technically, nothing prevents you from just using `mkdir`. This command additionally adds some default
77
+ files that might be useful, e.g. an empty `dvc.yaml`.
78
+
79
+ You can specify a path to an existing folder. In that case all template files that do not exist will
80
+ be copied to the folder. Existing files will never be overwritten.
81
+ """
82
+ # currently there's only one template for folders
83
+ template = "default"
84
+
85
+ if name is None:
86
+ name = Prompt.ask('[bold]Please enter the name of the folder, e.g. "RNAseq"')
87
+
88
+ target_dir = Path(getcwd()) / name
89
+
90
+ if target_dir.exists():
91
+ if not Confirm.ask("[bold]Directory already exists. Do you want to copy template files to existing folder?"):
92
+ sys.exit(1)
93
+
94
+ target_dir.mkdir(exist_ok=True)
95
+
96
+ _instantiate_template(_get_template_path("folder", template), target_dir, stage_name=name)
97
+ log.info("[green]Folder created successfully.")
98
+ compile_all_configs([target_dir])
99
+
100
+
101
+ @click.group(name="create")
102
+ def cli():
103
+ """Create stage folder structure subcommand."""
104
+ pass
105
+
106
+
107
+ cli.add_command(create_stage)
108
+ cli.add_command(create_folder)