hydraflow 0.12.4__tar.gz → 0.13.0__tar.gz

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 (98) hide show
  1. {hydraflow-0.12.4 → hydraflow-0.13.0}/.github/workflows/ci.yaml +1 -2
  2. {hydraflow-0.12.4 → hydraflow-0.13.0}/.github/workflows/docs.yaml +14 -10
  3. {hydraflow-0.12.4 → hydraflow-0.13.0}/.github/workflows/publish.yaml +0 -1
  4. {hydraflow-0.12.4 → hydraflow-0.13.0}/PKG-INFO +1 -1
  5. {hydraflow-0.12.4 → hydraflow-0.13.0}/pyproject.toml +2 -1
  6. hydraflow-0.13.0/src/hydraflow/cli.py +127 -0
  7. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/io.py +10 -25
  8. hydraflow-0.13.0/src/hydraflow/executor/job.py +222 -0
  9. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/conftest.py +2 -0
  10. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/hydraflow.yaml +20 -0
  11. hydraflow-0.13.0/tests/cli/submit.py +17 -0
  12. hydraflow-0.13.0/tests/cli/test_run.py +122 -0
  13. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/io/test_iter_dirs.py +21 -18
  14. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/test_mlflow.py +0 -7
  15. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/echo.py +2 -0
  16. hydraflow-0.13.0/tests/executor/read.py +15 -0
  17. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/test_job.py +38 -43
  18. hydraflow-0.12.4/src/hydraflow/cli.py +0 -70
  19. hydraflow-0.12.4/src/hydraflow/executor/job.py +0 -168
  20. hydraflow-0.12.4/tests/cli/test_run.py +0 -63
  21. {hydraflow-0.12.4 → hydraflow-0.13.0}/.devcontainer/devcontainer.json +0 -0
  22. {hydraflow-0.12.4 → hydraflow-0.13.0}/.devcontainer/postCreate.sh +0 -0
  23. {hydraflow-0.12.4 → hydraflow-0.13.0}/.devcontainer/starship.toml +0 -0
  24. {hydraflow-0.12.4 → hydraflow-0.13.0}/.gitattributes +0 -0
  25. {hydraflow-0.12.4 → hydraflow-0.13.0}/.gitignore +0 -0
  26. {hydraflow-0.12.4 → hydraflow-0.13.0}/LICENSE +0 -0
  27. {hydraflow-0.12.4 → hydraflow-0.13.0}/README.md +0 -0
  28. {hydraflow-0.12.4 → hydraflow-0.13.0}/apps/quickstart.py +0 -0
  29. {hydraflow-0.12.4 → hydraflow-0.13.0}/docs/index.md +0 -0
  30. {hydraflow-0.12.4 → hydraflow-0.13.0}/docs/usage/quickstart.md +0 -0
  31. {hydraflow-0.12.4 → hydraflow-0.13.0}/mkdocs.yaml +0 -0
  32. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/__init__.py +0 -0
  33. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/__init__.py +0 -0
  34. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/config.py +0 -0
  35. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/context.py +0 -0
  36. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/main.py +0 -0
  37. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/mlflow.py +0 -0
  38. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/core/param.py +0 -0
  39. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/entities/__init__.py +0 -0
  40. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/entities/run_collection.py +0 -0
  41. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/entities/run_data.py +0 -0
  42. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/entities/run_info.py +0 -0
  43. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/executor/__init__.py +0 -0
  44. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/executor/conf.py +0 -0
  45. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/executor/io.py +0 -0
  46. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/executor/parser.py +0 -0
  47. {hydraflow-0.12.4 → hydraflow-0.13.0}/src/hydraflow/py.typed +0 -0
  48. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/__init__.py +0 -0
  49. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/__init__.py +0 -0
  50. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/app.py +0 -0
  51. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/test_setup.py +0 -0
  52. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/test_show.py +0 -0
  53. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/cli/test_version.py +0 -0
  54. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/conftest.py +0 -0
  55. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/__init__.py +0 -0
  56. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/config/__init__.py +0 -0
  57. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/config/test_config.py +0 -0
  58. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/config/test_params.py +0 -0
  59. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/__init__.py +0 -0
  60. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/chdir.py +0 -0
  61. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/log_run.py +0 -0
  62. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/start_run.py +0 -0
  63. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/test_chdir.py +0 -0
  64. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/test_log_run.py +0 -0
  65. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/context/test_start_run.py +0 -0
  66. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/io/__init__.py +0 -0
  67. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/io/hydra_dir.py +0 -0
  68. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/io/test_hydra_dir.py +0 -0
  69. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/io/test_run.py +0 -0
  70. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/__init__.py +0 -0
  71. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/default.py +0 -0
  72. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/force_new_run.py +0 -0
  73. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/match_overrides.py +0 -0
  74. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/rerun_finished.py +0 -0
  75. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/skip_finished.py +0 -0
  76. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/test_default.py +0 -0
  77. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/test_force_new_run.py +0 -0
  78. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/test_match_overrides.py +0 -0
  79. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/test_rerun_finished.py +0 -0
  80. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/main/test_skip_finished.py +0 -0
  81. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/param/__init__.py +0 -0
  82. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/param/params.py +0 -0
  83. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/param/test_param.py +0 -0
  84. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/core/param/test_params.py +0 -0
  85. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/__init__.py +0 -0
  86. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/filter.py +0 -0
  87. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/test_collection.py +0 -0
  88. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/test_data.py +0 -0
  89. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/test_filter.py +0 -0
  90. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/test_info.py +0 -0
  91. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/test_values.py +0 -0
  92. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/entities/values.py +0 -0
  93. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/__init__.py +0 -0
  94. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/conftest.py +0 -0
  95. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/test_args.py +0 -0
  96. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/test_conf.py +0 -0
  97. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/test_io.py +0 -0
  98. {hydraflow-0.12.4 → hydraflow-0.13.0}/tests/executor/test_parser.py +0 -0
@@ -14,7 +14,7 @@ env:
14
14
  FORCE_COLOR: "1"
15
15
 
16
16
  jobs:
17
- run:
17
+ ci:
18
18
  name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
19
19
  runs-on: ${{ matrix.os }}
20
20
  strategy:
@@ -29,7 +29,6 @@ jobs:
29
29
  uses: actions/setup-python@v5
30
30
  with:
31
31
  python-version: ${{ matrix.python-version }}
32
- allow-prereleases: true
33
32
  - name: Install uv and ruff
34
33
  run: pip install uv ruff
35
34
  - name: Install the project
@@ -1,25 +1,29 @@
1
1
  name: Documentation
2
+
2
3
  on:
3
4
  push:
4
5
  branches: [main]
5
- tags: ["*"]
6
- permissions:
7
- contents: write
6
+ tags:
7
+ - "[0-9]+.[0-9]+.[0-9]+"
8
+
8
9
  jobs:
9
- deploy:
10
- name: Documentation
10
+ docs:
11
11
  runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: write
12
14
  steps:
13
15
  - uses: actions/checkout@v4
14
16
  - name: Configure Git Credentials
15
17
  run: |
16
18
  git config user.name github-actions[bot]
17
19
  git config user.email 41898282+github-actions[bot]@users.noreply.github.com
18
- - name: Set up Python 3.11
20
+ - name: Set up Python 3.13
19
21
  uses: actions/setup-python@v5
20
22
  with:
21
- python-version: 3.11
22
- - name: Install package
23
- run: pip install -e . mkapi markdown-exec[ansi]
23
+ python-version: 3.13
24
+ - name: Install uv
25
+ run: pip install uv
26
+ - name: Install the project
27
+ run: uv sync --group docs
24
28
  - name: Deploy documentation
25
- run: mkdocs gh-deploy --force
29
+ run: uv run mkdocs gh-deploy --force
@@ -7,7 +7,6 @@ on:
7
7
 
8
8
  jobs:
9
9
  publish:
10
- name: Publish
11
10
  runs-on: ubuntu-latest
12
11
  permissions:
13
12
  id-token: write
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hydraflow
3
- Version: 0.12.4
3
+ Version: 0.13.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hydraflow"
7
- version = "0.12.4"
7
+ version = "0.13.0"
8
8
  description = "Hydraflow integrates Hydra and MLflow to manage and track machine learning experiments."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -92,6 +92,7 @@ ignore = [
92
92
  "S603",
93
93
  "SIM102",
94
94
  "SIM108",
95
+ "SIM115",
95
96
  "TRY003",
96
97
  ]
97
98
 
@@ -0,0 +1,127 @@
1
+ """Hydraflow CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from typer import Argument, Option
11
+
12
+ app = typer.Typer(add_completion=False)
13
+ console = Console()
14
+
15
+
16
+ @app.command(context_settings={"ignore_unknown_options": True})
17
+ def run(
18
+ name: Annotated[str, Argument(help="Job name.", show_default=False)],
19
+ *,
20
+ args: Annotated[
21
+ list[str] | None,
22
+ Argument(help="Arguments to pass to the job.", show_default=False),
23
+ ] = None,
24
+ dry_run: Annotated[
25
+ bool,
26
+ Option("--dry-run", help="Perform a dry run."),
27
+ ] = False,
28
+ ) -> None:
29
+ """Run a job."""
30
+ from hydraflow.executor.io import get_job
31
+ from hydraflow.executor.job import iter_batches, iter_calls, iter_runs
32
+
33
+ args = args or []
34
+ job = get_job(name)
35
+
36
+ if job.run:
37
+ args = [*shlex.split(job.run), *args]
38
+ it = iter_runs(args, iter_batches(job), dry_run=dry_run)
39
+ elif job.call:
40
+ args = [*shlex.split(job.call), *args]
41
+ it = iter_calls(args, iter_batches(job), dry_run=dry_run)
42
+ else:
43
+ typer.echo(f"No command found in job: {job.name}.")
44
+ raise typer.Exit(1)
45
+
46
+ if not dry_run:
47
+ import mlflow
48
+
49
+ mlflow.set_experiment(job.name)
50
+
51
+ for task in it: # jobs will be executed here
52
+ if job.run and dry_run:
53
+ typer.echo(shlex.join(task.args))
54
+ elif job.call and dry_run:
55
+ funcname, *args = task.args
56
+ arg = ", ".join(f"{arg!r}" for arg in args)
57
+ typer.echo(f"{funcname}([{arg}])")
58
+
59
+
60
+ @app.command(context_settings={"ignore_unknown_options": True})
61
+ def submit(
62
+ name: Annotated[str, Argument(help="Job name.", show_default=False)],
63
+ *,
64
+ args: Annotated[
65
+ list[str] | None,
66
+ Argument(help="Arguments to pass to the job.", show_default=False),
67
+ ] = None,
68
+ dry_run: Annotated[
69
+ bool,
70
+ Option("--dry-run", help="Perform a dry run."),
71
+ ] = False,
72
+ ) -> None:
73
+ """Submit a job."""
74
+ from hydraflow.executor.io import get_job
75
+ from hydraflow.executor.job import iter_batches, submit
76
+
77
+ args = args or []
78
+ job = get_job(name)
79
+
80
+ if not job.run:
81
+ typer.echo(f"No run found in job: {job.name}.")
82
+ raise typer.Exit(1)
83
+
84
+ if not dry_run:
85
+ import mlflow
86
+
87
+ mlflow.set_experiment(job.name)
88
+
89
+ args = [*shlex.split(job.run), *args]
90
+ result = submit(args, iter_batches(job), dry_run=dry_run)
91
+
92
+ if dry_run and isinstance(result, tuple):
93
+ for line in result[1].splitlines():
94
+ args = shlex.split(line)
95
+ typer.echo(shlex.join([*result[0][:-1], *args]))
96
+
97
+
98
+ @app.command()
99
+ def show(
100
+ name: Annotated[str, Argument(help="Job name.", show_default=False)] = "",
101
+ ) -> None:
102
+ """Show the hydraflow config."""
103
+ from omegaconf import OmegaConf
104
+
105
+ from hydraflow.executor.io import get_job, load_config
106
+
107
+ if name:
108
+ cfg = get_job(name)
109
+ else:
110
+ cfg = load_config()
111
+
112
+ typer.echo(OmegaConf.to_yaml(cfg))
113
+
114
+
115
+ @app.callback(invoke_without_command=True)
116
+ def callback(
117
+ *,
118
+ version: Annotated[
119
+ bool,
120
+ Option("--version", help="Show the version and exit."),
121
+ ] = False,
122
+ ) -> None:
123
+ if version:
124
+ import importlib.metadata
125
+
126
+ typer.echo(f"hydraflow {importlib.metadata.version('hydraflow')}")
127
+ raise typer.Exit
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import fnmatch
5
6
  import shutil
6
7
  import urllib.parse
7
8
  import urllib.request
@@ -152,21 +153,6 @@ def remove_run(run: Run | Iterable[Run]) -> None:
152
153
  shutil.rmtree(get_artifact_dir(run).parent)
153
154
 
154
155
 
155
- def get_root_dir(uri: str | Path | None = None) -> Path:
156
- """Get the root directory for the MLflow tracking server."""
157
- import mlflow
158
-
159
- if uri is not None:
160
- return Path(uri).absolute()
161
-
162
- uri = mlflow.get_tracking_uri()
163
-
164
- if uri.startswith("file:"):
165
- return file_uri_to_path(uri)
166
-
167
- return Path(uri).absolute()
168
-
169
-
170
156
  def get_experiment_name(path: Path) -> str | None:
171
157
  """Get the experiment name from the meta file."""
172
158
  metafile = path / "meta.yaml"
@@ -195,50 +181,49 @@ def predicate_experiment_dir(
195
181
  return True
196
182
 
197
183
  if isinstance(experiment_names, list):
198
- return name in experiment_names
184
+ return any(fnmatch.fnmatch(name, e) for e in experiment_names)
199
185
 
200
186
  return experiment_names(name)
201
187
 
202
188
 
203
189
  def iter_experiment_dirs(
190
+ root_dir: str | Path,
204
191
  experiment_names: str | list[str] | Callable[[str], bool] | None = None,
205
- root_dir: str | Path | None = None,
206
192
  ) -> Iterator[Path]:
207
193
  """Iterate over the experiment directories in the root directory."""
208
194
  if isinstance(experiment_names, str):
209
195
  experiment_names = [experiment_names]
210
196
 
211
- root_dir = get_root_dir(root_dir)
212
- for path in root_dir.iterdir():
197
+ for path in Path(root_dir).iterdir():
213
198
  if predicate_experiment_dir(path, experiment_names):
214
199
  yield path
215
200
 
216
201
 
217
202
  def iter_run_dirs(
203
+ root_dir: str | Path,
218
204
  experiment_names: str | list[str] | Callable[[str], bool] | None = None,
219
- root_dir: str | Path | None = None,
220
205
  ) -> Iterator[Path]:
221
206
  """Iterate over the run directories in the root directory."""
222
- for experiment_dir in iter_experiment_dirs(experiment_names, root_dir):
207
+ for experiment_dir in iter_experiment_dirs(root_dir, experiment_names):
223
208
  for path in experiment_dir.iterdir():
224
209
  if path.is_dir() and (path / "artifacts").exists():
225
210
  yield path
226
211
 
227
212
 
228
213
  def iter_artifacts_dirs(
214
+ root_dir: str | Path,
229
215
  experiment_names: str | list[str] | Callable[[str], bool] | None = None,
230
- root_dir: str | Path | None = None,
231
216
  ) -> Iterator[Path]:
232
217
  """Iterate over the artifacts directories in the root directory."""
233
- for path in iter_run_dirs(experiment_names, root_dir):
218
+ for path in iter_run_dirs(root_dir, experiment_names):
234
219
  yield path / "artifacts"
235
220
 
236
221
 
237
222
  def iter_artifact_paths(
223
+ root_dir: str | Path,
238
224
  artifact_path: str | Path,
239
225
  experiment_names: str | list[str] | Callable[[str], bool] | None = None,
240
- root_dir: str | Path | None = None,
241
226
  ) -> Iterator[Path]:
242
227
  """Iterate over the artifact paths in the root directory."""
243
- for path in iter_artifacts_dirs(experiment_names, root_dir):
228
+ for path in iter_artifacts_dirs(root_dir, experiment_names):
244
229
  yield path / artifact_path
@@ -0,0 +1,222 @@
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 configurations 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
+ import sys
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from subprocess import CompletedProcess
27
+ from tempfile import NamedTemporaryFile
28
+ from typing import TYPE_CHECKING, overload
29
+
30
+ import ulid
31
+
32
+ from .parser import collect, expand
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable, Iterable, Iterator
36
+ from subprocess import CompletedProcess
37
+ from typing import Any
38
+
39
+ from .conf import Job
40
+
41
+
42
+ def iter_args(batch: str, args: str) -> Iterator[list[str]]:
43
+ """Iterate over combinations generated from parsed arguments.
44
+
45
+ Generate all possible combinations of arguments by parsing and
46
+ expanding each one, yielding them as an iterator.
47
+
48
+ Args:
49
+ batch (str): The batch to parse.
50
+ args (str): The arguments to parse.
51
+
52
+ Yields:
53
+ list[str]: a list of the parsed argument combinations.
54
+
55
+ """
56
+ args_ = collect(args)
57
+
58
+ for batch_ in expand(batch):
59
+ yield [*batch_, *args_]
60
+
61
+
62
+ def iter_batches(job: Job) -> Iterator[list[str]]:
63
+ """Generate Hydra application arguments for a job.
64
+
65
+ This function generates a list of Hydra application arguments
66
+ for a given job, including the job name and the root directory
67
+ for the sweep.
68
+
69
+ Args:
70
+ job (Job): The job to generate the Hydra configuration for.
71
+
72
+ Returns:
73
+ list[str]: A list of Hydra configuration strings.
74
+
75
+ """
76
+ job_name = f"hydra.job.name={job.name}"
77
+ job_configs = shlex.split(job.with_)
78
+
79
+ for step in job.steps:
80
+ configs = shlex.split(step.with_) or job_configs
81
+
82
+ for args in iter_args(step.batch, step.args):
83
+ sweep_dir = f"hydra.sweep.dir=multirun/{ulid.ULID()}"
84
+ yield ["--multirun", *args, job_name, sweep_dir, *configs]
85
+
86
+
87
+ @dataclass
88
+ class Task:
89
+ """An executed task."""
90
+
91
+ args: list[str]
92
+ total: int
93
+ completed: int
94
+
95
+
96
+ @dataclass
97
+ class Run(Task):
98
+ """An executed run."""
99
+
100
+ result: CompletedProcess
101
+
102
+
103
+ @dataclass
104
+ class Call(Task):
105
+ """An executed call."""
106
+
107
+ result: Any
108
+
109
+
110
+ @overload
111
+ def iter_runs(args: list[str], iterable: Iterable[list[str]]) -> Iterator[Run]: ...
112
+
113
+
114
+ @overload
115
+ def iter_runs(
116
+ args: list[str],
117
+ iterable: Iterable[list[str]],
118
+ *,
119
+ dry_run: bool = False,
120
+ ) -> Iterator[Task | Run]: ...
121
+
122
+
123
+ def iter_runs(
124
+ args: list[str],
125
+ iterable: Iterable[list[str]],
126
+ *,
127
+ dry_run: bool = False,
128
+ ) -> Iterator[Task | Run]:
129
+ """Execute multiple runs of a job using shell commands."""
130
+ executable, *args = args
131
+ if executable == "python" and sys.platform == "win32":
132
+ executable = sys.executable
133
+
134
+ iterable = list(iterable)
135
+ total = len(iterable)
136
+
137
+ for completed, args_ in enumerate(iterable, 1):
138
+ cmd = [executable, *args, *args_]
139
+ if dry_run:
140
+ yield Task(cmd, total, completed)
141
+ else:
142
+ result = subprocess.run(cmd, check=False)
143
+ yield Run(cmd, total, completed, result)
144
+
145
+
146
+ @overload
147
+ def iter_calls(args: list[str], iterable: Iterable[list[str]]) -> Iterator[Call]: ...
148
+
149
+
150
+ @overload
151
+ def iter_calls(
152
+ args: list[str],
153
+ iterable: Iterable[list[str]],
154
+ *,
155
+ dry_run: bool = False,
156
+ ) -> Iterator[Task | Call]: ...
157
+
158
+
159
+ def iter_calls(
160
+ args: list[str],
161
+ iterable: Iterable[list[str]],
162
+ *,
163
+ dry_run: bool = False,
164
+ ) -> Iterator[Task | Call]:
165
+ """Execute multiple calls of a job using Python functions."""
166
+ funcname, *args = args
167
+ func = get_callable(funcname)
168
+
169
+ iterable = list(iterable)
170
+ total = len(iterable)
171
+
172
+ for completed, args_ in enumerate(iterable, 1):
173
+ cmd = [funcname, *args, *args_]
174
+ if dry_run:
175
+ yield Task(cmd, total, completed)
176
+ else:
177
+ result = func([*args, *args_])
178
+ yield Call(cmd, total, completed, result)
179
+
180
+
181
+ def submit(
182
+ args: list[str],
183
+ iterable: Iterable[list[str]],
184
+ *,
185
+ dry_run: bool = False,
186
+ ) -> CompletedProcess | tuple[list[str], str]:
187
+ """Submit entire job using a shell command."""
188
+ executable, *args = args
189
+ if executable == "python" and sys.platform == "win32":
190
+ executable = sys.executable
191
+
192
+ temp = NamedTemporaryFile(dir=Path.cwd(), delete=False) # for Windows
193
+ file = Path(temp.name)
194
+ temp.close()
195
+
196
+ text = "\n".join(shlex.join(args) for args in iterable)
197
+ file.write_text(text)
198
+ cmd = [executable, *args, file.as_posix()]
199
+
200
+ try:
201
+ if dry_run:
202
+ return cmd, text
203
+ return subprocess.run(cmd, check=False)
204
+
205
+ finally:
206
+ file.unlink(missing_ok=True)
207
+
208
+
209
+ def get_callable(name: str) -> Callable:
210
+ """Get a callable from a function name."""
211
+ if "." not in name:
212
+ msg = f"Invalid function path: {name}."
213
+ raise ValueError(msg)
214
+
215
+ try:
216
+ module_name, func_name = name.rsplit(".", 1)
217
+ module = importlib.import_module(module_name)
218
+ return getattr(module, func_name)
219
+
220
+ except (ImportError, AttributeError, ModuleNotFoundError) as e:
221
+ msg = f"Failed to import or find function: {name}"
222
+ raise ValueError(msg) from e
@@ -10,3 +10,5 @@ def setup(chdir):
10
10
  copy(src, src.name)
11
11
  src = Path(__file__).parent / "app.py"
12
12
  copy(src, src.name)
13
+ src = Path(__file__).parent / "submit.py"
14
+ copy(src, src.name)
@@ -20,3 +20,23 @@ jobs:
20
20
  - batch: name=b
21
21
  args: count=11:14
22
22
  with: hydra/launcher=joblib hydra.launcher.n_jobs=4
23
+ echo:
24
+ call: typer.echo a b c
25
+ steps:
26
+ - batch: name=a,b
27
+ args: count=1:3
28
+ - batch: name=c,d
29
+ args: count=4:6
30
+ submit:
31
+ run: python submit.py
32
+ steps:
33
+ - batch: name=a,b
34
+ args: count=1
35
+ - batch: name=c
36
+ args: count=5
37
+ - batch: name=d
38
+ args: count=6
39
+ error:
40
+ steps:
41
+ - batch: name=a
42
+ args: count=1:3
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def main():
10
+ file = Path(sys.argv[-1])
11
+ for line in file.read_text().splitlines():
12
+ args = shlex.split(line)
13
+ subprocess.run([sys.executable, "app.py", *args], check=False)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()