hydraflow 0.12.3__tar.gz → 0.12.5__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 (93) hide show
  1. {hydraflow-0.12.3 → hydraflow-0.12.5}/.github/workflows/ci.yaml +1 -2
  2. {hydraflow-0.12.3 → hydraflow-0.12.5}/.github/workflows/docs.yaml +14 -10
  3. hydraflow-0.12.5/.github/workflows/publish.yaml +37 -0
  4. {hydraflow-0.12.3 → hydraflow-0.12.5}/PKG-INFO +1 -1
  5. {hydraflow-0.12.3 → hydraflow-0.12.5}/docs/index.md +9 -9
  6. {hydraflow-0.12.3 → hydraflow-0.12.5}/pyproject.toml +1 -1
  7. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/cli.py +26 -2
  8. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/io.py +37 -33
  9. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/executor/conf.py +1 -0
  10. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/executor/job.py +73 -48
  11. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/hydraflow.yaml +18 -0
  12. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/test_run.py +30 -0
  13. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/io/test_iter_dirs.py +32 -16
  14. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/test_mlflow.py +0 -7
  15. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/test_job.py +33 -33
  16. {hydraflow-0.12.3 → hydraflow-0.12.5}/.devcontainer/devcontainer.json +0 -0
  17. {hydraflow-0.12.3 → hydraflow-0.12.5}/.devcontainer/postCreate.sh +0 -0
  18. {hydraflow-0.12.3 → hydraflow-0.12.5}/.devcontainer/starship.toml +0 -0
  19. {hydraflow-0.12.3 → hydraflow-0.12.5}/.gitattributes +0 -0
  20. {hydraflow-0.12.3 → hydraflow-0.12.5}/.gitignore +0 -0
  21. {hydraflow-0.12.3 → hydraflow-0.12.5}/LICENSE +0 -0
  22. {hydraflow-0.12.3 → hydraflow-0.12.5}/README.md +0 -0
  23. {hydraflow-0.12.3 → hydraflow-0.12.5}/apps/quickstart.py +0 -0
  24. {hydraflow-0.12.3 → hydraflow-0.12.5}/docs/usage/quickstart.md +0 -0
  25. {hydraflow-0.12.3 → hydraflow-0.12.5}/mkdocs.yaml +0 -0
  26. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/__init__.py +0 -0
  27. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/__init__.py +0 -0
  28. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/config.py +0 -0
  29. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/context.py +0 -0
  30. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/main.py +0 -0
  31. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/mlflow.py +0 -0
  32. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/core/param.py +0 -0
  33. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/entities/__init__.py +0 -0
  34. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/entities/run_collection.py +0 -0
  35. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/entities/run_data.py +0 -0
  36. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/entities/run_info.py +0 -0
  37. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/executor/__init__.py +0 -0
  38. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/executor/io.py +0 -0
  39. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/executor/parser.py +0 -0
  40. {hydraflow-0.12.3 → hydraflow-0.12.5}/src/hydraflow/py.typed +0 -0
  41. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/__init__.py +0 -0
  42. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/__init__.py +0 -0
  43. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/app.py +0 -0
  44. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/conftest.py +0 -0
  45. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/test_setup.py +0 -0
  46. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/test_show.py +0 -0
  47. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/cli/test_version.py +0 -0
  48. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/conftest.py +0 -0
  49. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/__init__.py +0 -0
  50. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/config/__init__.py +0 -0
  51. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/config/test_config.py +0 -0
  52. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/config/test_params.py +0 -0
  53. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/__init__.py +0 -0
  54. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/chdir.py +0 -0
  55. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/log_run.py +0 -0
  56. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/start_run.py +0 -0
  57. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/test_chdir.py +0 -0
  58. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/test_log_run.py +0 -0
  59. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/context/test_start_run.py +0 -0
  60. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/io/__init__.py +0 -0
  61. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/io/hydra_dir.py +0 -0
  62. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/io/test_hydra_dir.py +0 -0
  63. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/io/test_run.py +0 -0
  64. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/__init__.py +0 -0
  65. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/default.py +0 -0
  66. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/force_new_run.py +0 -0
  67. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/match_overrides.py +0 -0
  68. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/rerun_finished.py +0 -0
  69. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/skip_finished.py +0 -0
  70. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/test_default.py +0 -0
  71. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/test_force_new_run.py +0 -0
  72. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/test_match_overrides.py +0 -0
  73. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/test_rerun_finished.py +0 -0
  74. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/main/test_skip_finished.py +0 -0
  75. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/param/__init__.py +0 -0
  76. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/param/params.py +0 -0
  77. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/param/test_param.py +0 -0
  78. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/core/param/test_params.py +0 -0
  79. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/__init__.py +0 -0
  80. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/filter.py +0 -0
  81. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/test_collection.py +0 -0
  82. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/test_data.py +0 -0
  83. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/test_filter.py +0 -0
  84. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/test_info.py +0 -0
  85. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/test_values.py +0 -0
  86. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/entities/values.py +0 -0
  87. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/__init__.py +0 -0
  88. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/conftest.py +0 -0
  89. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/echo.py +0 -0
  90. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/test_args.py +0 -0
  91. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/test_conf.py +0 -0
  92. {hydraflow-0.12.3 → hydraflow-0.12.5}/tests/executor/test_io.py +0 -0
  93. {hydraflow-0.12.3 → hydraflow-0.12.5}/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
@@ -0,0 +1,37 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "[0-9]+.[0-9]+.[0-9]+"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Verify version match
17
+ run: |
18
+ TAG=${GITHUB_REF#refs/tags/}
19
+ PYPROJECT_VERSION=$(grep -m 1 "version = " pyproject.toml | cut -d'"' -f2)
20
+ if [ "$TAG" != "$PYPROJECT_VERSION" ]; then
21
+ echo "Error: Git tag ($TAG) does not match pyproject.toml version ($PYPROJECT_VERSION)"
22
+ exit 1
23
+ fi
24
+ echo "Version verified: $TAG"
25
+
26
+ - name: Set up Python
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: 3.13
30
+ - name: Install uv
31
+ run: pip install uv
32
+ - name: Install the project
33
+ run: uv sync
34
+ - name: Build the project
35
+ run: uv build --no-sources
36
+ - name: Upload the project to PyPI
37
+ run: uv publish
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hydraflow
3
- Version: 0.12.3
3
+ Version: 0.12.5
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
@@ -1,10 +1,10 @@
1
- # Hydraflow Documentation
2
-
3
- Hydraflow integrates [Hydra](https://hydra.cc/) and [MLflow](https://mlflow.org/)
4
- to manage and track machine learning experiments.
5
-
6
- ## Installation
7
-
8
- ```bash
9
- pip install hydraflow
1
+ # Hydraflow Documentation
2
+
3
+ Hydraflow integrates [Hydra](https://hydra.cc/) and [MLflow](https://mlflow.org/)
4
+ to manage and track machine learning experiments.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install hydraflow
10
10
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hydraflow"
7
- version = "0.12.3"
7
+ version = "0.12.5"
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" }
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import shlex
5
6
  from typing import Annotated
6
7
 
7
8
  import typer
@@ -24,7 +25,13 @@ def run(
24
25
  """Run a job."""
25
26
 
26
27
  from hydraflow.executor.io import get_job
27
- from hydraflow.executor.job import multirun, to_text
28
+ from hydraflow.executor.job import (
29
+ iter_batches,
30
+ iter_calls,
31
+ iter_runs,
32
+ submit,
33
+ to_text,
34
+ )
28
35
 
29
36
  job = get_job(name)
30
37
 
@@ -35,7 +42,24 @@ def run(
35
42
  import mlflow
36
43
 
37
44
  mlflow.set_experiment(job.name)
38
- multirun(job)
45
+
46
+ if job.submit:
47
+ funcname, *args = shlex.split(job.submit)
48
+ submit(funcname, args, iter_batches(job))
49
+ raise typer.Exit
50
+
51
+ if job.run:
52
+ executable, *args = shlex.split(job.run)
53
+ it = iter_runs(executable, args, iter_batches(job))
54
+ elif job.call:
55
+ funcname, *args = shlex.split(job.call)
56
+ it = iter_calls(funcname, args, iter_batches(job))
57
+ else:
58
+ typer.echo(f"No command found in job: {job.name}.")
59
+ raise typer.Exit(1)
60
+
61
+ for _ in it:
62
+ pass
39
63
 
40
64
 
41
65
  @app.command()
@@ -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
@@ -12,7 +13,7 @@ from hydra.core.hydra_config import HydraConfig
12
13
  from omegaconf import DictConfig, ListConfig, OmegaConf
13
14
 
14
15
  if TYPE_CHECKING:
15
- from collections.abc import Iterable, Iterator
16
+ from collections.abc import Callable, Iterable, Iterator
16
17
 
17
18
  from mlflow.entities import Run
18
19
 
@@ -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"
@@ -179,47 +165,65 @@ def get_experiment_name(path: Path) -> str | None:
179
165
  return None
180
166
 
181
167
 
168
+ def predicate_experiment_dir(
169
+ path: Path,
170
+ experiment_names: list[str] | Callable[[str], bool] | None = None,
171
+ ) -> bool:
172
+ """Predicate an experiment directory based on the path and experiment names."""
173
+ if not path.is_dir() or path.name in [".trash", "0"]:
174
+ return False
175
+
176
+ name = get_experiment_name(path)
177
+ if not name:
178
+ return False
179
+
180
+ if experiment_names is None:
181
+ return True
182
+
183
+ if isinstance(experiment_names, list):
184
+ return any(fnmatch.fnmatch(name, e) for e in experiment_names)
185
+
186
+ return experiment_names(name)
187
+
188
+
182
189
  def iter_experiment_dirs(
183
- experiment_names: str | list[str] | None = None,
184
- root_dir: str | Path | None = None,
190
+ root_dir: str | Path,
191
+ experiment_names: str | list[str] | Callable[[str], bool] | None = None,
185
192
  ) -> Iterator[Path]:
186
193
  """Iterate over the experiment directories in the root directory."""
187
194
  if isinstance(experiment_names, str):
188
195
  experiment_names = [experiment_names]
189
196
 
190
- root_dir = get_root_dir(root_dir)
191
- for path in root_dir.iterdir():
192
- if path.is_dir() and path.name not in [".trash", "0"]:
193
- if name := get_experiment_name(path):
194
- if experiment_names is None or name in experiment_names:
195
- yield path
197
+ for path in Path(root_dir).iterdir():
198
+ if predicate_experiment_dir(path, experiment_names):
199
+ yield path
196
200
 
197
201
 
198
202
  def iter_run_dirs(
199
- experiment_names: str | list[str] | None = None,
200
- root_dir: str | Path | None = None,
203
+ root_dir: str | Path,
204
+ experiment_names: str | list[str] | Callable[[str], bool] | None = None,
201
205
  ) -> Iterator[Path]:
202
206
  """Iterate over the run directories in the root directory."""
203
- for experiment_dir in iter_experiment_dirs(experiment_names, root_dir):
207
+ for experiment_dir in iter_experiment_dirs(root_dir, experiment_names):
204
208
  for path in experiment_dir.iterdir():
205
209
  if path.is_dir() and (path / "artifacts").exists():
206
210
  yield path
207
211
 
208
212
 
209
213
  def iter_artifacts_dirs(
210
- experiment_names: str | list[str] | None = None,
211
- root_dir: str | Path | None = None,
214
+ root_dir: str | Path,
215
+ experiment_names: str | list[str] | Callable[[str], bool] | None = None,
212
216
  ) -> Iterator[Path]:
213
217
  """Iterate over the artifacts directories in the root directory."""
214
- for path in iter_run_dirs(experiment_names, root_dir):
218
+ for path in iter_run_dirs(root_dir, experiment_names):
215
219
  yield path / "artifacts"
216
220
 
217
221
 
218
222
  def iter_artifact_paths(
223
+ root_dir: str | Path,
219
224
  artifact_path: str | Path,
220
- experiment_names: str | list[str] | None = None,
221
- root_dir: str | Path | None = None,
225
+ experiment_names: str | list[str] | Callable[[str], bool] | None = None,
222
226
  ) -> Iterator[Path]:
223
227
  """Iterate over the artifact paths in the root directory."""
224
- for path in iter_artifacts_dirs(experiment_names, root_dir):
228
+ for path in iter_artifacts_dirs(root_dir, experiment_names):
225
229
  yield path / artifact_path
@@ -15,6 +15,7 @@ class Job:
15
15
  name: str = ""
16
16
  run: str = ""
17
17
  call: str = ""
18
+ submit: str = ""
18
19
  with_: str = ""
19
20
  steps: list[Step] = field(default_factory=list)
20
21
 
@@ -21,7 +21,7 @@ import importlib
21
21
  import shlex
22
22
  import subprocess
23
23
  import sys
24
- from subprocess import CalledProcessError
24
+ from dataclasses import dataclass
25
25
  from typing import TYPE_CHECKING
26
26
 
27
27
  import ulid
@@ -29,7 +29,9 @@ import ulid
29
29
  from .parser import collect, expand
30
30
 
31
31
  if TYPE_CHECKING:
32
- from collections.abc import Iterator
32
+ from collections.abc import Callable, Iterable, Iterator
33
+ from subprocess import CompletedProcess
34
+ from typing import Any
33
35
 
34
36
  from .conf import Job
35
37
 
@@ -79,63 +81,81 @@ def iter_batches(job: Job) -> Iterator[list[str]]:
79
81
  yield ["--multirun", *args, job_name, sweep_dir, *configs]
80
82
 
81
83
 
82
- def multirun(job: Job) -> None:
83
- """Execute multiple runs of a job using either shell commands or Python functions.
84
+ @dataclass
85
+ class Run:
86
+ """An executed run."""
84
87
 
85
- This function processes a job configuration and executes it in one of two modes:
88
+ total: int
89
+ completed: int
90
+ result: CompletedProcess
86
91
 
87
- 1. Shell command mode (job.run): Executes shell commands with the generated
88
- arguments
89
- 2. Python function mode (job.call): Calls a Python function with the generated
90
- arguments
91
92
 
92
- Args:
93
- job (Job): The job configuration containing run parameters and steps.
93
+ @dataclass
94
+ class Call:
95
+ """An executed call."""
94
96
 
95
- Raises:
96
- RuntimeError: If a shell command fails or if a function call encounters
97
- an error.
98
- ValueError: If the Python function path is invalid or the function cannot
99
- be imported.
97
+ total: int
98
+ completed: int
99
+ result: Any
100
100
 
101
- """
102
- it = iter_batches(job)
103
101
 
104
- if job.run:
105
- base_cmds = shlex.split(job.run)
106
- if base_cmds[0] == "python" and sys.platform == "win32":
107
- base_cmds[0] = sys.executable
102
+ def iter_runs(
103
+ executable: str,
104
+ args: list[str],
105
+ iterable: Iterable[list[str]],
106
+ ) -> Iterator[Run]:
107
+ """Execute multiple runs of a job using shell commands."""
108
+ if executable == "python" and sys.platform == "win32":
109
+ executable = sys.executable
108
110
 
109
- for args in it:
110
- cmds = [*base_cmds, *args]
111
- try:
112
- subprocess.run(cmds, check=True)
113
- except CalledProcessError as e:
114
- msg = f"Command failed with exit code {e.returncode}"
115
- raise RuntimeError(msg) from e
111
+ iterable = list(iterable)
112
+ total = len(iterable)
116
113
 
117
- elif job.call:
118
- call_name, *base_args = shlex.split(job.call)
114
+ for completed, args_ in enumerate(iterable, 1):
115
+ result = subprocess.run([executable, *args, *args_], check=False)
116
+ yield Run(total, completed, result)
119
117
 
120
- if "." not in call_name:
121
- msg = f"Invalid function path: {call_name}."
122
- msg += " Expected format: 'package.module.function'"
123
- raise ValueError(msg)
124
118
 
125
- try:
126
- module_name, func_name = call_name.rsplit(".", 1)
127
- module = importlib.import_module(module_name)
128
- func = getattr(module, func_name)
129
- except (ImportError, AttributeError, ModuleNotFoundError) as e:
130
- msg = f"Failed to import or find function: {call_name}"
131
- raise ValueError(msg) from e
119
+ def iter_calls(
120
+ funcname: str,
121
+ args: list[str],
122
+ iterable: Iterable[list[str]],
123
+ ) -> Iterator[Call]:
124
+ """Execute multiple calls of a job using Python functions."""
125
+ func = get_callable(funcname)
132
126
 
133
- for args in it:
134
- try:
135
- func([*base_args, *args])
136
- except Exception as e: # noqa: PERF203
137
- msg = f"Function call '{job.call}' failed with args: {args}"
138
- raise RuntimeError(msg) from e
127
+ iterable = list(iterable)
128
+ total = len(iterable)
129
+
130
+ for completed, args_ in enumerate(iterable, 1):
131
+ result = func([*args, *args_])
132
+ yield Call(total, completed, result)
133
+
134
+
135
+ def submit(
136
+ funcname: str,
137
+ args: list[str],
138
+ iterable: Iterable[list[str]],
139
+ ) -> Any:
140
+ """Submit entire job using Python functions."""
141
+ func = get_callable(funcname)
142
+ return func([[*args, *a] for a in iterable])
143
+
144
+
145
+ def get_callable(name: str) -> Callable:
146
+ """Get a callable from a function name."""
147
+ if "." not in name:
148
+ msg = f"Invalid function path: {name}."
149
+ raise ValueError(msg)
150
+
151
+ try:
152
+ module_name, func_name = name.rsplit(".", 1)
153
+ module = importlib.import_module(module_name)
154
+ return getattr(module, func_name)
155
+
156
+ except (ImportError, AttributeError, ModuleNotFoundError) as e:
157
+ msg = f"Failed to import or find function: {name}"
158
+ raise ValueError(msg) from e
139
159
 
140
160
 
141
161
  def to_text(job: Job) -> str:
@@ -165,4 +185,9 @@ def to_text(job: Job) -> str:
165
185
  for args in it:
166
186
  text += f"args: {args}\n"
167
187
 
188
+ elif job.submit:
189
+ text = f"submit: {job.submit}\n"
190
+ for args in it:
191
+ text += f"args: {args}\n"
192
+
168
193
  return text.rstrip()
@@ -20,3 +20,21 @@ 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
+ submit: typer.echo
32
+ steps:
33
+ - batch: name=a
34
+ args: count=1,3
35
+ - batch: name=b
36
+ args: count=5,7
37
+ error:
38
+ steps:
39
+ - batch: name=a
40
+ args: count=1:3
@@ -61,3 +61,33 @@ def test_run_parallel():
61
61
  assert result.exit_code == 0
62
62
  run_ids = hydraflow.list_run_ids("parallel")
63
63
  assert len(run_ids) == 8
64
+
65
+
66
+ @pytest.mark.xdist_group(name="group4")
67
+ def test_run_echo():
68
+ result = runner.invoke(app, ["run", "echo"])
69
+ assert result.exit_code == 0
70
+ out = result.stdout
71
+ lines = out.splitlines()
72
+ assert len(lines) == 5
73
+ assert "['a', 'b', 'c', '--multirun', 'name=a', 'count=1,2,3'" in lines[1]
74
+ assert "['a', 'b', 'c', '--multirun', 'name=b', 'count=1,2,3'" in lines[2]
75
+ assert "['a', 'b', 'c', '--multirun', 'name=c', 'count=4,5,6'" in lines[3]
76
+ assert "['a', 'b', 'c', '--multirun', 'name=d', 'count=4,5,6'" in lines[4]
77
+
78
+
79
+ @pytest.mark.xdist_group(name="group4")
80
+ def test_run_submit():
81
+ result = runner.invoke(app, ["run", "submit"])
82
+ assert result.exit_code == 0
83
+ out = result.stdout
84
+ lines = out.splitlines()
85
+ assert len(lines) == 2
86
+ assert "[['--multirun', 'name=a', 'count=1,3', 'hydra.job.name=submit'" in lines[1]
87
+
88
+
89
+ @pytest.mark.xdist_group(name="group5")
90
+ def test_run_error():
91
+ result = runner.invoke(app, ["run", "error"])
92
+ assert result.exit_code == 1
93
+ assert "No command found in job: error." in result.stdout
@@ -27,28 +27,44 @@ def setup(chdir):
27
27
  mlflow.log_text("5", "text.txt")
28
28
 
29
29
 
30
- def test_root_dir(root_dir: Path):
31
- from hydraflow.core.io import get_root_dir
30
+ def test_iter_experiment_dirs(root_dir: Path):
31
+ from hydraflow.core.io import get_experiment_name, iter_experiment_dirs
32
32
 
33
- assert get_root_dir(root_dir) == root_dir
34
- assert get_root_dir(root_dir.name) == root_dir
35
- assert get_root_dir() == root_dir
33
+ names = [get_experiment_name(p) for p in iter_experiment_dirs(root_dir)]
34
+ assert sorted(names) == ["e1", "e2"] # type: ignore
36
35
 
37
36
 
38
- def test_iter_experiment_dirs():
37
+ @pytest.mark.parametrize(
38
+ ("e", "es"),
39
+ [("e1", ["e1"]), ("e*", ["e1", "e2"]), ("*", ["e1", "e2"]), ("*2", ["e2"])],
40
+ )
41
+ def test_iter_experiment_dirs_glob(root_dir: Path, e, es):
39
42
  from hydraflow.core.io import get_experiment_name, iter_experiment_dirs
40
43
 
41
- names = [get_experiment_name(p) for p in iter_experiment_dirs()]
42
- assert sorted(names) == ["e1", "e2"] # type: ignore
44
+ names = [get_experiment_name(p) for p in iter_experiment_dirs(root_dir, e)]
45
+ assert sorted(names) == es # type: ignore
43
46
 
44
47
 
45
- def test_iter_experiment_dirs_filter():
48
+ def test_iter_experiment_dirs_filter(root_dir: Path):
46
49
  from hydraflow.core.io import get_experiment_name, iter_experiment_dirs
47
50
 
48
- it = iter_experiment_dirs(experiment_names="e1")
51
+ it = iter_experiment_dirs(root_dir, experiment_names="e1")
49
52
  assert [get_experiment_name(p) for p in it] == ["e1"]
50
53
 
51
54
 
55
+ def test_iter_experiment_dirs_filter_callable(root_dir: Path):
56
+ from hydraflow.core.io import get_experiment_name, iter_experiment_dirs
57
+
58
+ it = iter_experiment_dirs(root_dir, experiment_names=lambda name: name == "e2")
59
+ assert [get_experiment_name(p) for p in it] == ["e2"]
60
+
61
+
62
+ def test_predicate_experiment_dir():
63
+ from hydraflow.core.io import predicate_experiment_dir
64
+
65
+ assert predicate_experiment_dir(Path()) is False
66
+
67
+
52
68
  def test_get_experiment_name_none(root_dir: Path):
53
69
  from hydraflow.core.io import get_experiment_name
54
70
 
@@ -62,21 +78,21 @@ def test_get_experiment_name_metafile_none(root_dir: Path):
62
78
  assert get_experiment_name(root_dir) is None
63
79
 
64
80
 
65
- def test_iter_run_dirs():
81
+ def test_iter_run_dirs(root_dir: Path):
66
82
  from hydraflow.core.io import iter_run_dirs
67
83
 
68
- assert len(list(iter_run_dirs())) == 5
84
+ assert len(list(iter_run_dirs(root_dir))) == 5
69
85
 
70
86
 
71
- def test_iter_artifacts_dirs():
87
+ def test_iter_artifacts_dirs(root_dir: Path):
72
88
  from hydraflow.core.io import iter_artifacts_dirs
73
89
 
74
- assert len(list(iter_artifacts_dirs())) == 5
90
+ assert len(list(iter_artifacts_dirs(root_dir))) == 5
75
91
 
76
92
 
77
- def test_iter_artifact_paths():
93
+ def test_iter_artifact_paths(root_dir: Path):
78
94
  from hydraflow.core.io import iter_artifact_paths
79
95
 
80
- it = iter_artifact_paths("text.txt")
96
+ it = iter_artifact_paths(root_dir, "text.txt")
81
97
  text = sorted("".join(p.read_text() for p in it))
82
98
  assert text == ["1", "2", "3", "4", "5"]
@@ -81,10 +81,3 @@ def test_list_run_paths(experiment: Experiment):
81
81
 
82
82
  dirs = list_run_paths(experiment.name, "artifacts")
83
83
  assert all(d.is_dir() for d in dirs)
84
-
85
-
86
- @pytest.mark.parametrize("uri", [None, "test_mlflow"])
87
- def test_root_dir(experiment, uri):
88
- from hydraflow.core.io import get_root_dir
89
-
90
- assert get_root_dir(uri) == Path.cwd() / "test_mlflow"
@@ -1,4 +1,3 @@
1
- import sys
2
1
  from pathlib import Path
3
2
 
4
3
  import pytest
@@ -60,66 +59,67 @@ def test_sweep_args(batches, i, x):
60
59
  assert batches[i][-3] == x
61
60
 
62
61
 
63
- def test_multirun_run(job: Job, tmp_path: Path):
64
- from hydraflow.executor.job import multirun
62
+ def test_iter_runs(job: Job, tmp_path: Path):
63
+ from hydraflow.executor.job import iter_batches, iter_runs
65
64
 
66
65
  path = tmp_path / "output.txt"
67
66
  file = Path(__file__).parent / "echo.py"
68
67
 
69
- job.run = f"python {file.as_posix()} {path.as_posix()}"
70
- multirun(job)
68
+ args = [file.as_posix(), path.as_posix()]
69
+ x = list(iter_runs("python", args, iter_batches(job)))
71
70
  assert path.read_text() == "b=5 a=1,2 b=6 a=1,2 c=7 a=3,4 c=8 a=3,4"
71
+ assert x[0].completed == 1
72
+ assert x[0].result.returncode == 0
73
+ assert x[1].completed == 2
74
+ assert x[1].result.returncode == 0
75
+ assert x[2].completed == 3
76
+ assert x[2].result.returncode == 0
72
77
 
73
78
 
74
- def test_multirun_run_error(job: Job):
75
- from hydraflow.executor.job import multirun
79
+ def test_iter_calls(job: Job, capsys: pytest.CaptureFixture):
80
+ from hydraflow.executor.job import iter_batches, iter_calls
76
81
 
77
- job.run = "cmd /c exit 1" if sys.platform == "win32" else "false"
78
- with pytest.raises(RuntimeError):
79
- multirun(job)
80
-
81
-
82
- def test_multirun_call(job: Job, capsys: pytest.CaptureFixture):
83
- from hydraflow.executor.job import multirun
84
-
85
- job.call = "typer.echo"
86
- multirun(job)
82
+ x = list(iter_calls("typer.echo", [], iter_batches(job)))
87
83
  out, _ = capsys.readouterr()
88
84
  assert "'b=5', 'a=1,2'" in out
89
85
  assert "'c=8', 'a=3,4'" in out
86
+ assert x[0].completed == 1
87
+ assert x[1].completed == 2
88
+ assert x[2].completed == 3
90
89
 
91
90
 
92
- def test_multirun_call_args(job: Job, capsys: pytest.CaptureFixture):
93
- from hydraflow.executor.job import multirun
91
+ def test_iter_calls_args(job: Job, capsys: pytest.CaptureFixture):
92
+ from hydraflow.executor.job import iter_batches, iter_calls
94
93
 
95
94
  job.call = "typer.echo a 'b c'"
96
- multirun(job)
95
+ list(iter_calls("typer.echo", ["a", "b c"], iter_batches(job)))
97
96
  out, _ = capsys.readouterr()
98
97
  assert "['a', 'b c', '--multirun'," in out
99
98
 
100
99
 
101
- def test_multirun_call_error(job: Job):
102
- from hydraflow.executor.job import multirun
100
+ def test_submit(job: Job, capsys: pytest.CaptureFixture):
101
+ from hydraflow.executor.job import iter_batches, submit
103
102
 
104
- job.call = "hydraflow.executor.job.multirun"
105
- with pytest.raises(RuntimeError):
106
- multirun(job)
103
+ submit("typer.echo", ["a"], iter_batches(job))
104
+ out, _ = capsys.readouterr()
105
+ assert out.startswith("[['a', '--multirun', 'b=5', 'a=1,2', 'hydra.job.name=test'")
106
+ assert "], ['a', '--multirun', 'b=6', 'a=1,2', 'hydra" in out
107
+ assert "], ['a', '--multirun', 'c=7', 'a=3,4', 'hydra" in out
108
+ assert "], ['a', '--multirun', 'c=8', 'a=3,4', 'hydra" in out
107
109
 
108
110
 
109
- def test_multirun_call_invalid(job: Job):
110
- from hydraflow.executor.job import multirun
111
+ def test_get_callable_error():
112
+ from hydraflow.executor.job import get_callable
111
113
 
112
- job.call = "print"
113
114
  with pytest.raises(ValueError):
114
- multirun(job)
115
+ get_callable("print")
115
116
 
116
117
 
117
- def test_multirun_call_not_found(job: Job):
118
- from hydraflow.executor.job import multirun
118
+ def test_get_callable_not_found():
119
+ from hydraflow.executor.job import get_callable
119
120
 
120
- job.call = "hydraflow.invalid"
121
121
  with pytest.raises(ValueError):
122
- multirun(job)
122
+ get_callable("hydraflow.invalid")
123
123
 
124
124
 
125
125
  def test_to_text(job: Job):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes