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.
- dso/__init__.py +62 -0
- dso/_logging.py +15 -0
- dso/_metadata.py +3 -0
- dso/_util.py +226 -0
- dso/assets/__init__.py +0 -0
- dso/assets/open_sans.ttf +0 -0
- dso/compile_config.py +181 -0
- dso/create.py +108 -0
- dso/exec.py +146 -0
- dso/get_config.py +139 -0
- dso/hiyapyco.py +564 -0
- dso/init.py +45 -0
- dso/lint.py +279 -0
- dso/pandocfilter.py +98 -0
- dso/repro.py +26 -0
- dso/templates/__init__.py +0 -0
- dso/templates/folder/__init__.py +4 -0
- dso/templates/folder/default/dvc.yaml +0 -0
- dso/templates/folder/default/params.in.yaml +0 -0
- dso/templates/init/__init__.py +1 -0
- dso/templates/init/default/.dvc/.gitignore +3 -0
- dso/templates/init/default/.dvc/config +2 -0
- dso/templates/init/default/.dvcignore +0 -0
- dso/templates/init/default/.editorconfig +15 -0
- dso/templates/init/default/.gitattributes +2 -0
- dso/templates/init/default/.gitignore +41 -0
- dso/templates/init/default/.pre-commit-config.yaml +70 -0
- dso/templates/init/default/.ruff.toml +43 -0
- dso/templates/init/default/README.md +3 -0
- dso/templates/init/default/dvc.yaml +0 -0
- dso/templates/init/default/params.in.yaml +21 -0
- dso/templates/stage/__init__.py +1 -0
- dso/templates/stage/bash/.gitignore +4 -0
- dso/templates/stage/bash/README.md +3 -0
- dso/templates/stage/bash/dvc.yaml +13 -0
- dso/templates/stage/bash/input/.gitignore +0 -0
- dso/templates/stage/bash/output/.gitignore +0 -0
- dso/templates/stage/bash/params.in.yaml +0 -0
- dso/templates/stage/quarto/.gitignore +4 -0
- dso/templates/stage/quarto/README.md +3 -0
- dso/templates/stage/quarto/dvc.yaml +11 -0
- dso/templates/stage/quarto/input/.gitignore +0 -0
- dso/templates/stage/quarto/output/.gitignore +0 -0
- dso/templates/stage/quarto/params.in.yaml +0 -0
- dso/templates/stage/quarto/report/.gitignore +0 -0
- dso/templates/stage/quarto/src/.gitignore +2 -0
- dso/templates/stage/quarto/src/{{ stage_name }}.qmd +14 -0
- dso/watermark.py +192 -0
- dso_core-0.8.2.post2.dist-info/METADATA +863 -0
- dso_core-0.8.2.post2.dist-info/RECORD +53 -0
- dso_core-0.8.2.post2.dist-info/WHEEL +4 -0
- dso_core-0.8.2.post2.dist-info/entry_points.txt +3 -0
- 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
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
|
dso/assets/open_sans.ttf
ADDED
|
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)
|