py-cluster-api 0.6.0__tar.gz → 0.8.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.
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/.github/workflows/ci.yml +1 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/CLAUDE.md +1 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/PKG-INFO +44 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/README.md +43 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/_types.py +1 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/core.py +63 -1
- py_cluster_api-0.8.0/cluster_api/executors/local.py +497 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/executors/lsf.py +68 -5
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/docs/API.md +8 -3
- py_cluster_api-0.8.0/docs/superpowers/plans/2026-07-24-job-dependency-chaining.md +786 -0
- py_cluster_api-0.8.0/docs/superpowers/specs/2026-07-24-job-dependency-chaining-design.md +148 -0
- py_cluster_api-0.8.0/notes/lsf-implementations-comparison.html +561 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/pixi.lock +539 -543
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/pyproject.toml +1 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_integration.py +47 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_local.py +211 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_lsf.py +208 -0
- py_cluster_api-0.6.0/cluster_api/executors/local.py +0 -281
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/.gitignore +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/LICENSE +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/__init__.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/config.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/exceptions.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/executors/__init__.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/monitor.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/script.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/docs/Development.md +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/__init__.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/cluster_config.example.yaml +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/conftest.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_config.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_core.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_monitor.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_reconnect.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Generic Python library for submitting and monitoring jobs on HPC clusters. Wraps scheduler CLIs (bsub/bjobs/bkill) behind an async executor abstraction with an active polling monitor that fires callbacks on job completion. Inspired by dask-jobqueue's script templating and Nextflow's portable config profiles, but unlike dask-jobqueue, this library actively polls the scheduler rather than relying on workers phoning home.
|
|
4
4
|
|
|
5
|
-
Key capabilities beyond submit/poll/cancel: `reconnect()` rediscovers running jobs after a process restart (requires `job_name_prefix`), and `cancel_by_name()` kills jobs by name pattern (LSF only).
|
|
5
|
+
Key capabilities beyond submit/poll/cancel: `reconnect()` rediscovers running jobs after a process restart (requires `job_name_prefix`), and `cancel_by_name()` kills jobs by name pattern (LSF only). `depends_on` on `submit()`/`submit_array()` chains jobs (native `bsub -w ... -ti` on LSF; poll-driven deferred start on Local) with auto-cancel of dependents when an upstream job fails.
|
|
6
6
|
|
|
7
7
|
Founding principles: async-only API, executors are thin wrappers around scheduler CLIs, all state lives in `JobRecord` dataclasses tracked in-process, monitoring is poll-based via `bjobs -json`, and configuration uses Nextflow-style YAML profiles.
|
|
8
8
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-cluster-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Generic Python library for running jobs on HPC clusters
|
|
5
5
|
Project-URL: Homepage, https://github.com/JaneliaSciComp/py-cluster-api
|
|
6
6
|
Project-URL: Repository, https://github.com/JaneliaSciComp/py-cluster-api
|
|
@@ -71,6 +71,9 @@ A Python library for submitting and monitoring jobs on HPC clusters. Supports ru
|
|
|
71
71
|
- **Zombie detection** — jobs that disappear from the scheduler are marked as failed
|
|
72
72
|
- **YAML config with profiles** — Nextflow-style config with per-environment profiles
|
|
73
73
|
- **Callback chaining** — register `on_success`, `on_failure`, or `on_exit` handlers on any job
|
|
74
|
+
- **Job dependencies** — chain jobs with `depends_on`; dependents are auto-cancelled if an upstream job fails
|
|
75
|
+
- **Environment control** — `inherit_env` and `login_shell` submit options control what environment a job runs with
|
|
76
|
+
- **Reconnect & external tracking** — `reconnect()` rediscovers jobs after a restart; `track()` seeds a job from an external store (e.g. a database) into the executor
|
|
74
77
|
|
|
75
78
|
## Installation
|
|
76
79
|
|
|
@@ -136,6 +139,24 @@ async def run_array():
|
|
|
136
139
|
|
|
137
140
|
The array index environment variable depends on the executor: LSF uses `$LSB_JOBINDEX`, while the local executor uses `$ARRAY_INDEX`.
|
|
138
141
|
|
|
142
|
+
### Job Dependencies
|
|
143
|
+
|
|
144
|
+
Chain jobs so each stage starts only after the previous one succeeds:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
convert = await executor.submit("convert.sh input.tif", name="convert-s0")
|
|
148
|
+
pyramid = await executor.submit(
|
|
149
|
+
"build_pyramid.sh", name="pyramid",
|
|
150
|
+
depends_on=[convert], # JobRecords or raw job-id strings
|
|
151
|
+
)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`depends_on` accepts multiple jobs (fan-in). On LSF this uses native
|
|
155
|
+
`bsub -w "done(...)" -ti`, so chains keep running if your process exits,
|
|
156
|
+
and LSF terminates dependents whose upstream failed — no jobs stuck
|
|
157
|
+
pending forever. When a dependent is cancelled this way,
|
|
158
|
+
`record.metadata["dependency_failed"]` lists the failed upstream job ids.
|
|
159
|
+
|
|
139
160
|
### Reconnecting After Restart
|
|
140
161
|
|
|
141
162
|
If your process crashes or restarts, `reconnect()` rediscovers running jobs from the scheduler and resumes tracking them. Requires `job_name_prefix` to be set in config.
|
|
@@ -156,6 +177,28 @@ async def resume():
|
|
|
156
177
|
await monitor.stop()
|
|
157
178
|
```
|
|
158
179
|
|
|
180
|
+
### Environment Control
|
|
181
|
+
|
|
182
|
+
By default a submitted job inherits the submitting process's environment plus whatever `env` you pass. Set `inherit_env=False` to start from a minimal environment (only `env` and scheduler-provided variables), or `login_shell=True` to run under a login shell so the target user's own profile builds `PATH`, modules, conda, etc.:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
job = await executor.submit(
|
|
186
|
+
command="my_tool.sh",
|
|
187
|
+
name="my-job",
|
|
188
|
+
inherit_env=False,
|
|
189
|
+
login_shell=True,
|
|
190
|
+
env={"MY_VAR": "1"},
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Tracking Jobs From External State
|
|
195
|
+
|
|
196
|
+
`track()` seeds the executor with a job ID from a persistent store (e.g. a database) without re-submitting it, so a later `poll()` or `cancel()` can act on jobs submitted by a previous process:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
job = executor.track(job_id="12345", status=JobStatus.RUNNING)
|
|
200
|
+
```
|
|
201
|
+
|
|
159
202
|
### Local Testing
|
|
160
203
|
|
|
161
204
|
```python
|
|
@@ -19,6 +19,9 @@ A Python library for submitting and monitoring jobs on HPC clusters. Supports ru
|
|
|
19
19
|
- **Zombie detection** — jobs that disappear from the scheduler are marked as failed
|
|
20
20
|
- **YAML config with profiles** — Nextflow-style config with per-environment profiles
|
|
21
21
|
- **Callback chaining** — register `on_success`, `on_failure`, or `on_exit` handlers on any job
|
|
22
|
+
- **Job dependencies** — chain jobs with `depends_on`; dependents are auto-cancelled if an upstream job fails
|
|
23
|
+
- **Environment control** — `inherit_env` and `login_shell` submit options control what environment a job runs with
|
|
24
|
+
- **Reconnect & external tracking** — `reconnect()` rediscovers jobs after a restart; `track()` seeds a job from an external store (e.g. a database) into the executor
|
|
22
25
|
|
|
23
26
|
## Installation
|
|
24
27
|
|
|
@@ -84,6 +87,24 @@ async def run_array():
|
|
|
84
87
|
|
|
85
88
|
The array index environment variable depends on the executor: LSF uses `$LSB_JOBINDEX`, while the local executor uses `$ARRAY_INDEX`.
|
|
86
89
|
|
|
90
|
+
### Job Dependencies
|
|
91
|
+
|
|
92
|
+
Chain jobs so each stage starts only after the previous one succeeds:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
convert = await executor.submit("convert.sh input.tif", name="convert-s0")
|
|
96
|
+
pyramid = await executor.submit(
|
|
97
|
+
"build_pyramid.sh", name="pyramid",
|
|
98
|
+
depends_on=[convert], # JobRecords or raw job-id strings
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`depends_on` accepts multiple jobs (fan-in). On LSF this uses native
|
|
103
|
+
`bsub -w "done(...)" -ti`, so chains keep running if your process exits,
|
|
104
|
+
and LSF terminates dependents whose upstream failed — no jobs stuck
|
|
105
|
+
pending forever. When a dependent is cancelled this way,
|
|
106
|
+
`record.metadata["dependency_failed"]` lists the failed upstream job ids.
|
|
107
|
+
|
|
87
108
|
### Reconnecting After Restart
|
|
88
109
|
|
|
89
110
|
If your process crashes or restarts, `reconnect()` rediscovers running jobs from the scheduler and resumes tracking them. Requires `job_name_prefix` to be set in config.
|
|
@@ -104,6 +125,28 @@ async def resume():
|
|
|
104
125
|
await monitor.stop()
|
|
105
126
|
```
|
|
106
127
|
|
|
128
|
+
### Environment Control
|
|
129
|
+
|
|
130
|
+
By default a submitted job inherits the submitting process's environment plus whatever `env` you pass. Set `inherit_env=False` to start from a minimal environment (only `env` and scheduler-provided variables), or `login_shell=True` to run under a login shell so the target user's own profile builds `PATH`, modules, conda, etc.:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
job = await executor.submit(
|
|
134
|
+
command="my_tool.sh",
|
|
135
|
+
name="my-job",
|
|
136
|
+
inherit_env=False,
|
|
137
|
+
login_shell=True,
|
|
138
|
+
env={"MY_VAR": "1"},
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Tracking Jobs From External State
|
|
143
|
+
|
|
144
|
+
`track()` seeds the executor with a job ID from a persistent store (e.g. a database) without re-submitting it, so a later `poll()` or `cancel()` can act on jobs submitted by a previous process:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
job = executor.track(job_id="12345", status=JobStatus.RUNNING)
|
|
148
|
+
```
|
|
149
|
+
|
|
107
150
|
### Local Testing
|
|
108
151
|
|
|
109
152
|
```python
|
|
@@ -98,6 +98,7 @@ class JobRecord:
|
|
|
98
98
|
start_time: datetime | None = None
|
|
99
99
|
finish_time: datetime | None = None
|
|
100
100
|
array_elements: dict[int, ArrayElement] = field(default_factory=dict)
|
|
101
|
+
depends_on: list[str] = field(default_factory=list)
|
|
101
102
|
_callbacks: list[tuple[JobExitCondition, Callable]] = field(
|
|
102
103
|
default_factory=list, repr=False
|
|
103
104
|
)
|
|
@@ -7,6 +7,7 @@ import asyncio
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
from collections.abc import Sequence
|
|
10
11
|
from datetime import datetime, timezone
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
@@ -73,6 +74,15 @@ class Executor(abc.ABC):
|
|
|
73
74
|
|
|
74
75
|
# --- Submission ---
|
|
75
76
|
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _resolve_dependency_ids(
|
|
79
|
+
depends_on: Sequence[JobRecord | str] | None,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""Resolve JobRecords or raw id strings to a list of job ids."""
|
|
82
|
+
if not depends_on:
|
|
83
|
+
return []
|
|
84
|
+
return [d.job_id if isinstance(d, JobRecord) else str(d) for d in depends_on]
|
|
85
|
+
|
|
76
86
|
async def submit(
|
|
77
87
|
self,
|
|
78
88
|
command: str,
|
|
@@ -82,14 +92,34 @@ class Executor(abc.ABC):
|
|
|
82
92
|
epilogue: list[str] | None = None,
|
|
83
93
|
env: dict[str, str] | None = None,
|
|
84
94
|
metadata: dict[str, Any] | None = None,
|
|
95
|
+
inherit_env: bool = True,
|
|
96
|
+
login_shell: bool = False,
|
|
97
|
+
depends_on: Sequence[JobRecord | str] | None = None,
|
|
85
98
|
) -> JobRecord:
|
|
86
|
-
"""Submit a job to the scheduler.
|
|
99
|
+
"""Submit a job to the scheduler.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
inherit_env: When True (default), the job inherits the submitting
|
|
103
|
+
process's environment. When False, the job starts from a
|
|
104
|
+
minimal environment: only *env* plus scheduler-provided
|
|
105
|
+
variables reach the job.
|
|
106
|
+
login_shell: When True, the job runs under a login shell so the
|
|
107
|
+
target user's own profile builds the environment (PATH,
|
|
108
|
+
modules, conda), as if they had SSH'd to the node.
|
|
109
|
+
depends_on: Jobs that must finish successfully before this job
|
|
110
|
+
starts. Accepts JobRecords or raw job-id strings. If any
|
|
111
|
+
dependency fails or is killed, this job is cancelled instead
|
|
112
|
+
of pending forever.
|
|
113
|
+
"""
|
|
87
114
|
resources = resources or ResourceSpec()
|
|
88
115
|
full_name = _sanitize_job_name(f"{self._prefix}-{name}" if self._prefix else name)
|
|
116
|
+
dep_ids = self._resolve_dependency_ids(depends_on)
|
|
89
117
|
|
|
90
118
|
job_id, script_path = await self._submit_job(
|
|
91
119
|
command, full_name, resources, prologue, epilogue, env,
|
|
92
120
|
cwd=resources.work_dir,
|
|
121
|
+
inherit_env=inherit_env, login_shell=login_shell,
|
|
122
|
+
depends_on=dep_ids or None,
|
|
93
123
|
)
|
|
94
124
|
|
|
95
125
|
record = JobRecord(
|
|
@@ -100,6 +130,7 @@ class Executor(abc.ABC):
|
|
|
100
130
|
resources=resources,
|
|
101
131
|
script_path=script_path,
|
|
102
132
|
metadata=metadata or {},
|
|
133
|
+
depends_on=dep_ids,
|
|
103
134
|
_last_seen=datetime.now(timezone.utc),
|
|
104
135
|
)
|
|
105
136
|
self._jobs[job_id] = record
|
|
@@ -117,14 +148,20 @@ class Executor(abc.ABC):
|
|
|
117
148
|
env: dict[str, str] | None = None,
|
|
118
149
|
metadata: dict[str, Any] | None = None,
|
|
119
150
|
max_concurrent: int | None = None,
|
|
151
|
+
inherit_env: bool = True,
|
|
152
|
+
login_shell: bool = False,
|
|
153
|
+
depends_on: Sequence[JobRecord | str] | None = None,
|
|
120
154
|
) -> JobRecord:
|
|
121
155
|
"""Submit a job array to the scheduler."""
|
|
122
156
|
resources = resources or ResourceSpec()
|
|
123
157
|
full_name = _sanitize_job_name(f"{self._prefix}-{name}" if self._prefix else name)
|
|
158
|
+
dep_ids = self._resolve_dependency_ids(depends_on)
|
|
124
159
|
|
|
125
160
|
job_id, script_path = await self._submit_array_job(
|
|
126
161
|
command, full_name, array_range, resources, prologue, epilogue,
|
|
127
162
|
env, max_concurrent, cwd=resources.work_dir,
|
|
163
|
+
inherit_env=inherit_env, login_shell=login_shell,
|
|
164
|
+
depends_on=dep_ids or None,
|
|
128
165
|
)
|
|
129
166
|
|
|
130
167
|
meta = {**(metadata or {}), "array_range": array_range}
|
|
@@ -139,6 +176,7 @@ class Executor(abc.ABC):
|
|
|
139
176
|
resources=resources,
|
|
140
177
|
script_path=script_path,
|
|
141
178
|
metadata=meta,
|
|
179
|
+
depends_on=dep_ids,
|
|
142
180
|
_last_seen=datetime.now(timezone.utc),
|
|
143
181
|
)
|
|
144
182
|
self._jobs[job_id] = record
|
|
@@ -159,6 +197,9 @@ class Executor(abc.ABC):
|
|
|
159
197
|
env: dict[str, str] | None = None,
|
|
160
198
|
*,
|
|
161
199
|
cwd: str | None = None,
|
|
200
|
+
inherit_env: bool = True,
|
|
201
|
+
login_shell: bool = False,
|
|
202
|
+
depends_on: list[str] | None = None,
|
|
162
203
|
) -> tuple[str, str | None]:
|
|
163
204
|
"""Submit a single job.
|
|
164
205
|
|
|
@@ -179,10 +220,15 @@ class Executor(abc.ABC):
|
|
|
179
220
|
max_concurrent: int | None = None,
|
|
180
221
|
*,
|
|
181
222
|
cwd: str | None = None,
|
|
223
|
+
inherit_env: bool = True,
|
|
224
|
+
login_shell: bool = False,
|
|
225
|
+
depends_on: list[str] | None = None,
|
|
182
226
|
) -> tuple[str, str | None]:
|
|
183
227
|
"""Submit an array job. Override in subclasses."""
|
|
184
228
|
return await self._submit_job(
|
|
185
229
|
command, name, resources, prologue, epilogue, env, cwd=cwd,
|
|
230
|
+
inherit_env=inherit_env, login_shell=login_shell,
|
|
231
|
+
depends_on=depends_on,
|
|
186
232
|
)
|
|
187
233
|
|
|
188
234
|
def _job_id_from_submit_output(self, out: str) -> str:
|
|
@@ -315,6 +361,22 @@ class Executor(abc.ABC):
|
|
|
315
361
|
record = self._jobs[parent_id]
|
|
316
362
|
record.status = record.compute_array_status()
|
|
317
363
|
|
|
364
|
+
# Annotate dependency-driven failures so callbacks can distinguish
|
|
365
|
+
# "upstream broke" from "my job broke".
|
|
366
|
+
for record in self._jobs.values():
|
|
367
|
+
if (
|
|
368
|
+
record.depends_on
|
|
369
|
+
and record.status in (JobStatus.FAILED, JobStatus.KILLED)
|
|
370
|
+
and "dependency_failed" not in record.metadata
|
|
371
|
+
):
|
|
372
|
+
culprits = [
|
|
373
|
+
d for d in record.depends_on
|
|
374
|
+
if (dep := self._jobs.get(d)) is not None
|
|
375
|
+
and dep.status in (JobStatus.FAILED, JobStatus.KILLED)
|
|
376
|
+
]
|
|
377
|
+
if culprits:
|
|
378
|
+
record.metadata["dependency_failed"] = culprits
|
|
379
|
+
|
|
318
380
|
return {jid: r.status for jid, r in self._jobs.items()}
|
|
319
381
|
|
|
320
382
|
# --- Subprocess helper ---
|