py-cluster-api 0.6.0__tar.gz → 0.7.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.7.0}/.github/workflows/ci.yml +1 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/PKG-INFO +1 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/core.py +22 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/executors/local.py +32 -4
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/executors/lsf.py +51 -5
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/pixi.lock +539 -543
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/pyproject.toml +1 -1
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_local.py +48 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_lsf.py +74 -0
- py_cluster_api-0.7.0/unknown-fix.md +131 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/.gitignore +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/CLAUDE.md +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/LICENSE +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/README.md +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/__init__.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/_types.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/config.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/exceptions.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/executors/__init__.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/monitor.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/script.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/docs/API.md +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/docs/Development.md +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/__init__.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/cluster_config.example.yaml +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/conftest.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_config.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_core.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_integration.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_monitor.py +0 -0
- {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_reconnect.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-cluster-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -82,14 +82,27 @@ class Executor(abc.ABC):
|
|
|
82
82
|
epilogue: list[str] | None = None,
|
|
83
83
|
env: dict[str, str] | None = None,
|
|
84
84
|
metadata: dict[str, Any] | None = None,
|
|
85
|
+
inherit_env: bool = True,
|
|
86
|
+
login_shell: bool = False,
|
|
85
87
|
) -> JobRecord:
|
|
86
|
-
"""Submit a job to the scheduler.
|
|
88
|
+
"""Submit a job to the scheduler.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
inherit_env: When True (default), the job inherits the submitting
|
|
92
|
+
process's environment. When False, the job starts from a
|
|
93
|
+
minimal environment: only *env* plus scheduler-provided
|
|
94
|
+
variables reach the job.
|
|
95
|
+
login_shell: When True, the job runs under a login shell so the
|
|
96
|
+
target user's own profile builds the environment (PATH,
|
|
97
|
+
modules, conda), as if they had SSH'd to the node.
|
|
98
|
+
"""
|
|
87
99
|
resources = resources or ResourceSpec()
|
|
88
100
|
full_name = _sanitize_job_name(f"{self._prefix}-{name}" if self._prefix else name)
|
|
89
101
|
|
|
90
102
|
job_id, script_path = await self._submit_job(
|
|
91
103
|
command, full_name, resources, prologue, epilogue, env,
|
|
92
104
|
cwd=resources.work_dir,
|
|
105
|
+
inherit_env=inherit_env, login_shell=login_shell,
|
|
93
106
|
)
|
|
94
107
|
|
|
95
108
|
record = JobRecord(
|
|
@@ -117,6 +130,8 @@ class Executor(abc.ABC):
|
|
|
117
130
|
env: dict[str, str] | None = None,
|
|
118
131
|
metadata: dict[str, Any] | None = None,
|
|
119
132
|
max_concurrent: int | None = None,
|
|
133
|
+
inherit_env: bool = True,
|
|
134
|
+
login_shell: bool = False,
|
|
120
135
|
) -> JobRecord:
|
|
121
136
|
"""Submit a job array to the scheduler."""
|
|
122
137
|
resources = resources or ResourceSpec()
|
|
@@ -125,6 +140,7 @@ class Executor(abc.ABC):
|
|
|
125
140
|
job_id, script_path = await self._submit_array_job(
|
|
126
141
|
command, full_name, array_range, resources, prologue, epilogue,
|
|
127
142
|
env, max_concurrent, cwd=resources.work_dir,
|
|
143
|
+
inherit_env=inherit_env, login_shell=login_shell,
|
|
128
144
|
)
|
|
129
145
|
|
|
130
146
|
meta = {**(metadata or {}), "array_range": array_range}
|
|
@@ -159,6 +175,8 @@ class Executor(abc.ABC):
|
|
|
159
175
|
env: dict[str, str] | None = None,
|
|
160
176
|
*,
|
|
161
177
|
cwd: str | None = None,
|
|
178
|
+
inherit_env: bool = True,
|
|
179
|
+
login_shell: bool = False,
|
|
162
180
|
) -> tuple[str, str | None]:
|
|
163
181
|
"""Submit a single job.
|
|
164
182
|
|
|
@@ -179,10 +197,13 @@ class Executor(abc.ABC):
|
|
|
179
197
|
max_concurrent: int | None = None,
|
|
180
198
|
*,
|
|
181
199
|
cwd: str | None = None,
|
|
200
|
+
inherit_env: bool = True,
|
|
201
|
+
login_shell: bool = False,
|
|
182
202
|
) -> tuple[str, str | None]:
|
|
183
203
|
"""Submit an array job. Override in subclasses."""
|
|
184
204
|
return await self._submit_job(
|
|
185
205
|
command, name, resources, prologue, epilogue, env, cwd=cwd,
|
|
206
|
+
inherit_env=inherit_env, login_shell=login_shell,
|
|
186
207
|
)
|
|
187
208
|
|
|
188
209
|
def _job_id_from_submit_output(self, out: str) -> str:
|
|
@@ -17,6 +17,22 @@ from ..script import render_script, write_script
|
|
|
17
17
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
20
|
+
# PATH for jobs that don't inherit the submitting process's environment;
|
|
21
|
+
# mirrors what cron/sshd hand a fresh process. A login shell's profile will
|
|
22
|
+
# prepend the user's own entries on top of this.
|
|
23
|
+
_BASELINE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
|
24
|
+
|
|
25
|
+
# Identity and locale variables that describe the *user*, not the submitting
|
|
26
|
+
# process, so they are safe to carry into a non-inheriting job.
|
|
27
|
+
_BASELINE_KEYS = ("HOME", "USER", "LOGNAME", "SHELL", "LANG", "TERM")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _baseline_env(env: dict[str, str] | None) -> dict[str, str]:
|
|
31
|
+
"""Minimal job environment for inherit_env=False submissions."""
|
|
32
|
+
base = {k: os.environ[k] for k in _BASELINE_KEYS if k in os.environ}
|
|
33
|
+
base["PATH"] = _BASELINE_PATH
|
|
34
|
+
return {**base, **(env or {})}
|
|
35
|
+
|
|
20
36
|
|
|
21
37
|
class LocalExecutor(Executor):
|
|
22
38
|
"""Runs jobs as local bash subprocesses. Useful for testing."""
|
|
@@ -49,6 +65,8 @@ class LocalExecutor(Executor):
|
|
|
49
65
|
env: dict[str, str] | None = None,
|
|
50
66
|
*,
|
|
51
67
|
cwd: str | None = None,
|
|
68
|
+
inherit_env: bool = True,
|
|
69
|
+
login_shell: bool = False,
|
|
52
70
|
) -> tuple[str, str | None]:
|
|
53
71
|
"""Render script, write to disk, run as a background subprocess."""
|
|
54
72
|
header = self.build_header(name, resources)
|
|
@@ -58,13 +76,17 @@ class LocalExecutor(Executor):
|
|
|
58
76
|
job_id = str(self._next_id)
|
|
59
77
|
self._next_id += 1
|
|
60
78
|
|
|
61
|
-
|
|
79
|
+
if inherit_env:
|
|
80
|
+
full_env = {**os.environ, **(env or {})}
|
|
81
|
+
else:
|
|
82
|
+
full_env = _baseline_env(env)
|
|
83
|
+
bash_cmd = ["bash", "-l", script_path] if login_shell else ["bash", script_path]
|
|
62
84
|
|
|
63
85
|
# Write stdout/stderr directly to log files for real-time access
|
|
64
86
|
stdout_dest, stderr_dest = self._open_output_files(resources, job_id=job_id)
|
|
65
87
|
|
|
66
88
|
proc = await asyncio.create_subprocess_exec(
|
|
67
|
-
|
|
89
|
+
*bash_cmd,
|
|
68
90
|
stdout=stdout_dest,
|
|
69
91
|
stderr=stderr_dest,
|
|
70
92
|
env=full_env,
|
|
@@ -86,6 +108,8 @@ class LocalExecutor(Executor):
|
|
|
86
108
|
max_concurrent: int | None = None,
|
|
87
109
|
*,
|
|
88
110
|
cwd: str | None = None,
|
|
111
|
+
inherit_env: bool = True,
|
|
112
|
+
login_shell: bool = False,
|
|
89
113
|
) -> tuple[str, str | None]:
|
|
90
114
|
"""Spawn one subprocess per array element with ARRAY_INDEX env var."""
|
|
91
115
|
if max_concurrent is not None:
|
|
@@ -101,7 +125,11 @@ class LocalExecutor(Executor):
|
|
|
101
125
|
job_id = str(self._next_id)
|
|
102
126
|
self._next_id += 1
|
|
103
127
|
|
|
104
|
-
|
|
128
|
+
if inherit_env:
|
|
129
|
+
full_env = {**os.environ, **(env or {})}
|
|
130
|
+
else:
|
|
131
|
+
full_env = _baseline_env(env)
|
|
132
|
+
bash_cmd = ["bash", "-l", script_path] if login_shell else ["bash", script_path]
|
|
105
133
|
|
|
106
134
|
for index in range(array_range[0], array_range[1] + 1):
|
|
107
135
|
element_env = {**full_env, "ARRAY_INDEX": str(index)}
|
|
@@ -109,7 +137,7 @@ class LocalExecutor(Executor):
|
|
|
109
137
|
resources, job_id=job_id, element_index=index,
|
|
110
138
|
)
|
|
111
139
|
proc = await asyncio.create_subprocess_exec(
|
|
112
|
-
|
|
140
|
+
*bash_cmd,
|
|
113
141
|
stdout=stdout_dest,
|
|
114
142
|
stderr=stderr_dest,
|
|
115
143
|
env=element_env,
|
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import logging
|
|
9
9
|
import math
|
|
10
10
|
import re
|
|
11
|
+
import shlex
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -134,6 +135,33 @@ class LSFExecutor(Executor):
|
|
|
134
135
|
args.extend(resources.extra_args)
|
|
135
136
|
return args
|
|
136
137
|
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _env_control_args(inherit_env: bool, login_shell: bool) -> list[str]:
|
|
140
|
+
"""bsub args controlling the job's starting environment."""
|
|
141
|
+
args: list[str] = []
|
|
142
|
+
if not inherit_env:
|
|
143
|
+
# Don't copy the submitting process's environment into the job;
|
|
144
|
+
# it starts from scheduler-provided variables only.
|
|
145
|
+
args.extend(["-env", "none"])
|
|
146
|
+
if login_shell:
|
|
147
|
+
# Initialize the execution environment through the user's login
|
|
148
|
+
# shell, as an SSH session to the node would.
|
|
149
|
+
args.extend(["-L", "/bin/bash"])
|
|
150
|
+
return args
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _env_prologue(env: dict[str, str] | None, inherit_env: bool,
|
|
154
|
+
prologue: list[str] | None) -> list[str] | None:
|
|
155
|
+
"""Fold explicit env vars into the script when bsub won't copy them.
|
|
156
|
+
|
|
157
|
+
With ``-env none`` the submission environment never reaches the job,
|
|
158
|
+
so variables passed via *env* must be exported by the script itself.
|
|
159
|
+
"""
|
|
160
|
+
if inherit_env or not env:
|
|
161
|
+
return prologue
|
|
162
|
+
exports = [f"export {k}={shlex.quote(v)}" for k, v in env.items()]
|
|
163
|
+
return [*exports, *(prologue or [])]
|
|
164
|
+
|
|
137
165
|
async def _bsub(
|
|
138
166
|
self, script_path: str, env: dict[str, str] | None,
|
|
139
167
|
extra_args: list[str] | None = None,
|
|
@@ -158,13 +186,17 @@ class LSFExecutor(Executor):
|
|
|
158
186
|
env: dict[str, str] | None = None,
|
|
159
187
|
*,
|
|
160
188
|
cwd: str | None = None,
|
|
189
|
+
inherit_env: bool = True,
|
|
190
|
+
login_shell: bool = False,
|
|
161
191
|
) -> tuple[str, str | None]:
|
|
162
192
|
"""Render script, write to disk, submit via bsub."""
|
|
163
193
|
header = self.build_header(name, resources)
|
|
194
|
+
prologue = self._env_prologue(env, inherit_env, prologue)
|
|
164
195
|
script = render_script(self.config, command, header, prologue, epilogue)
|
|
165
196
|
script_path = write_script(resources.work_dir, script, name, next(self._script_counter))
|
|
166
197
|
|
|
167
198
|
extra_args = self._collect_extra_args(resources)
|
|
199
|
+
extra_args.extend(self._env_control_args(inherit_env, login_shell))
|
|
168
200
|
out = await self._bsub(script_path, env, extra_args)
|
|
169
201
|
return self._job_id_from_submit_output(out), script_path
|
|
170
202
|
|
|
@@ -180,9 +212,12 @@ class LSFExecutor(Executor):
|
|
|
180
212
|
max_concurrent: int | None = None,
|
|
181
213
|
*,
|
|
182
214
|
cwd: str | None = None,
|
|
215
|
+
inherit_env: bool = True,
|
|
216
|
+
login_shell: bool = False,
|
|
183
217
|
) -> tuple[str, str | None]:
|
|
184
218
|
"""Render script, rewrite for array syntax, submit via bsub."""
|
|
185
219
|
header = self.build_header(name, resources)
|
|
220
|
+
prologue = self._env_prologue(env, inherit_env, prologue)
|
|
186
221
|
script = render_script(self.config, command, header, prologue, epilogue)
|
|
187
222
|
script_path = write_script(resources.work_dir, script, name, next(self._script_counter))
|
|
188
223
|
|
|
@@ -206,20 +241,31 @@ class LSFExecutor(Executor):
|
|
|
206
241
|
f.write(content)
|
|
207
242
|
|
|
208
243
|
extra_args = self._collect_extra_args(resources)
|
|
244
|
+
extra_args.extend(self._env_control_args(inherit_env, login_shell))
|
|
209
245
|
out = await self._bsub(script_path, env, extra_args)
|
|
210
246
|
return self._job_id_from_submit_output(out), script_path
|
|
211
247
|
|
|
212
248
|
def _build_status_args(self) -> list[str]:
|
|
213
|
-
"""Build bjobs command with JSON output.
|
|
249
|
+
"""Build bjobs command with JSON output.
|
|
250
|
+
|
|
251
|
+
When jobs are being tracked, always query their IDs explicitly: LSF
|
|
252
|
+
resolves explicit job IDs across user boundaries, whereas a
|
|
253
|
+
name-filtered query (``-J``) returns only the calling user's jobs
|
|
254
|
+
without ``-u all`` — so polling other users' jobs through one user's
|
|
255
|
+
account would silently miss them. Combining ``-J`` with explicit IDs
|
|
256
|
+
is an intersection, which would hide any tracked job whose name does
|
|
257
|
+
not match the prefix, so the prefix filter is applied only when there
|
|
258
|
+
is nothing tracked yet; :meth:`reconnect` has its own query builder
|
|
259
|
+
for name-based discovery.
|
|
260
|
+
"""
|
|
214
261
|
args = [self.status_command]
|
|
215
262
|
if self.config.poll_all_users:
|
|
216
263
|
args.extend(["-u", "all"])
|
|
217
|
-
|
|
264
|
+
active_ids = [jid for jid, r in self._jobs.items() if not r.is_terminal]
|
|
265
|
+
if not active_ids and self._prefix:
|
|
218
266
|
args.extend(["-J", f"{self._prefix}-*"])
|
|
219
267
|
args.extend(["-a", "-o", _BJOBS_FIELDS, "-json"])
|
|
220
|
-
|
|
221
|
-
active_ids = [jid for jid, r in self._jobs.items() if not r.is_terminal]
|
|
222
|
-
args.extend(active_ids)
|
|
268
|
+
args.extend(active_ids)
|
|
223
269
|
return args
|
|
224
270
|
|
|
225
271
|
def _parse_job_statuses(
|