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.
Files changed (33) hide show
  1. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/PKG-INFO +2 -2
  2. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/executors/lsf.py +7 -1
  3. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/pyproject.toml +1 -1
  4. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_lsf.py +63 -11
  5. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_reconnect.py +7 -0
  6. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/.github/workflows/ci.yml +0 -0
  7. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/.gitignore +0 -0
  8. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/CLAUDE.md +0 -0
  9. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/LICENSE +0 -0
  10. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/README.md +0 -0
  11. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/__init__.py +0 -0
  12. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/_types.py +0 -0
  13. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/config.py +0 -0
  14. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/core.py +0 -0
  15. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/exceptions.py +0 -0
  16. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/executors/__init__.py +0 -0
  17. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/executors/local.py +0 -0
  18. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/monitor.py +0 -0
  19. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/cluster_api/script.py +0 -0
  20. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/API.md +0 -0
  21. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/Development.md +0 -0
  22. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/docs/superpowers/plans/2026-07-24-job-dependency-chaining.md +0 -0
  23. {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
  24. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/notes/lsf-implementations-comparison.html +0 -0
  25. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/pixi.lock +0 -0
  26. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/__init__.py +0 -0
  27. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/cluster_config.example.yaml +0 -0
  28. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/conftest.py +0 -0
  29. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_config.py +0 -0
  30. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_core.py +0 -0
  31. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_integration.py +0 -0
  32. {py_cluster_api-0.8.0 → py_cluster_api-0.8.1}/tests/test_local.py +0 -0
  33. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: py-cluster-api
3
- Version: 0.8.0
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-cluster-api"
3
- version = "0.8.0"
3
+ version = "0.8.1"
4
4
  description = "Generic Python library for running jobs on HPC clusters"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -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[1]", "STAT": "DONE", "EXIT_CODE": "0",
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[2]", "STAT": "RUN", "EXIT_CODE": "-",
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[1]", "STAT": "DONE", "EXIT_CODE": "0",
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[1]", "STAT": "DONE", "EXIT_CODE": "0",
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[2]", "STAT": "RUN", "EXIT_CODE": "-",
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[3]", "STAT": "PEND", "EXIT_CODE": "-",
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[1]", "STAT": "DONE", "EXIT_CODE": "0",
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[2]", "STAT": "DONE", "EXIT_CODE": "0",
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[1]", "STAT": "DONE", "EXIT_CODE": "0",
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[2]", "STAT": "EXIT", "EXIT_CODE": "1",
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[3]", "STAT": "DONE", "EXIT_CODE": "0",
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