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.
Files changed (34) hide show
  1. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/.github/workflows/ci.yml +1 -1
  2. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/CLAUDE.md +1 -1
  3. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/PKG-INFO +44 -1
  4. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/README.md +43 -0
  5. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/_types.py +1 -0
  6. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/core.py +63 -1
  7. py_cluster_api-0.8.0/cluster_api/executors/local.py +497 -0
  8. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/executors/lsf.py +68 -5
  9. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/docs/API.md +8 -3
  10. py_cluster_api-0.8.0/docs/superpowers/plans/2026-07-24-job-dependency-chaining.md +786 -0
  11. py_cluster_api-0.8.0/docs/superpowers/specs/2026-07-24-job-dependency-chaining-design.md +148 -0
  12. py_cluster_api-0.8.0/notes/lsf-implementations-comparison.html +561 -0
  13. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/pixi.lock +539 -543
  14. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/pyproject.toml +1 -1
  15. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_integration.py +47 -0
  16. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_local.py +211 -0
  17. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_lsf.py +208 -0
  18. py_cluster_api-0.6.0/cluster_api/executors/local.py +0 -281
  19. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/.gitignore +0 -0
  20. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/LICENSE +0 -0
  21. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/__init__.py +0 -0
  22. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/config.py +0 -0
  23. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/exceptions.py +0 -0
  24. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/executors/__init__.py +0 -0
  25. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/monitor.py +0 -0
  26. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/cluster_api/script.py +0 -0
  27. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/docs/Development.md +0 -0
  28. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/__init__.py +0 -0
  29. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/cluster_config.example.yaml +0 -0
  30. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/conftest.py +0 -0
  31. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_config.py +0 -0
  32. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_core.py +0 -0
  33. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_monitor.py +0 -0
  34. {py_cluster_api-0.6.0 → py_cluster_api-0.8.0}/tests/test_reconnect.py +0 -0
@@ -23,7 +23,7 @@ jobs:
23
23
  - name: Set up Pixi
24
24
  uses: prefix-dev/setup-pixi@v0.9.0
25
25
  with:
26
- pixi-version: v0.65.0
26
+ pixi-version: v0.70.2
27
27
  cache: true
28
28
 
29
29
  - name: Lint
@@ -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.6.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 ---