py-cluster-api 0.8.0__tar.gz → 0.8.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.
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/PKG-INFO +2 -2
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/executors/lsf.py +7 -1
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/pyproject.toml +1 -1
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_lsf.py +63 -11
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_reconnect.py +7 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/.github/workflows/ci.yml +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/.gitignore +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/CLAUDE.md +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/LICENSE +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/README.md +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/__init__.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/_types.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/config.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/core.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/exceptions.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/executors/__init__.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/executors/local.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/monitor.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/script.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/API.md +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/Development.md +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/superpowers/plans/2026-07-24-job-dependency-chaining.md +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/superpowers/specs/2026-07-24-job-dependency-chaining-design.md +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/notes/lsf-implementations-comparison.html +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/pixi.lock +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/__init__.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/cluster_config.example.yaml +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/conftest.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_config.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_core.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_integration.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_local.py +0 -0
- {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_monitor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: py-cluster-api
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.1
|
|
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
|
|
@@ -36,7 +36,7 @@ _LSF_STATUS_MAP: dict[str, JobStatus] = {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
_BJOBS_FIELDS = (
|
|
39
|
-
"jobid job_name stat exit_code exec_host max_mem "
|
|
39
|
+
"jobid jobindex job_name stat exit_code exec_host max_mem "
|
|
40
40
|
"submit_time start_time finish_time"
|
|
41
41
|
)
|
|
42
42
|
|
|
@@ -305,6 +305,12 @@ class LSFExecutor(Executor):
|
|
|
305
305
|
job_id = str(rec.get("JOBID", "")).strip()
|
|
306
306
|
if not job_id:
|
|
307
307
|
continue
|
|
308
|
+
# bjobs reports the bare parent ID for every array element; the
|
|
309
|
+
# index lives in JOBINDEX ("0" for non-array jobs). Re-key as
|
|
310
|
+
# "parent[idx]" so poll()/reconnect() can tell elements apart.
|
|
311
|
+
job_index = str(rec.get("JOBINDEX", "")).strip()
|
|
312
|
+
if job_index and job_index != "0" and "[" not in job_id:
|
|
313
|
+
job_id = f"{job_id}[{job_index}]"
|
|
308
314
|
|
|
309
315
|
stat = rec.get("STAT", "").strip()
|
|
310
316
|
status = _LSF_STATUS_MAP.get(stat)
|
|
@@ -599,10 +599,10 @@ class TestParseArrayElements:
|
|
|
599
599
|
def test_element_ids_as_keys(self, lsf_config):
|
|
600
600
|
executor = LSFExecutor(lsf_config)
|
|
601
601
|
output = self._make_bjobs_json([
|
|
602
|
-
{"JOBID": "12345
|
|
602
|
+
{"JOBID": "12345", "JOBINDEX": "1", "STAT": "DONE", "EXIT_CODE": "0",
|
|
603
603
|
"EXEC_HOST": "node01", "MAX_MEM": "128 MB",
|
|
604
604
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
605
|
-
{"JOBID": "12345
|
|
605
|
+
{"JOBID": "12345", "JOBINDEX": "2", "STAT": "RUN", "EXIT_CODE": "-",
|
|
606
606
|
"EXEC_HOST": "node02", "MAX_MEM": "-",
|
|
607
607
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
608
608
|
])
|
|
@@ -618,7 +618,7 @@ class TestParseArrayElements:
|
|
|
618
618
|
{"JOBID": "12345", "STAT": "RUN", "EXIT_CODE": "-",
|
|
619
619
|
"EXEC_HOST": "-", "MAX_MEM": "-",
|
|
620
620
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
621
|
-
{"JOBID": "12345
|
|
621
|
+
{"JOBID": "12345", "JOBINDEX": "1", "STAT": "DONE", "EXIT_CODE": "0",
|
|
622
622
|
"EXEC_HOST": "node01", "MAX_MEM": "256 MB",
|
|
623
623
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
624
624
|
])
|
|
@@ -627,6 +627,24 @@ class TestParseArrayElements:
|
|
|
627
627
|
assert "12345[1]" in result
|
|
628
628
|
|
|
629
629
|
|
|
630
|
+
def test_real_lsf_format_uses_jobindex(self, lsf_config):
|
|
631
|
+
"""Real bjobs -json reports the bare parent ID in JOBID and the
|
|
632
|
+
element index only in JOBINDEX (regression for issue #8)."""
|
|
633
|
+
executor = LSFExecutor(lsf_config)
|
|
634
|
+
output = self._make_bjobs_json([
|
|
635
|
+
{"JOBID": "154541712", "JOBINDEX": "2", "STAT": "RUN",
|
|
636
|
+
"JOB_NAME": "wells[2]", "EXIT_CODE": "-"},
|
|
637
|
+
{"JOBID": "154541712", "JOBINDEX": "1", "STAT": "DONE",
|
|
638
|
+
"JOB_NAME": "wells[1]", "EXIT_CODE": ""},
|
|
639
|
+
{"JOBID": "99", "JOBINDEX": "0", "STAT": "RUN",
|
|
640
|
+
"JOB_NAME": "single", "EXIT_CODE": "-"},
|
|
641
|
+
])
|
|
642
|
+
result = executor._parse_job_statuses(output)
|
|
643
|
+
assert set(result) == {"154541712[1]", "154541712[2]", "99"}
|
|
644
|
+
assert result["154541712[1]"][0] == JobStatus.DONE
|
|
645
|
+
assert result["154541712[2]"][0] == JobStatus.RUNNING
|
|
646
|
+
|
|
647
|
+
|
|
630
648
|
class TestArrayStatusComputation:
|
|
631
649
|
def test_no_elements(self):
|
|
632
650
|
record = JobRecord(
|
|
@@ -730,13 +748,13 @@ class TestArrayElementPolling:
|
|
|
730
748
|
assert len(job.array_elements) == 0
|
|
731
749
|
|
|
732
750
|
bjobs_output = json.dumps({"RECORDS": [
|
|
733
|
-
{"JOBID": "12345
|
|
751
|
+
{"JOBID": "12345", "JOBINDEX": "1", "STAT": "DONE", "EXIT_CODE": "0",
|
|
734
752
|
"EXEC_HOST": "node01", "MAX_MEM": "128 MB",
|
|
735
753
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
736
|
-
{"JOBID": "12345
|
|
754
|
+
{"JOBID": "12345", "JOBINDEX": "2", "STAT": "RUN", "EXIT_CODE": "-",
|
|
737
755
|
"EXEC_HOST": "node02", "MAX_MEM": "-",
|
|
738
756
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
739
|
-
{"JOBID": "12345
|
|
757
|
+
{"JOBID": "12345", "JOBINDEX": "3", "STAT": "PEND", "EXIT_CODE": "-",
|
|
740
758
|
"EXEC_HOST": "-", "MAX_MEM": "-",
|
|
741
759
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
742
760
|
]})
|
|
@@ -752,6 +770,40 @@ class TestArrayElementPolling:
|
|
|
752
770
|
assert job.status == JobStatus.RUNNING
|
|
753
771
|
assert statuses["12345"] == JobStatus.RUNNING
|
|
754
772
|
|
|
773
|
+
async def test_poll_real_lsf_format_one_element_done(self, lsf_config, work_dir):
|
|
774
|
+
"""Issue #8: bjobs lists DONE elements last and reports the bare parent
|
|
775
|
+
ID for every element; the parent must stay RUNNING until all are done."""
|
|
776
|
+
executor = LSFExecutor(lsf_config)
|
|
777
|
+
with patch.object(
|
|
778
|
+
executor, "_call",
|
|
779
|
+
new_callable=AsyncMock,
|
|
780
|
+
return_value="Job <154541712> is submitted to queue <normal>.",
|
|
781
|
+
):
|
|
782
|
+
job = await executor.submit_array(
|
|
783
|
+
command="echo hello", name="wells", array_range=(1, 4),
|
|
784
|
+
resources=ResourceSpec(work_dir=work_dir),
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
def rec(idx, stat):
|
|
788
|
+
return {"JOBID": "154541712", "JOBINDEX": str(idx), "STAT": stat,
|
|
789
|
+
"JOB_NAME": f"wells[{idx}]", "EXIT_CODE": "-",
|
|
790
|
+
"EXEC_HOST": "-", "MAX_MEM": "-",
|
|
791
|
+
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"}
|
|
792
|
+
|
|
793
|
+
bjobs_output = json.dumps({"RECORDS": [
|
|
794
|
+
rec(2, "RUN"), rec(3, "RUN"), rec(4, "RUN"), rec(1, "DONE"),
|
|
795
|
+
]})
|
|
796
|
+
with patch.object(executor, "_call", new_callable=AsyncMock, return_value=bjobs_output) as m:
|
|
797
|
+
await executor.poll()
|
|
798
|
+
args = m.call_args.args[0]
|
|
799
|
+
assert "jobindex" in args[args.index("-o") + 1]
|
|
800
|
+
|
|
801
|
+
assert job.status == JobStatus.RUNNING
|
|
802
|
+
assert not job.is_terminal
|
|
803
|
+
assert len(job.array_elements) == 4
|
|
804
|
+
assert job.array_elements[1].status == JobStatus.DONE
|
|
805
|
+
assert job.array_elements[4].status == JobStatus.RUNNING
|
|
806
|
+
|
|
755
807
|
async def test_poll_all_done(self, lsf_config, work_dir):
|
|
756
808
|
executor = LSFExecutor(lsf_config)
|
|
757
809
|
with patch.object(
|
|
@@ -767,10 +819,10 @@ class TestArrayElementPolling:
|
|
|
767
819
|
)
|
|
768
820
|
|
|
769
821
|
bjobs_output = json.dumps({"RECORDS": [
|
|
770
|
-
{"JOBID": "12345
|
|
822
|
+
{"JOBID": "12345", "JOBINDEX": "1", "STAT": "DONE", "EXIT_CODE": "0",
|
|
771
823
|
"EXEC_HOST": "node01", "MAX_MEM": "128 MB",
|
|
772
824
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
773
|
-
{"JOBID": "12345
|
|
825
|
+
{"JOBID": "12345", "JOBINDEX": "2", "STAT": "DONE", "EXIT_CODE": "0",
|
|
774
826
|
"EXEC_HOST": "node02", "MAX_MEM": "256 MB",
|
|
775
827
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
776
828
|
]})
|
|
@@ -796,13 +848,13 @@ class TestArrayElementPolling:
|
|
|
796
848
|
)
|
|
797
849
|
|
|
798
850
|
bjobs_output = json.dumps({"RECORDS": [
|
|
799
|
-
{"JOBID": "12345
|
|
851
|
+
{"JOBID": "12345", "JOBINDEX": "1", "STAT": "DONE", "EXIT_CODE": "0",
|
|
800
852
|
"EXEC_HOST": "node01", "MAX_MEM": "128 MB",
|
|
801
853
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
802
|
-
{"JOBID": "12345
|
|
854
|
+
{"JOBID": "12345", "JOBINDEX": "2", "STAT": "EXIT", "EXIT_CODE": "1",
|
|
803
855
|
"EXEC_HOST": "node02", "MAX_MEM": "-",
|
|
804
856
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
805
|
-
{"JOBID": "12345
|
|
857
|
+
{"JOBID": "12345", "JOBINDEX": "3", "STAT": "DONE", "EXIT_CODE": "0",
|
|
806
858
|
"EXEC_HOST": "node03", "MAX_MEM": "-",
|
|
807
859
|
"SUBMIT_TIME": "-", "START_TIME": "-", "FINISH_TIME": "-"},
|
|
808
860
|
]})
|
|
@@ -27,8 +27,15 @@ def _make_record(
|
|
|
27
27
|
start_time="Jan 5 10:01:00 2024",
|
|
28
28
|
finish_time="-",
|
|
29
29
|
):
|
|
30
|
+
# Real bjobs -json: elements carry the bare parent ID in JOBID, the
|
|
31
|
+
# index in JOBINDEX, and "name[idx]" in JOB_NAME.
|
|
32
|
+
job_index = "0"
|
|
33
|
+
if "[" in job_id:
|
|
34
|
+
job_id, job_index = job_id.rstrip("]").split("[")
|
|
35
|
+
job_name = f"{job_name}[{job_index}]"
|
|
30
36
|
return {
|
|
31
37
|
"JOBID": job_id,
|
|
38
|
+
"JOBINDEX": job_index,
|
|
32
39
|
"JOB_NAME": job_name,
|
|
33
40
|
"STAT": stat,
|
|
34
41
|
"EXIT_CODE": exit_code,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|