recp 0.1.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.
recp/__init__.py ADDED
File without changes
recp/cli/__init__.py ADDED
File without changes
recp/cli/main.py ADDED
@@ -0,0 +1,141 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ from datetime import datetime
5
+ from importlib.metadata import version
6
+ from ..utils.recipe import Recipe
7
+ from ..utils.config import PackageConfig
8
+
9
+
10
+ def get_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ description="run command line tools recp",
13
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
14
+ allow_abbrev=False
15
+ )
16
+ subparser = parser.add_subparsers(dest="action")
17
+
18
+ # Run parser
19
+ run_parser = subparser.add_parser(
20
+ "run",
21
+ description="run recipes in .yaml format",
22
+ help="run recipes in .yaml format",
23
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
24
+ allow_abbrev=False
25
+ )
26
+ run_parser.add_argument(
27
+ "recipe",
28
+ type=str,
29
+ help="recipe .yaml file"
30
+ )
31
+ run_parser.add_argument(
32
+ "-t", "--tag",
33
+ nargs="*",
34
+ type=str,
35
+ help="include only steps matching a specific tag"
36
+ )
37
+ run_parser.add_argument(
38
+ "-d", "--dry-run",
39
+ action="store_true",
40
+ help="show the sequence of commands to be run without running them"
41
+ )
42
+ run_parser.add_argument(
43
+ "--ignore-errors",
44
+ action="store_true",
45
+ help="ignore and skip steps that resulted in errors"
46
+ )
47
+ run_parser.add_argument(
48
+ "--unsafe",
49
+ action="store_true",
50
+ help="enable unsafe !expr constructor in recipe files"
51
+ )
52
+
53
+ # Config parser
54
+ config_parser = subparser.add_parser(
55
+ "config", # Show available recipes too
56
+ description="configure recp",
57
+ help="configure recp",
58
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
59
+ allow_abbrev=False
60
+ )
61
+ config_parser_option_group = config_parser.add_mutually_exclusive_group()
62
+ config_parser_option_group.add_argument(
63
+ "--set",
64
+ type=str,
65
+ nargs=2,
66
+ help="set parameter",
67
+ )
68
+ config_parser_option_group.add_argument(
69
+ "--add",
70
+ type=str,
71
+ help="add a recipe",
72
+ )
73
+ return parser
74
+
75
+
76
+ def main() -> None:
77
+ # Version print
78
+ if (
79
+ len(sys.argv) == 1
80
+ or (len(sys.argv) == 2 and sys.argv[1] in ("-v", "--version"))
81
+ ):
82
+ print(
83
+ f"recp version {version('recp')} developed by Esteban Gómez 2025-"
84
+ f"{datetime.now().year} (Speech Interaction Technology, Aalto "
85
+ "University)"
86
+ )
87
+ sys.exit(0)
88
+
89
+ # Parse args
90
+ parser = get_parser()
91
+ args = parser.parse_args()
92
+
93
+ match args.action:
94
+ case "config":
95
+ config = PackageConfig(app_name="recp", app_author="Esteban Gómez")
96
+
97
+ if args.set:
98
+ config.set_param(param=args.set[0], value=args.set[1])
99
+
100
+ elif args.add:
101
+ config.add_recipe(args.add)
102
+
103
+ else:
104
+ config.print_params()
105
+
106
+ case "run":
107
+ # Check if recipe is a preset
108
+ if not args.recipe.endswith(".yaml"):
109
+ config = PackageConfig(
110
+ app_name="recp",
111
+ app_author="Esteban Gómez"
112
+ )
113
+
114
+ if os.path.isfile(
115
+ os.path.join(config.recipes_dir, args.recipe + ".yaml")
116
+ ):
117
+ args.recipe = os.path.join(
118
+ config.recipes_dir,
119
+ args.recipe + ".yaml"
120
+ )
121
+
122
+ else:
123
+ raise FileNotFoundError(
124
+ f"Recipe {args.recipe!r} not found in recipes folder "
125
+ f"{config.recipes_dir!r}"
126
+ )
127
+
128
+ # Create and run recipe
129
+ recipe = Recipe(file=args.recipe, allow_expr=args.unsafe)
130
+ recipe.run(
131
+ tag=args.tag,
132
+ ignore_errors=args.ignore_errors,
133
+ dry_run=args.dry_run
134
+ )
135
+
136
+ case _:
137
+ raise AssertionError
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
recp/py.typed ADDED
File without changes
recp/utils/__init__.py ADDED
File without changes
recp/utils/apply.py ADDED
@@ -0,0 +1,275 @@
1
+ import os
2
+ import random
3
+ from datetime import datetime
4
+ from typing import List
5
+ from .io import get_dir_files
6
+ from .exceptions import LenghtError
7
+
8
+
9
+ def apply_date(
10
+ cmd_list: List[str],
11
+ token: str,
12
+ format: str = "%Y-%m-%d"
13
+ ) -> List[str]:
14
+ """Replaces a given token by a date.
15
+
16
+ Args:
17
+ cmd_list (List[str]): Input commands.
18
+ token (str): The token within the command strings to be replaced.
19
+ format (str): Date format to use.
20
+
21
+ Returns:
22
+ List[str]: List of modified commands.
23
+ """
24
+ for cmd_idx, cmd in enumerate(cmd_list):
25
+ cmd_list[cmd_idx] = cmd.replace(token, datetime.now().strftime(format))
26
+
27
+ return cmd_list
28
+
29
+
30
+ def apply_dir_files(
31
+ cmd_list: List[str],
32
+ token: str,
33
+ dir: str,
34
+ ext: str | List[str] = "*",
35
+ recursive: bool = True
36
+ ) -> List[str]:
37
+ """Replaces a given token by the path to each file in a foleder.
38
+
39
+ Args:
40
+ cmd_list (List[str]): Input commands.
41
+ token (str): The token within the command strings to be replaced.
42
+ dir (str): Path to the folder to be searched.
43
+ ext (str | List[str]): The file extension(s) used to filter files. Only
44
+ files matching these extension(s) will be considered.
45
+ recursive (bool): If `True`, the search is done recursively.
46
+
47
+ Returns:
48
+ List[str]: List of modified commands.
49
+ """
50
+ for cmd in cmd_list:
51
+ files = get_dir_files(dir=dir, ext=ext, recursive=recursive)
52
+ cmd_expanded_list = []
53
+
54
+ for file in files:
55
+ cmd_expanded_list.append(cmd.replace(token, file))
56
+
57
+ return cmd_expanded_list
58
+
59
+
60
+ def apply_index(
61
+ cmd_list: List[str],
62
+ token: str,
63
+ offset: int = 0,
64
+ zfill: int = 0
65
+ ) -> List[str]:
66
+ """Replaces a given token by the command index value.
67
+
68
+ Args:
69
+ cmd_list (List[str]): Input commands.
70
+ token (str): Token to be replaced.
71
+ offset (int): Offset applied to all values.
72
+ zfill (int | None): Minimum width of the number, padded with leading
73
+ zeros if needed.
74
+
75
+ Returns:
76
+ List[str]: List of modified commands.
77
+ """
78
+ for cmd_idx, cmd in enumerate(cmd_list):
79
+ cmd_list[cmd_idx] = cmd.replace(
80
+ token,
81
+ str(cmd_idx + offset).zfill(zfill)
82
+ )
83
+
84
+ return cmd_list
85
+
86
+
87
+ def apply_parent_dir(
88
+ cmd_list: List[str],
89
+ token: str,
90
+ path: str
91
+ ) -> List[str]:
92
+ """Replaces a given token by the parent path of a given path.
93
+
94
+ Args:
95
+ cmd_list (List[str]): Input commands.
96
+ token (str): The token within the command strings to be replaced.
97
+ path (str): The file system path from which the parent directory will
98
+ be extracted.
99
+
100
+ Returns:
101
+ List[str]: List of modified commands.
102
+ """
103
+ for cmd_idx, cmd in enumerate(cmd_list):
104
+ cmd_list[cmd_idx] = cmd.replace(
105
+ token,
106
+ os.path.dirname(os.path.normpath(path))
107
+ )
108
+
109
+ return cmd_list
110
+
111
+
112
+ def apply_randchoice(
113
+ cmd_list: List[str],
114
+ token: str,
115
+ choices: List[str],
116
+ seed: int | None = None
117
+ ) -> List[str]:
118
+ """Replace a token by a random choice from a list of choices.
119
+
120
+ Args:
121
+ cmd_list (List[str]): Input commands.
122
+ token (str): Token to be replaced.
123
+ choices (List[str]): List of choices.
124
+ seed (int | None): Random seed.
125
+ """
126
+ generator = random.Random(seed)
127
+
128
+ for cmd_idx, cmd in enumerate(cmd_list):
129
+ value = generator.choice(choices)
130
+ cmd_list[cmd_idx] = cmd.replace(token, str(value))
131
+
132
+ return cmd_list
133
+
134
+
135
+ def apply_randint(
136
+ cmd_list: List[str],
137
+ token: str,
138
+ min: int,
139
+ max: int,
140
+ seed: int | None = None
141
+ ) -> List[str]:
142
+ """Replace a token by a random integer number within a range, including
143
+ both `min` and `max` within this range.
144
+
145
+ Args:
146
+ cmd_list (List[str]): Input commands.
147
+ token (str): Token to be replaced.
148
+ min (int): Minimum `int` value to be generated.
149
+ max (int): Maximum `int` value to be generated.
150
+ seed (int | None): Random seed.
151
+ """
152
+ generator = random.Random(seed)
153
+
154
+ for cmd_idx, cmd in enumerate(cmd_list):
155
+ value = generator.randint(min, max)
156
+ cmd_list[cmd_idx] = cmd.replace(token, str(value))
157
+
158
+ return cmd_list
159
+
160
+
161
+ def apply_randfloat(
162
+ cmd_list: List[str],
163
+ token: str,
164
+ min: float,
165
+ max: float,
166
+ seed: int | None = None
167
+ ) -> List[str]:
168
+ """Replace a token by a random floating-point number within the
169
+ `[min, max)` range.
170
+
171
+ Args:
172
+ cmd_list (List[str]): Input commands.
173
+ token (str): Token to be replaced.
174
+ min (float): Minimum `float` value to be generated (inclusive).
175
+ max (float): Maximum `float` value to be generated (exclusive).
176
+ seed (int | None): Random seed.
177
+ """
178
+ generator = random.Random(seed)
179
+
180
+ for cmd_idx, cmd in enumerate(cmd_list):
181
+ value = generator.uniform(min, max)
182
+ cmd_list[cmd_idx] = cmd.replace(token, str(value))
183
+
184
+ return cmd_list
185
+
186
+
187
+ def apply_replace(cmd_list: List[str], **kwargs) -> List[str]:
188
+ """Replace placeholders by specified values.
189
+
190
+ Args:
191
+ cmd_list (List[str]): Input commands.
192
+ **kwargs: Each subsequent argument corresponds to the value to be
193
+ replaced, and the value is the updated value it will take.
194
+
195
+ Returns:
196
+ List[str]: List of modified commands.
197
+ """
198
+ for cmd_idx, cmd in enumerate(cmd_list):
199
+ for k, v in kwargs.items():
200
+ cmd = cmd.replace(k, v)
201
+
202
+ cmd_list[cmd_idx] = cmd
203
+
204
+ return cmd_list
205
+
206
+
207
+ def apply_repeat(cmd_list: List[str], n: int) -> List[str]:
208
+ """Repeat a command or command list `n` times.
209
+
210
+ Args:
211
+ cmd_list (List[str]): Input commands.
212
+ n (int): Number of repetitions.
213
+
214
+ Returns:
215
+ List[str]: List of modified commands.
216
+ """
217
+ return cmd_list * n
218
+
219
+
220
+ def apply_match(
221
+ cmd_list: List[str],
222
+ var: str,
223
+ token: str,
224
+ choices: List[str],
225
+ values: List[str],
226
+ ) -> List[str]:
227
+ """Matches a variable value against multiple choices and replaces a token
228
+ in the command based on the matching value.
229
+
230
+ Args:
231
+ cmd_list (List[str]): Input commands.
232
+ var (str): Variable to match.
233
+ token (str): Token to replace.
234
+ choices (List[str]): Possible values for `var`.
235
+ values (List[str]): Values used to replace `token` based on the
236
+ matching value from `choices`.
237
+
238
+ Returns:
239
+ List[str]: List of modified commands.
240
+ """
241
+ # Assertions
242
+ if len(choices) != len(values):
243
+ raise LenghtError(
244
+ "case and value lists must have the same number of elements, but "
245
+ f"case has {len(choices)} elements and value has {len(values)} "
246
+ "elements"
247
+ )
248
+
249
+ # Turn into sets to filter out repeated values
250
+ choice = list(dict.fromkeys(choices)) # Preserves order
251
+ value = list(dict.fromkeys(values))
252
+
253
+ for cmd_idx, cmd in enumerate(cmd_list):
254
+ value_idx = choice.index(var)
255
+ cmd_list[cmd_idx] = cmd.replace(token, value[value_idx])
256
+
257
+ return cmd_list
258
+
259
+
260
+ def get_apply_registy() -> dict:
261
+ """Returns the registry of all functions that can be used withing the `run`
262
+ key of a recipe `.yaml` file.
263
+ """
264
+ return {
265
+ "date": apply_date,
266
+ "dir_files": apply_dir_files,
267
+ "index": apply_index,
268
+ "match": apply_match,
269
+ "parent_dir": apply_parent_dir,
270
+ "randchoice": apply_randchoice,
271
+ "randint": apply_randint,
272
+ "randfloat": apply_randfloat,
273
+ "replace": apply_replace,
274
+ "repeat": apply_repeat
275
+ }
@@ -0,0 +1,43 @@
1
+ import os
2
+ from contextlib import contextmanager
3
+ from typing import (
4
+ Any,
5
+ List
6
+ )
7
+
8
+
9
+ def make_list(x: Any) -> List[Any]:
10
+ """If `x` is a single element, turns it into a `list` of one element.
11
+
12
+ Args:
13
+ x (Any): Element(s) to be returned as a `list`.
14
+
15
+ Returns:
16
+ (list): `x` as a `list`.
17
+ """
18
+ return [x] if not isinstance(x, list) and x is not None else x
19
+
20
+
21
+ @contextmanager
22
+ def temp_env(merge: dict = {}):
23
+ """Temporarily modify the environment variables.
24
+
25
+ This context manager allows temporary modification of the
26
+ environment variables for the duration of the context. Once the
27
+ context is exited, the environment variables are restored to their
28
+ original state.
29
+
30
+ Args:
31
+ merge (dict): A dictionary of environment variables to be
32
+ temporarily added or updated. The keys are the variable
33
+ names and the values are their corresponding values.
34
+ """
35
+ old_env = os.environ.copy()
36
+
37
+ try:
38
+ os.environ.update({k: str(v) for k, v in merge.items()})
39
+ yield
40
+
41
+ finally:
42
+ os.environ.clear()
43
+ os.environ.update(old_env)
recp/utils/config.py ADDED
@@ -0,0 +1,114 @@
1
+ import os
2
+ import json
3
+ import shutil
4
+ from typing import Any
5
+ from platformdirs import PlatformDirs
6
+ from .exceptions import (
7
+ FileExtensionError,
8
+ FolderNotFoundError
9
+ )
10
+
11
+
12
+ class PackageConfig:
13
+ """Package configuration parameters.
14
+
15
+ Args:
16
+ app_name (str): Application name.
17
+ app_author (str): Application author name.
18
+ """
19
+ def __init__(
20
+ self,
21
+ app_name: str,
22
+ app_author: str
23
+ ) -> None:
24
+ super().__init__()
25
+
26
+ # Params
27
+ self._platform = PlatformDirs(appname=app_name, appauthor=app_author)
28
+ self.maybe_reset_default_config()
29
+ self.config_file = os.path.join(
30
+ self._platform.user_data_dir,
31
+ "settings.json"
32
+ )
33
+
34
+ with open(self.config_file, "r") as f:
35
+ self.config = json.load(f)
36
+
37
+ @property
38
+ def recipes_dir(self) -> str:
39
+ """Returns the folder where user recipes are stored.
40
+
41
+ Returns:
42
+ (str): Folder where user recipes are stored.
43
+ """
44
+ return self.config["recipes.dir"]
45
+
46
+ def maybe_reset_default_config(self) -> None:
47
+ """Restores the default package configuration if it does not exist."""
48
+ BASE_DIR = self._platform.user_data_dir
49
+ RECIPES_DIR = os.path.join(BASE_DIR, "recipes")
50
+ BASE_SETTINGS_FILE = os.path.join(BASE_DIR, "settings.json")
51
+ BASE_SETTINGS = {"recipes.dir": RECIPES_DIR}
52
+
53
+ os.makedirs(BASE_DIR, exist_ok=True)
54
+ os.makedirs(RECIPES_DIR, exist_ok=True)
55
+
56
+ if not os.path.isfile(BASE_SETTINGS_FILE):
57
+ with open(BASE_SETTINGS_FILE, "w") as f:
58
+ json.dump(obj=BASE_SETTINGS, fp=f, indent=2)
59
+
60
+ def save_config(self) -> None:
61
+ """Saves the `settings.json` configuration file with the update
62
+ configuration parameters.
63
+ """
64
+ with open(self.config_file, "w", encoding="utf-8") as f:
65
+ json.dump(self.config, f)
66
+
67
+ def set_param(self, param: str, value: Any) -> None:
68
+ """Sets a parameter value in the user configuration file.
69
+
70
+ Args:
71
+ param (str): Parameter name.
72
+ value (Any): Parameter value.
73
+ """
74
+ if param not in self.config:
75
+ raise ValueError(
76
+ f"Invalid parameter {param!r}. Run 'recp config' to list "
77
+ "existing parameters"
78
+ )
79
+
80
+ match param:
81
+ case "recipes.dir":
82
+ if not os.path.isdir(value):
83
+ raise FolderNotFoundError(f"Invalid folder {value!r}")
84
+
85
+ self.config[param] = value
86
+
87
+ case _:
88
+ raise AssertionError
89
+
90
+ self.save_config()
91
+
92
+ def add_recipe(self, file: str) -> None:
93
+ """Adds a new recipe to the user recipes folder.
94
+
95
+ Args:
96
+ file (str): `.yaml` recipe file to add.
97
+ """
98
+ if not os.path.isfile(file):
99
+ raise FileNotFoundError(f"File {file!r} not found")
100
+
101
+ if not file.endswith(".yaml"):
102
+ raise FileExtensionError(
103
+ "Only .yaml files can be added as recipes"
104
+ )
105
+
106
+ shutil.copy(file, self.recipes_dir)
107
+ print(f"Recipe {file!r} successfully added to recipes folder")
108
+
109
+ def print_params(self) -> None:
110
+ """Prints user parameters to the terminal. """
111
+ print(f"file: {self.config_file!r}")
112
+
113
+ for k, v in self.config.items():
114
+ print(f"{k}: {v!r}")
@@ -0,0 +1,19 @@
1
+
2
+ class RequiredValueNotFoundError(Exception):
3
+ pass
4
+
5
+
6
+ class MinimumVersionRequirementError(Exception):
7
+ pass
8
+
9
+
10
+ class FolderNotFoundError(Exception):
11
+ pass
12
+
13
+
14
+ class FileExtensionError(Exception):
15
+ pass
16
+
17
+
18
+ class LenghtError(Exception):
19
+ pass
recp/utils/io.py ADDED
@@ -0,0 +1,75 @@
1
+ import os
2
+ from glob import glob
3
+ from typing import (
4
+ Callable,
5
+ List
6
+ )
7
+ from .collections import make_list
8
+
9
+
10
+ def get_dir_files(
11
+ dir: str | List[str],
12
+ ext: str | List[str] = "*",
13
+ recursive: bool = True,
14
+ key: Callable | None = None
15
+ ) -> List[str]:
16
+ """Returns a `list` with all the files inside folder with extension `ext`.
17
+ It supports a recursive search and searching in more than one root folder
18
+ at a time if `recursive=True` and `dir` is a `list` of `str`,
19
+ respectively.
20
+
21
+ Args:
22
+ dir (str | List[str]): Folder(s) to be searched.
23
+ ext (str | Tuple[str]): File extensions to be considered. Accepts `.*`
24
+ as a wild card.
25
+ recursive (bool): If `True`, the search inside each folder will be
26
+ recursive.
27
+ key (Callable | None): Key function to sort the results. If it is not
28
+ provided, files will be sorted alphabetically.
29
+
30
+ Returns:
31
+ `list` of `str` with the path to each retrieved file.
32
+
33
+ Raises:
34
+ FileNotFoundError: If one of the folder(s) cannot be found.
35
+ """
36
+ dir = make_list(dir)
37
+ ext = make_list(ext)
38
+
39
+ # Expand user and vars
40
+ for idx, dir_ in enumerate(dir):
41
+ dir[idx] = dir_
42
+
43
+ # Check dirs exist before fetching content
44
+ for dir_ in dir:
45
+ if not os.path.isdir(dir_):
46
+ raise FileNotFoundError(f"Folder not found: '{dir_}'")
47
+
48
+ all_files = []
49
+
50
+ # Search dirs
51
+ for dir_ in dir:
52
+ for ext_ in ext:
53
+ if recursive:
54
+ all_files.extend(
55
+ list(
56
+ glob(
57
+ os.path.join(dir_, "**", f"*{ext_}"),
58
+ recursive=True
59
+ )
60
+ )
61
+ )
62
+ else:
63
+ all_files.extend(list(glob(os.path.join(dir_, f"*{ext_}"))))
64
+
65
+ # Filter out folders with file-like names (e.g. ending in .wav extension)
66
+ flagged_files = []
67
+
68
+ for file in all_files:
69
+ if not os.path.isfile(file):
70
+ flagged_files.append(file)
71
+
72
+ for flagged_file in flagged_files:
73
+ all_files.remove(flagged_file)
74
+
75
+ return sorted(all_files, key=key)