parameterrun 0.1.0__tar.gz → 0.2.1__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.
@@ -1,9 +1,16 @@
1
1
  name: CI
2
2
 
3
3
  on:
4
- push:
5
- branches: [ main ]
6
4
  pull_request:
5
+ branches: [ main ]
6
+ # push:
7
+ # branches: [ main ]
8
+ # Optional only if you use GitHub merge queue:
9
+ # merge_group:
10
+ # types: [checks_requested]
11
+
12
+ permissions:
13
+ contents: read
7
14
 
8
15
  jobs:
9
16
  test:
@@ -16,7 +23,7 @@ jobs:
16
23
  python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ]
17
24
 
18
25
  steps:
19
- - uses: actions/checkout@v4
26
+ - uses: actions/checkout@v6
20
27
 
21
28
  - uses: actions/setup-python@v5
22
29
  with:
@@ -31,33 +38,11 @@ jobs:
31
38
  - name: Lint
32
39
  run: uv run ruff check .
33
40
 
34
- # - name: Format check
35
- # run: uv run ruff format --check .
41
+ # - name: Format check
42
+ # run: uv run ruff format --check .
36
43
 
37
- # - name: Type check
38
- # run: uv run mypy
44
+ # - name: Type check
45
+ # run: uv run mypy
39
46
 
40
47
  - name: Tests
41
- run: uv run pytest
42
-
43
- build:
44
- name: Build package
45
- runs-on: ubuntu-latest
46
- steps:
47
- - uses: actions/checkout@v4
48
-
49
- - uses: actions/setup-python@v5
50
- with:
51
- python-version: "3.9"
52
-
53
- - name: Install uv
54
- run: python -m pip install uv
55
-
56
- - name: Sync environment
57
- run: uv sync
58
-
59
- - name: Build
60
- run: uv run python -m build
61
-
62
- - name: Check distributions
63
- run: uv run twine check --strict dist/*
48
+ run: uv run pytest
@@ -1,18 +1,23 @@
1
1
  name: Publish
2
2
 
3
3
  on:
4
- push:
5
- tags:
6
- - "v0.1.0"
4
+ workflow_run:
5
+ workflows: ["Release"]
6
+ types: [completed]
7
7
 
8
8
  jobs:
9
- build:
9
+ publish:
10
+ if: ${{ github.event.workflow_run.conclusion == 'success' }}
10
11
  runs-on: ubuntu-latest
12
+ environment: pypi
11
13
  permissions:
12
14
  contents: read
15
+ id-token: write
13
16
 
14
17
  steps:
15
- - uses: actions/checkout@v4
18
+ - uses: actions/checkout@v6
19
+ with:
20
+ ref: ${{ github.event.workflow_run.head_sha }}
16
21
 
17
22
  - uses: actions/setup-python@v5
18
23
  with:
@@ -30,21 +35,5 @@ jobs:
30
35
  - name: Check distributions
31
36
  run: uv run twine check --strict dist/*
32
37
 
33
- - uses: actions/upload-artifact@v4
34
- with:
35
- name: dist
36
- path: dist/
37
-
38
- publish:
39
- needs: build
40
- runs-on: ubuntu-latest
41
- permissions:
42
- id-token: write
43
-
44
- steps:
45
- - uses: actions/download-artifact@v4
46
- with:
47
- name: dist
48
- path: dist/
49
-
50
- - uses: pypa/gh-action-pypi-publish@release/v1
38
+ - name: Publish to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,88 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+
7
+ permissions:
8
+ contents: write
9
+
10
+ concurrency:
11
+ group: release-main
12
+ cancel-in-progress: false
13
+
14
+ jobs:
15
+ release:
16
+ runs-on: ubuntu-latest
17
+
18
+ steps:
19
+ - uses: actions/checkout@v6
20
+ with:
21
+ ref: ${{ github.sha }}
22
+ fetch-depth: 0
23
+
24
+ - name: Read version from package
25
+ id: version
26
+ run: |
27
+ python - <<'PY'
28
+ import os
29
+ import pathlib
30
+ import re
31
+
32
+ init_file = pathlib.Path("src/parameterrun/__init__.py")
33
+ content = init_file.read_text(encoding="utf-8")
34
+ match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
35
+ if not match:
36
+ raise SystemExit("Could not find __version__ in src/parameterrun/__init__.py")
37
+
38
+ version = match.group(1)
39
+ output_file = os.environ["GITHUB_OUTPUT"]
40
+ with open(output_file, "a", encoding="utf-8") as f:
41
+ f.write(f"version={version}\n")
42
+ f.write(f"tag=v{version}\n")
43
+ PY
44
+
45
+ - name: Fail if tag already exists
46
+ env:
47
+ TAG: ${{ steps.version.outputs.tag }}
48
+ run: |
49
+ git fetch --tags origin
50
+ if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
51
+ echo "Tag ${TAG} already exists. Bump __version__ before merging to main."
52
+ exit 1
53
+ fi
54
+
55
+ - name: Extract release notes from changelog
56
+ env:
57
+ VERSION: ${{ steps.version.outputs.version }}
58
+ run: |
59
+ python - <<'PY'
60
+ import os
61
+ import pathlib
62
+ import re
63
+
64
+ version = os.environ["VERSION"]
65
+ changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
66
+
67
+ pattern = re.compile(
68
+ rf"^## \[{re.escape(version)}\][^\n]*\n(?P<body>.*?)(?=^## \[|\Z)",
69
+ re.MULTILINE | re.DOTALL,
70
+ )
71
+ match = pattern.search(changelog)
72
+ if not match:
73
+ raise SystemExit(f"Could not find CHANGELOG entry for version {version}")
74
+
75
+ notes = match.group("body").strip()
76
+ pathlib.Path("release_notes.md").write_text(notes + "\n", encoding="utf-8")
77
+ PY
78
+
79
+ - name: Create GitHub release and tag
80
+ env:
81
+ GH_TOKEN: ${{ github.token }}
82
+ TAG: ${{ steps.version.outputs.tag }}
83
+ SHA: ${{ github.sha }}
84
+ run: |
85
+ gh release create "${TAG}" \
86
+ --target "${SHA}" \
87
+ --title "${TAG}" \
88
+ --notes-file release_notes.md
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on Keep a Changelog and the project follows Semantic Versioning.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] - 2026-04-01
10
+
11
+ ### Added
12
+
13
+ - Modified GitHub Actions workflow to trigger on pull requests to the main branch, ensuring that all changes are
14
+ reviewed before merging.
15
+
16
+ ## [0.2.0] - 2026-03-26
17
+
18
+ ### Added
19
+
20
+ - Automated version management: single source of truth in `src/parameterrun/__init__.py`
21
+ - Version bump script `scripts/bump_version.py` for automatic version updates across all files
22
+ - VERSIONING.md guide for release workflows and version management
23
+ - `__version__` exported from main package: `from parameterrun import __version__`
24
+ - Dynamic version extraction in `pyproject.toml` (no more manual version updates needed)
25
+
26
+ ### Changed
27
+
28
+ - `parameterrun(..., reshape=True)` now reshapes outputs to the parameter-grid dimensions automatically (including
29
+ multi-output functions).
30
+ - Added early validation for duplicate parameter names, unknown swept parameters, unknown kwargs, and kwarg/sweep-name
31
+ conflicts.
32
+
33
+ ## [0.1.0] - 2026-03-25
34
+
35
+ ### Added
36
+
37
+ - Initial public release of `parameterrun`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parameterrun
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Parallel parameter sweeps with joblib/MPI and tqdm progress bars.
5
5
  Project-URL: Homepage, https://github.com/Davtax/ParameterRun
6
6
  Project-URL: Repository, https://github.com/Davtax/ParameterRun
@@ -104,7 +104,6 @@ def f(x, y, scale=1):
104
104
 
105
105
  result = parameterrun(f, param_names=["x", "y"], param_values=[[1, 2, 3], [10, 20], ], scale=2, )
106
106
 
107
- result = result.reshape(3, 2)
108
107
  print(result.shape) # (3, 2)
109
108
  ```
110
109
 
@@ -121,10 +120,9 @@ def f(x, y, z):
121
120
 
122
121
 
123
122
  result = parameterrun(f, param_names=[["x", "y"], ["z"]], param_values=[[[1, 2], [10, 20]], # (x, y) = (1,10), (2,20)
124
- [[100, 200, 300]], # z varies independently
125
- ], n_workers=1, )
123
+ [[100, 200, 300]], # z varies independently
124
+ ], n_workers=1, )
126
125
 
127
- result = result.reshape(2, 3)
128
126
  print(result.shape) # (2, 3)
129
127
  ```
130
128
 
@@ -152,6 +150,18 @@ print(square_like) # array([1, 4, 9])
152
150
 
153
151
  If the function returns a single object, the result is returned as a single array when reshaping is possible.
154
152
 
153
+ For multi-dimensional sweeps, reshaping is automatic when `reshape=True` (default): the first dimensions always match
154
+ the parameter grid shape.
155
+
156
+ ## Validation behavior
157
+
158
+ `parameterrun` validates inputs before launching workers and raises clear `ValueError`s when:
159
+
160
+ - parameter names are duplicated
161
+ - a swept parameter does not exist in the target function signature
162
+ - extra keyword arguments do not match the target function signature
163
+ - a keyword argument conflicts with a swept parameter name
164
+
155
165
  ## Parallel backend
156
166
 
157
167
  `parameterrun` supports two execution backends:
@@ -166,7 +176,7 @@ If `backend=None`, the package tries to detect whether MPI is available. If MPI
166
176
 
167
177
  ```python
168
178
  result = parameterrun(my_function, param_names=["x", "y"], param_values=[[1, 2, 3], [4, 5]], backend="joblib",
169
- n_workers=-1, )
179
+ n_workers=-1, )
170
180
  ```
171
181
 
172
182
  If `n_workers=1`, the function runs serially without spawning joblib workers.
@@ -214,7 +224,7 @@ The progress-bar description is generated automatically from the function name a
214
224
 
215
225
  ```python
216
226
  parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbose=False, pbar_kwargs=None,
217
- reshape=True, backend=None, desc=None, **kwargs, )
227
+ reshape=True, backend=None, desc=None, **kwargs, )
218
228
  ```
219
229
 
220
230
  * `fun`: function to evaluate
@@ -72,7 +72,6 @@ def f(x, y, scale=1):
72
72
 
73
73
  result = parameterrun(f, param_names=["x", "y"], param_values=[[1, 2, 3], [10, 20], ], scale=2, )
74
74
 
75
- result = result.reshape(3, 2)
76
75
  print(result.shape) # (3, 2)
77
76
  ```
78
77
 
@@ -89,10 +88,9 @@ def f(x, y, z):
89
88
 
90
89
 
91
90
  result = parameterrun(f, param_names=[["x", "y"], ["z"]], param_values=[[[1, 2], [10, 20]], # (x, y) = (1,10), (2,20)
92
- [[100, 200, 300]], # z varies independently
93
- ], n_workers=1, )
91
+ [[100, 200, 300]], # z varies independently
92
+ ], n_workers=1, )
94
93
 
95
- result = result.reshape(2, 3)
96
94
  print(result.shape) # (2, 3)
97
95
  ```
98
96
 
@@ -120,6 +118,18 @@ print(square_like) # array([1, 4, 9])
120
118
 
121
119
  If the function returns a single object, the result is returned as a single array when reshaping is possible.
122
120
 
121
+ For multi-dimensional sweeps, reshaping is automatic when `reshape=True` (default): the first dimensions always match
122
+ the parameter grid shape.
123
+
124
+ ## Validation behavior
125
+
126
+ `parameterrun` validates inputs before launching workers and raises clear `ValueError`s when:
127
+
128
+ - parameter names are duplicated
129
+ - a swept parameter does not exist in the target function signature
130
+ - extra keyword arguments do not match the target function signature
131
+ - a keyword argument conflicts with a swept parameter name
132
+
123
133
  ## Parallel backend
124
134
 
125
135
  `parameterrun` supports two execution backends:
@@ -134,7 +144,7 @@ If `backend=None`, the package tries to detect whether MPI is available. If MPI
134
144
 
135
145
  ```python
136
146
  result = parameterrun(my_function, param_names=["x", "y"], param_values=[[1, 2, 3], [4, 5]], backend="joblib",
137
- n_workers=-1, )
147
+ n_workers=-1, )
138
148
  ```
139
149
 
140
150
  If `n_workers=1`, the function runs serially without spawning joblib workers.
@@ -182,7 +192,7 @@ The progress-bar description is generated automatically from the function name a
182
192
 
183
193
  ```python
184
194
  parameterrun(fun, param_names, param_values, n_workers=-1, pbar_bool=True, verbose=False, pbar_kwargs=None,
185
- reshape=True, backend=None, desc=None, **kwargs, )
195
+ reshape=True, backend=None, desc=None, **kwargs, )
186
196
  ```
187
197
 
188
198
  * `fun`: function to evaluate
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "parameterrun"
7
- version = "0.1.0"
7
+ dynamic = ["version"]
8
8
  description = "Parallel parameter sweeps with joblib/MPI and tqdm progress bars."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -54,6 +54,10 @@ dev = [
54
54
  "twine>=6",
55
55
  ]
56
56
 
57
+ [tool.hatch.version]
58
+ path = "src/parameterrun/__init__.py"
59
+ pattern = '__version__ = "(?P<version>[^"]+)"'
60
+
57
61
  [tool.hatch.build.targets.wheel]
58
62
  packages = ["src/parameterrun"]
59
63
 
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Script to automate version bumping across all project files.
4
+
5
+ Usage:
6
+ python scripts/bump_version.py <new_version>
7
+ python scripts/bump_version.py --patch
8
+ python scripts/bump_version.py --minor
9
+ python scripts/bump_version.py --major
10
+
11
+ Example:
12
+ python scripts/bump_version.py 0.2.0
13
+ python scripts/bump_version.py --minor # bumps X.Y to X.Y+1
14
+ """
15
+
16
+ import re
17
+ import sys
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Tuple
21
+
22
+
23
+ def parse_version(version_str: str) -> Tuple[int, int, int]:
24
+ """Parse semantic version string into (major, minor, patch)."""
25
+ parts = version_str.split(".")
26
+ if len(parts) != 3:
27
+ raise ValueError(f"Invalid version format: {version_str}. Expected: X.Y.Z")
28
+ try:
29
+ major, minor, patch = (int(p) for p in parts)
30
+ return major, minor, patch
31
+ except ValueError as err:
32
+ raise ValueError(f"Version parts must be integers: {version_str}") from err
33
+
34
+
35
+ def version_to_string(major: int, minor: int, patch: int) -> str:
36
+ """Convert (major, minor, patch) to string."""
37
+ return f"{major}.{minor}.{patch}"
38
+
39
+
40
+ def bump_version(version_str: str, bump_type: str) -> str:
41
+ """Bump version based on type: 'major', 'minor', or 'patch'."""
42
+ major, minor, patch = parse_version(version_str)
43
+
44
+ if bump_type == "major":
45
+ major += 1
46
+ minor = 0
47
+ patch = 0
48
+ elif bump_type == "minor":
49
+ minor += 1
50
+ patch = 0
51
+ elif bump_type == "patch":
52
+ patch += 1
53
+ else:
54
+ raise ValueError(f"Unknown bump type: {bump_type}")
55
+
56
+ return version_to_string(major, minor, patch)
57
+
58
+
59
+ def get_current_version(init_file: Path) -> str:
60
+ """Extract current version from __init__.py."""
61
+ content = init_file.read_text()
62
+ match = re.search(r'__version__\s*=\s*"([^"]+)"', content)
63
+ if not match:
64
+ raise ValueError(f"Could not find __version__ in {init_file}")
65
+ return match.group(1)
66
+
67
+
68
+ def update_init_file(init_file: Path, new_version: str) -> None:
69
+ """Update version in __init__.py."""
70
+ content = init_file.read_text()
71
+ updated = re.sub(
72
+ r'__version__\s*=\s*"[^"]+"',
73
+ f'__version__ = "{new_version}"',
74
+ content
75
+ )
76
+ init_file.write_text(updated)
77
+ print(f"✓ Updated {init_file.relative_to(init_file.parent.parent.parent)}")
78
+
79
+
80
+ def update_changelog(changelog_file: Path, new_version: str) -> None:
81
+ """Update CHANGELOG.md with new version section."""
82
+ content = changelog_file.read_text()
83
+
84
+ # Create new version entry
85
+ date = datetime.now().strftime("%Y-%m-%d")
86
+ new_entry = f"""## [{new_version}] - {date}
87
+
88
+ ### Added
89
+ - (Your changes here)
90
+
91
+ """
92
+
93
+ # Replace [Unreleased] section with new version and restore [Unreleased]
94
+ updated = re.sub(
95
+ r"## \[Unreleased\]",
96
+ f"{new_entry}## [Unreleased]",
97
+ content
98
+ )
99
+
100
+ # If no changes were made, insert new version after the header
101
+ if updated == content:
102
+ lines = content.split("\n")
103
+ insert_idx = next(i for i, line in enumerate(lines) if line.startswith("## "))
104
+ lines.insert(insert_idx, new_entry.rstrip("\n"))
105
+ updated = "\n".join(lines)
106
+
107
+ changelog_file.write_text(updated)
108
+ print(f"✓ Updated {changelog_file.relative_to(changelog_file.parent.parent.parent)}")
109
+
110
+
111
+ def main() -> None:
112
+ project_root = Path(__file__).parent.parent
113
+ init_file = project_root / "src" / "parameterrun" / "__init__.py"
114
+ changelog_file = project_root / "CHANGELOG.md"
115
+
116
+ # Parse arguments
117
+ if len(sys.argv) < 2:
118
+ print(__doc__)
119
+ sys.exit(1)
120
+
121
+ arg = sys.argv[1]
122
+
123
+ # Get current version
124
+ current_version = get_current_version(init_file)
125
+ print(f"Current version: {current_version}\n")
126
+
127
+ # Determine new version
128
+ if arg.startswith("--"):
129
+ bump_type = arg[2:]
130
+ if bump_type not in ["major", "minor", "patch"]:
131
+ print(f"Invalid bump type: {bump_type}")
132
+ sys.exit(1)
133
+ new_version = bump_version(current_version, bump_type)
134
+ else:
135
+ # Validate provided version format
136
+ new_version = arg
137
+ try:
138
+ parse_version(new_version)
139
+ except ValueError as e:
140
+ print(f"Error: {e}")
141
+ sys.exit(1)
142
+
143
+ print(f"New version: {new_version}\n")
144
+
145
+ # Update files
146
+ try:
147
+ update_init_file(init_file, new_version)
148
+ update_changelog(changelog_file, new_version)
149
+ print(f"\n✓ Version successfully bumped to {new_version}")
150
+ print(" Remember to: commit, tag, and push the changes")
151
+ print(f" Example: git tag v{new_version}")
152
+ except Exception as e:
153
+ print(f"Error during update: {e}", file=sys.stderr)
154
+ sys.exit(1)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
159
+
@@ -0,0 +1,5 @@
1
+ __version__ = "0.2.1"
2
+
3
+ from .parallel_utils import parameterrun
4
+
5
+ __all__ = ["parameterrun", "__version__"]
@@ -1,4 +1,5 @@
1
1
  import contextlib
2
+ import inspect
2
3
  import sys
3
4
  from datetime import datetime
4
5
  from itertools import product
@@ -197,18 +198,62 @@ def _get_iteration(index, param_names, param_values):
197
198
  return dic_temp
198
199
 
199
200
 
201
+ def _flatten_param_names(param_names: List[List[str]]) -> List[str]:
202
+ return [name for group in param_names for name in group]
203
+
204
+
205
+ def _validate_function_arguments(fun: Callable[..., Any], param_names: List[List[str]], fun_kwargs: dict) -> None:
206
+ signature = inspect.signature(fun)
207
+ parameters = signature.parameters
208
+ accepts_var_kwargs = any(parameter.kind == inspect.Parameter.VAR_KEYWORD for parameter in parameters.values())
209
+
210
+ sweep_names = _flatten_param_names(param_names)
211
+
212
+ duplicated = sorted({name for name in sweep_names if sweep_names.count(name) > 1})
213
+ if duplicated:
214
+ raise ValueError(f'Duplicate parameter names are not allowed: {duplicated}')
215
+
216
+ overlapping_kwargs = sorted(set(sweep_names).intersection(fun_kwargs))
217
+ if overlapping_kwargs:
218
+ raise ValueError(f'Keyword arguments conflict with swept parameters: {overlapping_kwargs}. '
219
+ f'Remove them from kwargs or param_names.')
220
+
221
+ if accepts_var_kwargs:
222
+ return
223
+
224
+ known_names = set(parameters)
225
+ unknown_sweep = sorted(name for name in sweep_names if name not in known_names)
226
+ if unknown_sweep:
227
+ raise ValueError(f'Unknown swept parameter names for function {fun.__name__}: {unknown_sweep}')
228
+
229
+ unknown_kwargs = sorted(name for name in fun_kwargs if name not in known_names)
230
+ if unknown_kwargs:
231
+ raise ValueError(f'Unknown keyword arguments for function {fun.__name__}: {unknown_kwargs}')
232
+
233
+
200
234
  def _format_input(param_names: Union[str, list], param_values: list) -> Tuple[List[List[str]], List[List[List]]]:
201
235
  # Check the depth of the parameters
202
236
 
203
- if type(param_names) is str:
237
+ if isinstance(param_names, str):
238
+ if not param_names:
239
+ raise ValueError('Parameter names must be non-empty strings')
240
+ if not isinstance(param_values, list):
241
+ raise ValueError('The parameters values must be a list of values')
204
242
  depth = 1
205
- elif type(param_names[0]) is str:
243
+ elif isinstance(param_names, list) and len(param_names) > 0 and all(isinstance(name, str) for name in param_names):
244
+ if any(not name for name in param_names):
245
+ raise ValueError('Parameter names must be non-empty strings')
206
246
  depth = 2
207
- if type(param_values) is not list:
247
+ if not isinstance(param_values, list):
208
248
  raise ValueError('The parameters values must be a list of values')
209
- elif type(param_names[0][0]) is str:
249
+ elif isinstance(param_names, list) and len(param_names) > 0 and all(
250
+ isinstance(group, list) for group in param_names):
251
+ if any(len(group) == 0 for group in param_names):
252
+ raise ValueError('Parameter name groups must contain at least one parameter')
253
+ if any(any(not isinstance(name, str) or not name for name in group) for group in param_names):
254
+ raise ValueError('Parameter names must be non-empty strings')
210
255
  depth = 3
211
- if type(param_values[0]) is not list:
256
+ if not isinstance(param_values, list) or len(param_values) == 0 or not isinstance(param_values[0], list):
212
257
  raise ValueError('The parameters values must be a list of values')
213
258
  else:
214
259
  raise ValueError('Unknown input format for the parameters names')
@@ -227,14 +272,36 @@ def _format_input(param_names: Union[str, list], param_values: list) -> Tuple[Li
227
272
  if len(param_names) != len(param_values):
228
273
  raise ValueError('The number of groups of parameters must be the same as the number of groups of values')
229
274
 
275
+ for names_group, values_group in zip(param_names, param_values):
276
+ if not isinstance(values_group, list):
277
+ raise ValueError('Each parameter group values must be provided as a list')
278
+ if len(names_group) != len(values_group):
279
+ raise ValueError('Each parameter group must include one values list per parameter')
280
+ if any(not isinstance(parameter_values, list) for parameter_values in values_group):
281
+ raise ValueError('Each parameter values entry must be a list')
282
+
230
283
  # Check that all parameters in the same group have the same length
231
284
  for group in param_values:
232
285
  if len(set([len(param) for param in group])) != 1:
233
286
  raise ValueError('All parameters in the same group must have the same length')
234
287
 
288
+ all_names = _flatten_param_names(param_names) # type: ignore[arg-type]
289
+ duplicated = sorted({name for name in all_names if all_names.count(name) > 1})
290
+ if duplicated:
291
+ raise ValueError(f'Duplicate parameter names are not allowed: {duplicated}')
292
+
235
293
  return param_names, param_values # type: ignore[return-value]
236
294
 
237
295
 
296
+ def _reshape_channel_result(channel_values: List[Any], n_values: List[int]) -> np.ndarray:
297
+ array_result = np.array(channel_values)
298
+ if array_result.ndim == 0:
299
+ raise ValueError('Could not reshape scalar output')
300
+
301
+ target_shape = tuple(n_values) + tuple(array_result.shape[1:])
302
+ return array_result.reshape(target_shape)
303
+
304
+
238
305
  def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], List[List[str]]],
239
306
  param_values: Union[List, List[List], List[List[List]]], n_workers: Optional[int] = -1,
240
307
  pbar_bool: bool = True, verbose: Optional[bool] = False, pbar_kwargs: Optional[dict] = None,
@@ -298,6 +365,7 @@ def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], Lis
298
365
  time_start = time()
299
366
 
300
367
  param_names, param_values = _format_input(param_names, param_values)
368
+ _validate_function_arguments(fun, param_names, kwargs)
301
369
 
302
370
  # Create the list of dictionaries with the parameters in a nested loop
303
371
  n_values = [len(group[0]) for group in param_values]
@@ -362,9 +430,9 @@ def parameterrun(fun: Callable[..., Any], param_names: Union[str, List[str], Lis
362
430
  if reshape:
363
431
  try:
364
432
  if n_outputs == 1:
365
- result = np.array(result[0])
433
+ result = _reshape_channel_result(result[0], n_values)
366
434
  else:
367
- result = [np.array(result[i]) for i in range(n_outputs)]
435
+ result = [_reshape_channel_result(result[i], n_values) for i in range(n_outputs)]
368
436
  except ValueError:
369
437
  print('Could not reshape the result')
370
438
 
@@ -18,3 +18,19 @@ def test_format_input_multiple_parameters():
18
18
  def test_format_input_group_length_mismatch_raises():
19
19
  with pytest.raises(ValueError, match="same length"):
20
20
  _format_input([["x", "y"]], [[[1, 2], [10]]])
21
+
22
+
23
+ def test_format_input_rejects_empty_parameter_name():
24
+ with pytest.raises(ValueError, match="non-empty"):
25
+ _format_input("", [1, 2])
26
+
27
+
28
+ def test_format_input_rejects_non_list_group_values():
29
+ with pytest.raises(ValueError, match="values entry must be a list"):
30
+ _format_input([["x"]], [[(1, 2)]])
31
+
32
+
33
+ def test_format_input_rejects_duplicate_names():
34
+ with pytest.raises(ValueError, match="Duplicate parameter names"):
35
+ _format_input(["x", "x"], [[1, 2], [3, 4]])
36
+
@@ -9,7 +9,7 @@ def test_parameterrun_single_parameter_returns_1d_array():
9
9
  return x ** 2
10
10
 
11
11
  result = pu.parameterrun(square, param_names="x", param_values=[0, 1, 2, 3], n_workers=1, pbar_bool=False,
12
- backend="joblib", )
12
+ backend="joblib", )
13
13
 
14
14
  assert isinstance(result, np.ndarray)
15
15
  np.testing.assert_array_equal(result, np.array([0, 1, 4, 9]))
@@ -20,11 +20,9 @@ def test_parameterrun_grouped_parameters_builds_expected_grid():
20
20
  return x + y + z + offset
21
21
 
22
22
  result = pu.parameterrun(f, param_names=[["x", "y"], ["z"]],
23
- param_values=[[[1, 2], [10, 20]], # grouped pairs: (1,10), (2,20)
24
- [[100, 200, 300]], # second group scanned independently
25
- ], n_workers=1, pbar_bool=False, backend="joblib", offset=5, )
26
-
27
- result = result.reshape(2, 3) # Reshape to match the expected output shape
23
+ param_values=[[[1, 2], [10, 20]], # grouped pairs: (1,10), (2,20)
24
+ [[100, 200, 300]], # second group scanned independently
25
+ ], n_workers=1, pbar_bool=False, backend="joblib", offset=5, )
28
26
 
29
27
  expected = np.array([[116, 216, 316], # (x,y) = (1,10)
30
28
  [127, 227, 327], # (x,y) = (2,20)
@@ -40,7 +38,7 @@ def test_parameterrun_multiple_outputs_returns_list_of_arrays():
40
38
  return x + 1, x ** 2
41
39
 
42
40
  result = pu.parameterrun(f, param_names="x", param_values=[1, 2, 3], n_workers=1, pbar_bool=False,
43
- backend="joblib", )
41
+ backend="joblib", )
44
42
 
45
43
  assert isinstance(result, list)
46
44
  assert len(result) == 2
@@ -49,10 +47,25 @@ def test_parameterrun_multiple_outputs_returns_list_of_arrays():
49
47
  np.testing.assert_array_equal(result[1], np.array([1, 4, 9]))
50
48
 
51
49
 
50
+ def test_parameterrun_multiple_outputs_keep_grid_shape():
51
+ def f(x, y):
52
+ return x + y, x * y
53
+
54
+ result = pu.parameterrun(f, param_names=["x", "y"], param_values=[[1, 2], [10, 20, 30]], n_workers=1,
55
+ pbar_bool=False, backend="joblib", )
56
+
57
+ assert isinstance(result, list)
58
+ assert len(result) == 2
59
+ assert result[0].shape == (2, 3)
60
+ assert result[1].shape == (2, 3)
61
+
62
+ np.testing.assert_array_equal(result[0], np.array([[11, 21, 31], [12, 22, 32]]))
63
+ np.testing.assert_array_equal(result[1], np.array([[10, 20, 30], [20, 40, 60]]))
64
+
65
+
52
66
  def test_parameterrun_rejects_empty_parameter_groups():
53
67
  with pytest.raises(ValueError, match="at least one value"):
54
- pu.parameterrun(lambda x: x, param_names="x", param_values=[], n_workers=1, pbar_bool=False,
55
- backend="joblib", )
68
+ pu.parameterrun(lambda x: x, param_names="x", param_values=[], n_workers=1, pbar_bool=False, backend="joblib", )
56
69
 
57
70
 
58
71
  def test_parameterrun_explicit_mpi_requires_mpi4py(monkeypatch):
@@ -74,3 +87,28 @@ def test_parameterrun_explicit_mpi_requires_mpi4py(monkeypatch):
74
87
  def test_parameterrun_invalid_backend():
75
88
  with pytest.raises(ValueError, match="Unknown backend"):
76
89
  pu.parameterrun(lambda x: x, "x", [1, 2], backend="bad", pbar_bool=False)
90
+
91
+
92
+ def test_parameterrun_rejects_unknown_swept_parameters():
93
+ def f(x):
94
+ return x
95
+
96
+ with pytest.raises(ValueError, match="Unknown swept parameter"):
97
+ pu.parameterrun(f, param_names="y", param_values=[1, 2], n_workers=1, pbar_bool=False, backend="joblib", )
98
+
99
+
100
+ def test_parameterrun_rejects_unknown_kwargs():
101
+ def f(x):
102
+ return x
103
+
104
+ with pytest.raises(ValueError, match="Unknown keyword arguments"):
105
+ pu.parameterrun(f, param_names="x", param_values=[1, 2], extra=3, n_workers=1, pbar_bool=False,
106
+ backend="joblib", )
107
+
108
+
109
+ def test_parameterrun_rejects_kwargs_conflicting_with_swept_parameters():
110
+ def f(x):
111
+ return x
112
+
113
+ with pytest.raises(ValueError, match="conflict with swept parameters"):
114
+ pu.parameterrun(f, param_names="x", param_values=[1, 2], x=99, n_workers=1, pbar_bool=False, backend="joblib", )
@@ -1,10 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project are documented in this file.
4
-
5
- The format is based on Keep a Changelog and the project follows Semantic Versioning.
6
-
7
- ## [0.1.0] - 2026-03-25
8
-
9
- ### Added
10
- - Initial public release of `parameterrun`
@@ -1,3 +0,0 @@
1
- from .parallel_utils import parameterrun
2
-
3
- __all__ = ["parameterrun"]
File without changes
File without changes