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.
Files changed (31) hide show
  1. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/.github/workflows/ci.yml +1 -1
  2. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/PKG-INFO +1 -1
  3. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/core.py +22 -1
  4. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/executors/local.py +32 -4
  5. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/executors/lsf.py +51 -5
  6. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/pixi.lock +539 -543
  7. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/pyproject.toml +1 -1
  8. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_local.py +48 -0
  9. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_lsf.py +74 -0
  10. py_cluster_api-0.7.0/unknown-fix.md +131 -0
  11. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/.gitignore +0 -0
  12. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/CLAUDE.md +0 -0
  13. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/LICENSE +0 -0
  14. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/README.md +0 -0
  15. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/__init__.py +0 -0
  16. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/_types.py +0 -0
  17. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/config.py +0 -0
  18. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/exceptions.py +0 -0
  19. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/executors/__init__.py +0 -0
  20. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/monitor.py +0 -0
  21. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/cluster_api/script.py +0 -0
  22. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/docs/API.md +0 -0
  23. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/docs/Development.md +0 -0
  24. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/__init__.py +0 -0
  25. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/cluster_config.example.yaml +0 -0
  26. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/conftest.py +0 -0
  27. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_config.py +0 -0
  28. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_core.py +0 -0
  29. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_integration.py +0 -0
  30. {py_cluster_api-0.6.0 → py_cluster_api-0.7.0}/tests/test_monitor.py +0 -0
  31. {py_cluster_api-0.6.0 → py_cluster_api-0.7.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-cluster-api
3
- Version: 0.6.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
- full_env = {**os.environ, **(env or {})}
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
- "bash", script_path,
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
- full_env = {**os.environ, **(env or {})}
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
- "bash", script_path,
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
- if self._prefix:
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
- if not self._prefix:
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(