hydraflow 0.7.5__py3-none-any.whl → 0.9.0__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.
@@ -0,0 +1,152 @@
1
+ """Job execution and argument handling for HydraFlow.
2
+
3
+ This module provides functionality for executing jobs in HydraFlow, including:
4
+
5
+ - Argument parsing and expansion for job steps
6
+ - Batch processing of Hydra configurations
7
+ - Execution of jobs via shell commands or Python functions
8
+
9
+ The module supports two execution modes:
10
+
11
+ 1. Shell command execution
12
+ 2. Python function calls
13
+
14
+ Each job can consist of multiple steps, and each step can have its own
15
+ arguments and options that will be expanded into multiple runs.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import importlib
21
+ import shlex
22
+ import subprocess
23
+ from subprocess import CalledProcessError
24
+ from typing import TYPE_CHECKING
25
+
26
+ import ulid
27
+
28
+ from .parser import collect, expand
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Iterator
32
+
33
+ from .conf import Job, Step
34
+
35
+
36
+ def iter_args(step: Step) -> Iterator[list[str]]:
37
+ """Iterate over combinations generated from parsed arguments.
38
+
39
+ Generate all possible combinations of arguments by parsing and
40
+ expanding each one, yielding them as an iterator.
41
+
42
+ Args:
43
+ step (Step): The step to parse.
44
+
45
+ Yields:
46
+ list[str]: a list of the parsed argument combinations.
47
+
48
+ """
49
+ args = collect(step.args)
50
+ options = [o for o in step.options.split(" ") if o]
51
+
52
+ for batch in expand(step.batch):
53
+ yield [*options, *sorted([*batch, *args])]
54
+
55
+
56
+ def iter_batches(job: Job) -> Iterator[list[str]]:
57
+ """Generate Hydra application arguments for a job.
58
+
59
+ This function generates a list of Hydra application arguments
60
+ for a given job, including the job name and the root directory
61
+ for the sweep.
62
+
63
+ Args:
64
+ job (Job): The job to generate the Hydra configuration for.
65
+
66
+ Returns:
67
+ list[str]: A list of Hydra configuration strings.
68
+
69
+ """
70
+ job_name = f"hydra.job.name={job.name}"
71
+
72
+ for step in job.steps:
73
+ for args in iter_args(step):
74
+ sweep_dir = f"hydra.sweep.dir=multirun/{ulid.ulid()}"
75
+ yield ["--multirun", sweep_dir, job_name, *args]
76
+
77
+
78
+ def multirun(job: Job) -> None:
79
+ """Execute multiple runs of a job using either shell commands or Python functions.
80
+
81
+ This function processes a job configuration and executes it in one of two modes:
82
+
83
+ 1. Shell command mode (job.run): Executes shell commands with the generated
84
+ arguments
85
+ 2. Python function mode (job.call): Calls a Python function with the generated
86
+ arguments
87
+
88
+ Args:
89
+ job (Job): The job configuration containing run parameters and steps.
90
+
91
+ Raises:
92
+ RuntimeError: If a shell command fails or if a function call encounters
93
+ an error.
94
+ ValueError: If the Python function path is invalid or the function cannot
95
+ be imported.
96
+
97
+ """
98
+ it = iter_batches(job)
99
+
100
+ if job.run:
101
+ base_cmds = shlex.split(job.run)
102
+ for args in it:
103
+ cmds = [*base_cmds, *args]
104
+ try:
105
+ subprocess.run(cmds, check=True)
106
+ except CalledProcessError as e:
107
+ msg = f"Command failed with exit code {e.returncode}"
108
+ raise RuntimeError(msg) from e
109
+
110
+ elif job.call:
111
+ if "." not in job.call:
112
+ msg = f"Invalid function path: {job.call}."
113
+ msg += " Expected format: 'package.module.function'"
114
+ raise ValueError(msg)
115
+
116
+ try:
117
+ module_name, func_name = job.call.rsplit(".", 1)
118
+ module = importlib.import_module(module_name)
119
+ func = getattr(module, func_name)
120
+ except (ImportError, AttributeError, ModuleNotFoundError) as e:
121
+ msg = f"Failed to import or find function: {job.call}"
122
+ raise ValueError(msg) from e
123
+
124
+ for args in it:
125
+ try:
126
+ func(*args)
127
+ except Exception as e: # noqa: PERF203
128
+ msg = f"Function call '{job.call}' failed with args: {args}"
129
+ raise RuntimeError(msg) from e
130
+
131
+
132
+ def show(job: Job) -> None:
133
+ """Show the job configuration.
134
+
135
+ This function shows the job configuration for a given job.
136
+
137
+ Args:
138
+ job (Job): The job configuration to show.
139
+
140
+ """
141
+ it = iter_batches(job)
142
+
143
+ if job.run:
144
+ base_cmds = shlex.split(job.run)
145
+ for args in it:
146
+ cmds = " ".join([*base_cmds, *args])
147
+ print(cmds) # noqa: T201
148
+
149
+ elif job.call:
150
+ print(f"call: {job.call}") # noqa: T201
151
+ for args in it:
152
+ print(f"args: {args}") # noqa: T201
@@ -0,0 +1,397 @@
1
+ """Parse and convert string representations of numbers and ranges.
2
+
3
+ This module provides utility functions for parsing and converting
4
+ string representations of numbers and ranges. It includes functions
5
+ to convert strings to numbers, count decimal places, handle numeric
6
+ ranges, and expand values from string arguments.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from itertools import chain, product
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterator
17
+
18
+
19
+ def to_number(x: str) -> int | float:
20
+ """Convert a string to an integer or float.
21
+
22
+ Attempts to convert a string to an integer or a float,
23
+ returning 0 if the string is empty or cannot be converted.
24
+
25
+ Args:
26
+ x (str): The string to convert.
27
+
28
+ Returns:
29
+ int | float: The converted number as an integer or float.
30
+
31
+ Examples:
32
+ >>> type(to_number("1"))
33
+ <class 'int'>
34
+ >>> type(to_number("1.2"))
35
+ <class 'float'>
36
+ >>> to_number("")
37
+ 0
38
+ >>> to_number("1e-3")
39
+ 0.001
40
+
41
+ """
42
+ if not x:
43
+ return 0
44
+
45
+ if "." in x or "e" in x.lower():
46
+ return float(x)
47
+
48
+ return int(x)
49
+
50
+
51
+ def count_decimal_places(x: str) -> int:
52
+ """Count decimal places in a string.
53
+
54
+ Examine a string representing a number and returns the count
55
+ of decimal places present after the decimal point.
56
+ Return 0 if no decimal point is found.
57
+
58
+ Args:
59
+ x (str): The string to check.
60
+
61
+ Returns:
62
+ int: The number of decimal places.
63
+
64
+ Examples:
65
+ >>> count_decimal_places("1")
66
+ 0
67
+ >>> count_decimal_places("-1.2")
68
+ 1
69
+ >>> count_decimal_places("1.234")
70
+ 3
71
+ >>> count_decimal_places("-1.234e-10")
72
+ 3
73
+
74
+ """
75
+ if "." not in x:
76
+ return 0
77
+
78
+ decimal_part = x.split(".")[1]
79
+ if "e" in decimal_part.lower():
80
+ decimal_part = decimal_part.split("e")[0]
81
+
82
+ return len(decimal_part)
83
+
84
+
85
+ def is_number(x: str) -> bool:
86
+ """Check if a string is a number.
87
+
88
+ Args:
89
+ x (str): The string to check.
90
+
91
+ Returns:
92
+ bool: True if the string is a number, False otherwise.
93
+
94
+ Examples:
95
+ >>> is_number("1")
96
+ True
97
+ >>> is_number("-1.2")
98
+ True
99
+ >>> is_number("1.2.3")
100
+ False
101
+
102
+ """
103
+ try:
104
+ float(x)
105
+ except ValueError:
106
+ return False
107
+ return True
108
+
109
+
110
+ SUFFIX_EXPONENT = {
111
+ "T": "e12",
112
+ "G": "e9",
113
+ "M": "e6",
114
+ "k": "e3",
115
+ "m": "e-3",
116
+ "u": "e-6",
117
+ "n": "e-9",
118
+ "p": "e-12",
119
+ "f": "e-15",
120
+ }
121
+
122
+
123
+ def _get_range(arg: str) -> tuple[float, float, float]:
124
+ args = [to_number(x) for x in arg.split(":")]
125
+
126
+ if len(args) == 2:
127
+ if args[0] > args[1]:
128
+ raise ValueError("start cannot be greater than stop")
129
+
130
+ return (args[0], 1, args[1])
131
+
132
+ if args[1] == 0:
133
+ raise ValueError("step cannot be zero")
134
+ if args[1] > 0 and args[0] > args[2]:
135
+ raise ValueError("start cannot be greater than stop")
136
+ if args[1] < 0 and args[0] < args[2]:
137
+ raise ValueError("start cannot be less than stop")
138
+
139
+ return args[0], args[1], args[2]
140
+
141
+
142
+ def _arange(start: float, step: float, stop: float) -> list[float]:
143
+ result = []
144
+ current = start
145
+
146
+ while current <= stop if step > 0 else current >= stop:
147
+ result.append(current)
148
+ current += step
149
+
150
+ return result
151
+
152
+
153
+ def split_suffix(arg: str) -> tuple[str, str]:
154
+ """Split a string into prefix and suffix.
155
+
156
+ Args:
157
+ arg (str): The string to split.
158
+
159
+ Returns:
160
+ tuple[str, str]: A tuple containing the prefix and suffix.
161
+
162
+ Examples:
163
+ >>> split_suffix("1:k")
164
+ ('1', 'e3')
165
+ >>> split_suffix("1:2:k")
166
+ ('1:2', 'e3')
167
+ >>> split_suffix("1:2:M")
168
+ ('1:2', 'e6')
169
+ >>> split_suffix(":1:2:M")
170
+ (':1:2', 'e6')
171
+
172
+ """
173
+ if len(arg) < 3 or ":" not in arg:
174
+ return arg, ""
175
+
176
+ prefix, suffix = arg.rsplit(":", 1)
177
+
178
+ if suffix.lower().startswith("e"):
179
+ return prefix, suffix
180
+
181
+ if suffix not in SUFFIX_EXPONENT:
182
+ return arg, ""
183
+
184
+ return prefix, SUFFIX_EXPONENT[suffix]
185
+
186
+
187
+ def add_exponent(value: str, exponent: str) -> str:
188
+ """Append an exponent to a value string.
189
+
190
+ Args:
191
+ value (str): The value to modify.
192
+ exponent (str): The exponent to append.
193
+
194
+ Returns:
195
+ str: The value with the exponent added.
196
+
197
+ """
198
+ if value in ["0", "0.", "0.0"] or not exponent:
199
+ return value
200
+
201
+ return f"{value}{exponent}"
202
+
203
+
204
+ def collect_values(arg: str) -> list[str]:
205
+ """Collect a list of values from a range argument.
206
+
207
+ Collect all individual values within a numeric range
208
+ represented by a string (e.g., `1:4`) and return them
209
+ as a list of strings.
210
+ Support both integer and floating-point ranges.
211
+
212
+ Args:
213
+ arg (str): The argument to collect.
214
+
215
+ Returns:
216
+ list[str]: A list of the collected values.
217
+
218
+ """
219
+ if ":" not in arg:
220
+ return [arg]
221
+
222
+ arg, exponent = split_suffix(arg)
223
+
224
+ if ":" not in arg:
225
+ return [f"{arg}{exponent}"]
226
+
227
+ rng = _get_range(arg)
228
+
229
+ if all(isinstance(x, int) for x in rng):
230
+ values = [str(x) for x in _arange(*rng)]
231
+ else:
232
+ n = max(*(count_decimal_places(x) for x in arg.split(":")))
233
+ values = [str(round(x, n)) for x in _arange(*rng)]
234
+
235
+ return [add_exponent(x, exponent) for x in values]
236
+
237
+
238
+ def split(arg: str) -> list[str]:
239
+ r"""Split a string by top-level commas.
240
+
241
+ Splits a string by commas while respecting nested structures.
242
+ Commas inside brackets and quotes are ignored, only splitting
243
+ at the top-level commas.
244
+
245
+ Args:
246
+ arg (str): The string to split.
247
+
248
+ Returns:
249
+ list[str]: A list of split strings.
250
+
251
+ Examples:
252
+ >>> split("[a,1],[b,2]")
253
+ ['[a,1]', '[b,2]']
254
+ >>> split('"x,y",z')
255
+ ['"x,y"', 'z']
256
+ >>> split("'p,q',r")
257
+ ["'p,q'", 'r']
258
+
259
+ """
260
+ result = []
261
+ current = []
262
+ bracket_count = 0
263
+ in_single_quote = False
264
+ in_double_quote = False
265
+
266
+ for char in arg:
267
+ if char == "'" and not in_double_quote:
268
+ in_single_quote = not in_single_quote
269
+ elif char == '"' and not in_single_quote:
270
+ in_double_quote = not in_double_quote
271
+ elif char == "[" and not (in_single_quote or in_double_quote):
272
+ bracket_count += 1
273
+ elif char == "]" and not (in_single_quote or in_double_quote):
274
+ bracket_count -= 1
275
+ elif (
276
+ char == ","
277
+ and bracket_count == 0
278
+ and not in_single_quote
279
+ and not in_double_quote
280
+ ):
281
+ result.append("".join(current))
282
+ current = []
283
+ continue
284
+ current.append(char)
285
+
286
+ if current:
287
+ result.append("".join(current))
288
+
289
+ return result
290
+
291
+
292
+ def expand_values(arg: str) -> list[str]:
293
+ """Expand a string argument into a list of values.
294
+
295
+ Take a string containing comma-separated values or ranges and return a list
296
+ of all individual values. Handle numeric ranges and special characters.
297
+
298
+ Args:
299
+ arg (str): The argument to expand.
300
+
301
+ Returns:
302
+ list[str]: A list of the expanded values.
303
+
304
+ """
305
+ return list(chain.from_iterable(collect_values(x) for x in split(arg)))
306
+
307
+
308
+ def collect_arg(arg: str) -> str:
309
+ """Collect a string of expanded key-value pairs.
310
+
311
+ Take a key-value pair argument and concatenates all expanded values with commas,
312
+ returning a single string suitable for command-line usage.
313
+
314
+ Args:
315
+ arg (str): The argument to collect.
316
+
317
+ Returns:
318
+ str: A string of the collected key and values.
319
+
320
+ """
321
+ key, arg = arg.split("=")
322
+ arg = ",".join(expand_values(arg))
323
+ return f"{key}={arg}"
324
+
325
+
326
+ def expand_arg(arg: str) -> Iterator[str]:
327
+ """Parse a string argument into a list of values.
328
+
329
+ Responsible for parsing a string that may contain multiple
330
+ arguments separated by pipes ("|") and returns a list of all
331
+ expanded arguments.
332
+
333
+ Args:
334
+ arg (str): The argument to parse.
335
+
336
+ Returns:
337
+ list[str]: A list of the parsed arguments.
338
+
339
+ """
340
+ if "|" not in arg:
341
+ key, value = arg.split("=")
342
+
343
+ for v in expand_values(value):
344
+ yield f"{key}={v}"
345
+
346
+ return
347
+
348
+ args = arg.split("|")
349
+ key = ""
350
+
351
+ for arg_ in args:
352
+ if "=" in arg_:
353
+ key, value = arg_.split("=")
354
+ elif key:
355
+ value = arg_
356
+ else:
357
+ msg = f"Invalid argument: {arg_}"
358
+ raise ValueError(msg)
359
+
360
+ value = ",".join(expand_values(value))
361
+ yield f"{key}={value}"
362
+
363
+
364
+ def collect(args: str | list[str]) -> list[str]:
365
+ """Collect a list of arguments into a list of strings.
366
+
367
+ Args:
368
+ args (list[str]): The arguments to collect.
369
+
370
+ Returns:
371
+ list[str]: A list of the collected arguments.
372
+
373
+ """
374
+ if isinstance(args, str):
375
+ args = re.split(r"\s+", args.strip())
376
+
377
+ args = [arg for arg in args if "=" in arg]
378
+
379
+ return [collect_arg(arg) for arg in args]
380
+
381
+
382
+ def expand(args: str | list[str]) -> list[list[str]]:
383
+ """Expand a list of arguments into a list of lists of strings.
384
+
385
+ Args:
386
+ args (list[str]): The arguments to expand.
387
+
388
+ Returns:
389
+ list[list[str]]: A list of the expanded arguments.
390
+
391
+ """
392
+ if isinstance(args, str):
393
+ args = re.split(r"\s+", args.strip())
394
+
395
+ args = [arg for arg in args if "=" in arg]
396
+
397
+ return [list(x) for x in product(*(expand_arg(arg) for arg in args))]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hydraflow
3
- Version: 0.7.5
3
+ Version: 0.9.0
4
4
  Summary: Hydraflow integrates Hydra and MLflow to manage and track machine learning experiments.
5
5
  Project-URL: Documentation, https://daizutabi.github.io/hydraflow/
6
6
  Project-URL: Source, https://github.com/daizutabi/hydraflow
@@ -41,6 +41,7 @@ Requires-Dist: mlflow>=2.15
41
41
  Requires-Dist: omegaconf
42
42
  Requires-Dist: rich
43
43
  Requires-Dist: typer
44
+ Requires-Dist: ulid
44
45
  Description-Content-Type: text/markdown
45
46
 
46
47
  # Hydraflow
@@ -93,31 +94,29 @@ pip install hydraflow
93
94
  Here is a simple example to get you started with Hydraflow:
94
95
 
95
96
  ```python
96
- import hydra
97
- import hydraflow
98
- import mlflow
97
+ from __future__ import annotations
98
+
99
99
  from dataclasses import dataclass
100
- from hydra.core.config_store import ConfigStore
101
100
  from pathlib import Path
101
+ from typing import TYPE_CHECKING
102
102
 
103
- @dataclass
104
- class MySQLConfig:
105
- host: str = "localhost"
106
- port: int = 3306
103
+ import hydraflow
107
104
 
108
- cs = ConfigStore.instance()
109
- cs.store(name="config", node=MySQLConfig)
105
+ if TYPE_CHECKING:
106
+ from mlflow.entities import Run
107
+
108
+
109
+ @dataclass
110
+ class Config:
111
+ count: int = 1
112
+ name: str = "a"
110
113
 
111
- @hydra.main(version_base=None, config_name="config")
112
- def my_app(cfg: MySQLConfig) -> None:
113
- # Set experiment by Hydra job name.
114
- hydraflow.set_experiment()
115
114
 
116
- # Automatically log Hydra config as params.
117
- with hydraflow.start_run(cfg):
118
- # Your app code below.
115
+ @hydraflow.main(Config)
116
+ def app(run: Run, cfg: Config):
117
+ """Your app code here."""
119
118
 
120
119
 
121
120
  if __name__ == "__main__":
122
- my_app()
121
+ app()
123
122
  ```
@@ -0,0 +1,24 @@
1
+ hydraflow/__init__.py,sha256=WnReG-E2paQSMLJm42N-KaQyHYsfA4OX-WWcst610PI,738
2
+ hydraflow/cli.py,sha256=gbDPj49azP8CCGxkxU0rksh1-gCyjP0VkVYH34ktcsA,1338
3
+ hydraflow/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ hydraflow/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ hydraflow/core/config.py,sha256=SJzjgsO_kzB78_whJ3lmy7GlZvTvwZONH1BJBn8zCuI,3817
6
+ hydraflow/core/context.py,sha256=QPyPg1xrTlmhviKNn-0nDY9bXcVky1zInqRqPN-VNhc,4741
7
+ hydraflow/core/io.py,sha256=T4ESiepEcqR-FZlo_m7VTBEFMwalrqPI8eFKPagvv3Q,4402
8
+ hydraflow/core/main.py,sha256=gYb1OOVH0CL4385Dm-06Mqi1Mr9-24URwLUiW86pGNs,5018
9
+ hydraflow/core/mlflow.py,sha256=M3MhiChnMzKnKRmjBl4h_SRGkAZKL7GAmFr3DdzwRuQ,5666
10
+ hydraflow/core/param.py,sha256=LHU9j9_7oA99igasoOyKofKClVr9FmGA3UABJ-KmyS0,4538
11
+ hydraflow/entities/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ hydraflow/entities/run_collection.py,sha256=BHbMvYB4WyG5qSNYpIzwmHySOtmGNb-jg2OrCaTPr2I,19651
13
+ hydraflow/entities/run_data.py,sha256=Y2_Lc-BdQ7nXhcEIjdHGHIkLrXsmAktOftESEwYOY8o,1602
14
+ hydraflow/entities/run_info.py,sha256=FRC6ICOlzB2u_xi_33Qs-YZLt677UotuNbYqI7XSmHY,1017
15
+ hydraflow/executor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ hydraflow/executor/conf.py,sha256=q_FrPXQJCVGKS1FYnGRGqTUgMQeMBkaVPW2mtQc8oxk,384
17
+ hydraflow/executor/io.py,sha256=4nafwge6vHanYFuEHxd0LRv_3ZLgMpV50qSbssZNe3Q,696
18
+ hydraflow/executor/job.py,sha256=Vp2IZOuIC25Gqo9A_5MkllWl1T1QBfUnI5ksMvKwakg,4479
19
+ hydraflow/executor/parser.py,sha256=y4C9wVdUnazJDxdWrT5y3yWFIo0zAGzO-cS9x1MTK_8,9486
20
+ hydraflow-0.9.0.dist-info/METADATA,sha256=p8-vmPIYV-uU9BJbSB1roqrhNO7nK2HRdrpj8Tip1AA,4559
21
+ hydraflow-0.9.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
+ hydraflow-0.9.0.dist-info/entry_points.txt,sha256=XI0khPbpCIUo9UPqkNEpgh-kqK3Jy8T7L2VCWOdkbSM,48
23
+ hydraflow-0.9.0.dist-info/licenses/LICENSE,sha256=IGdDrBPqz1O0v_UwCW-NJlbX9Hy9b3uJ11t28y2srmY,1062
24
+ hydraflow-0.9.0.dist-info/RECORD,,
hydraflow/main.py DELETED
@@ -1,54 +0,0 @@
1
- """main decorator."""
2
-
3
- from __future__ import annotations
4
-
5
- from functools import wraps
6
- from typing import TYPE_CHECKING, Any
7
-
8
- import hydra
9
- from hydra.core.config_store import ConfigStore
10
- from mlflow.entities import RunStatus
11
-
12
- import hydraflow
13
-
14
- if TYPE_CHECKING:
15
- from collections.abc import Callable
16
-
17
- from mlflow.entities import Run
18
-
19
- FINISHED = RunStatus.to_string(RunStatus.FINISHED)
20
-
21
-
22
- def main(
23
- node: Any,
24
- config_name: str = "config",
25
- *,
26
- chdir: bool = False,
27
- force_new_run: bool = False,
28
- skip_finished: bool = True,
29
- ):
30
- """Main decorator."""
31
-
32
- def decorator(app: Callable[[Run, Any], None]) -> Callable[[], None]:
33
- ConfigStore.instance().store(name=config_name, node=node)
34
-
35
- @wraps(app)
36
- @hydra.main(version_base=None, config_name=config_name)
37
- def inner_app(cfg: object) -> None:
38
- hydraflow.set_experiment()
39
-
40
- if force_new_run:
41
- run = None
42
- else:
43
- rc = hydraflow.search_runs()
44
- run = rc.try_get(cfg, override=True)
45
-
46
- if skip_finished and run and run.info.status == FINISHED:
47
- return
48
-
49
- with hydraflow.start_run(cfg, run=run, chdir=chdir) as run:
50
- app(run, cfg)
51
-
52
- return inner_app
53
-
54
- return decorator