Skip to content

Commit c5e700b

Browse files
authored
Fix rmg_env_command dropping PYTHONPATH needed for source-tree RMG-Py (#994)
## Failure mode `rmg_env_command()`'s `RMG_PYTHON` branch (direct-interpreter invocation, used on conda/mambaforge installs where micromamba's `conda` shim is broken) unsets `_ARC_ENV_ACTIVATION_VARS` before invoking `rmg_env`'s interpreter, to scrub ARC's env leakage (`BABEL_LIBDIR`, `LD_LIBRARY_PATH`, `CONDA_*`, etc. — otherwise bound to `arc_env`'s tree and causing ABI-mismatch crashes). `PYTHONPATH` is included in that unset list. That's correct when RMG-Py/Arkane are pip-installed into `rmg_env`. But on a host where RMG-Py/Arkane are used from a **source checkout reachable only via `PYTHONPATH`**, unsetting it and stopping there leaves the child interpreter unable to import `rmgpy`/`arkane` at all. Every call through this branch dies with: ``` /home/alon/anaconda3/envs/rmg_env/bin/python: No module named arkane ``` Real-run evidence: `/home/alon/runs/t3-pes-CH2O2/iteration_1/PDep_SA/network1_1/MSC/stderr.log` contains exactly that line. Consequence: T3's master-equation sensitivity job can never run on such a host, so no P-dep network can ever qualify for QM refinement — the entire PDep→QM feature is silently dead, surfaced only as the bland "No PDep networks qualified for QM refinement". ## Why unsetting PYTHONPATH is still right Leaving ARC's own `PYTHONPATH` bound in the child is exactly the kind of env leakage this branch exists to scrub (see the module docstring / the `_ARC_ENV_ACTIVATION_VARS` comment) — a stale entry (e.g. an old checkout on the caller's `PYTHONPATH`) could shadow `rmg_env`'s own site-packages or ARC's own modules could bleed into the child. The unset is correct; the bug is stopping there instead of restoring the *target* env's own path. ## The fix After the unset, re-export `PYTHONPATH` from ARC's own `RMG_PATH` setting (already resolved in `arc/settings/settings.py`, alongside `RMG_PYTHON`), shell-quoted like the neighbouring lines, and only when `RMG_PATH` is truthy (never emit an empty `export PYTHONPATH=`, which would be worse than leaving it unset): ```bash unset CONDA_PREFIX ... PYTHONPATH PYTHONHOME export PATH=/home/alon/anaconda3/envs/rmg_env/bin:"$PATH" export PYTHONPATH=/home/alon/Code/RMG-Py-t3pes # <-- restored /home/alon/anaconda3/envs/rmg_env/bin/python -c "import arkane; print(arkane.__file__)" # -> arkane OK ``` Verified manually on the affected host before implementing. ## The other two branches - `MAMBA_EXE` branch and the launcher-hunt (`bash -l`) branch never unset `PYTHONPATH` in the first place — they route through a launcher's `run` (which re-fires the target env's own activation hooks) or a login shell that sources the user's profile. Neither has this gap, so neither was changed. ## Test plan - [x] Baseline: `python -m pytest arc/job/env_run_test.py` — 36 passed (unmodified branch) - [x] Post-fix: same command — 38 passed (36 + 2 new regression tests) - [x] New tests assert `PYTHONPATH` is re-exported from `RMG_PATH` (after the `unset`) in the `RMG_PYTHON` branch, and that nothing is emitted when `RMG_PATH` is falsy. Settings are patched via `patch.dict`, so the tests don't depend on this machine's real paths. - [x] `ruff check arc/job/env_run.py arc/job/env_run_test.py` — clean
2 parents 8317303 + ea8f3c2 commit c5e700b

2 files changed

Lines changed: 128 additions & 13 deletions

File tree

arc/job/env_run.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,16 @@ def run_in_conda_env(
168168
# target env, but invoking rmg_env's interpreter *directly* does not: they stay
169169
# pointed at arc_env's tree and make the child resolve shared-library plugins
170170
# against the wrong env, which is what silently kills Arkane's OpenBabel
171-
# import. Only the direct-interpreter branch below has to scrub them.
172-
# PYTHONPATH is included because the RMG helper scripts in arc/scripts/ import
173-
# only rmgpy/arkane/rdkit plus a sibling ``common`` module (found via the
174-
# script's own directory), so nothing there needs ARC on the path -- while a
175-
# stale entry would shadow rmg_env's site-packages.
171+
# import. Only the direct-interpreter branch below has to scrub this whole set;
172+
# the launcher branches leave the ABI-sensitive vars to ``run``'s re-activation.
173+
# PYTHONPATH is the exception: a launcher's ``run`` re-fires the target env's
174+
# activation hooks but does NOT manage PYTHONPATH, so ARC's own PYTHONPATH would
175+
# leak into the child on *every* branch and shadow rmg_env's site-packages.
176+
# ``_pythonpath_lines`` therefore normalizes it uniformly across all three
177+
# branches. It is kept in this tuple too so the direct-interpreter branch scrubs
178+
# it as part of the single ``unset``; the RMG helper scripts in arc/scripts/
179+
# import only rmgpy/arkane/rdkit plus a sibling ``common`` module (found via the
180+
# script's own directory), so nothing there needs ARC on the path.
176181
_ARC_ENV_ACTIVATION_VARS = (
177182
'CONDA_PREFIX', 'CONDA_PREFIX_1', 'CONDA_DEFAULT_ENV', 'CONDA_PROMPT_MODIFIER',
178183
'CONDA_SHLVL', 'LD_LIBRARY_PATH', 'BABEL_LIBDIR', 'BABEL_DATADIR',
@@ -182,6 +187,31 @@ def run_in_conda_env(
182187
_NO_LAUNCHER_MSG = 'micromamba, mamba, or conda is required to run RMG helper scripts'
183188

184189

190+
def _pythonpath_lines(rmg_path: str | None) -> list[str]:
191+
"""Bash lines that normalize ``PYTHONPATH`` for the RMG child.
192+
193+
A launcher's ``run`` rebinds the ABI-sensitive activation vars but never
194+
manages ``PYTHONPATH``, and the direct-interpreter branch has no launcher at
195+
all -- so on every branch ARC's own ``PYTHONPATH`` would otherwise leak into
196+
the child. Scrub it, then re-point it at a source-tree RMG-Py/Arkane checkout
197+
when ``RMG_PATH`` is configured (reachable only via ``PYTHONPATH``, rather
198+
than pip-installed into rmg_env), giving all three branches one and the same
199+
``PYTHONPATH`` policy.
200+
201+
Args:
202+
rmg_path (str | None): ARC's configured ``RMG_PATH``, or a falsy value
203+
when RMG-Py/Arkane come from rmg_env's own
204+
site-packages.
205+
206+
Returns:
207+
list[str]: A single bash line -- ``export PYTHONPATH=<rmg_path>`` when
208+
``rmg_path`` is truthy, else ``unset PYTHONPATH``.
209+
"""
210+
if rmg_path:
211+
return [f'export PYTHONPATH={shlex.quote(rmg_path)}']
212+
return ['unset PYTHONPATH']
213+
214+
185215
def rmg_env_command(py_args: str | list[str],
186216
cwd: str | None = None,
187217
env_vars: dict[str, str] | None = None,
@@ -196,9 +226,19 @@ def rmg_env_command(py_args: str | list[str],
196226
Resolution order, preserved from the call sites this replaced:
197227
``MAMBA_EXE`` (exported by setup-micromamba in CI) → ``RMG_PYTHON`` from
198228
ARC's settings (needed on conda/mambaforge installs where micromamba's
199-
``conda`` shim is broken) → a launcher found on PATH, hunted for under a
200-
login shell so that a conda initialization block in the user's profile is
201-
still honoured.
229+
``conda`` shim is broken; this branch invokes the interpreter directly, so
230+
it also has to scrub ARC's leaked activation vars) → a launcher found on
231+
PATH, hunted for under a login shell so that a conda initialization block in
232+
the user's profile is still honoured.
233+
234+
Every branch normalizes ``PYTHONPATH`` identically via ``_pythonpath_lines``
235+
(``RMG_PATH`` when set, scrubbed otherwise): a launcher's ``run`` re-fires
236+
the target env's activation hooks but never manages ``PYTHONPATH``, so
237+
without this ARC's own ``PYTHONPATH`` would leak into the child on the two
238+
launcher branches -- and on all three a source-tree RMG-Py/Arkane checkout
239+
reachable only via ``PYTHONPATH`` would be invisible. The direct-interpreter
240+
branch additionally scrubs the ABI-sensitive vars (``LD_LIBRARY_PATH``,
241+
``BABEL_LIBDIR``, ...) that the launcher branches leave to ``run``.
202242
203243
Args:
204244
py_args (str | list[str]): Everything after ``python``. Passing a
@@ -224,6 +264,7 @@ def rmg_env_command(py_args: str | list[str],
224264
"""
225265
env_name = settings.get('RMG_ENV_NAME', 'rmg_env')
226266
rmg_python = settings.get('RMG_PYTHON')
267+
rmg_path = settings.get('RMG_PATH')
227268
if isinstance(py_args, list):
228269
py_args = ' '.join(shlex.quote(arg) for arg in py_args)
229270

@@ -235,23 +276,25 @@ def rmg_env_command(py_args: str | list[str],
235276

236277
mamba_exe = os.environ.get('MAMBA_EXE', '')
237278
if mamba_exe and os.path.isfile(mamba_exe):
238-
return '\n'.join(preamble + [
279+
return '\n'.join(preamble + _pythonpath_lines(rmg_path) + [
239280
f'{shlex.quote(mamba_exe)} run -n {env_name} python {py_args}{suffix}',
240281
])
241282

242283
if rmg_python and os.path.isfile(rmg_python):
243-
return '\n'.join(preamble + [
284+
lines = preamble + [
244285
f'unset {" ".join(_ARC_ENV_ACTIVATION_VARS)}',
245286
f'export PATH={shlex.quote(os.path.dirname(rmg_python))}:"$PATH"',
246-
f'{shlex.quote(rmg_python)} {py_args}{suffix}',
247-
])
287+
]
288+
lines += _pythonpath_lines(rmg_path)
289+
lines.append(f'{shlex.quote(rmg_python)} {py_args}{suffix}')
290+
return '\n'.join(lines)
248291

249292
# No launcher pinned by an env var and no configured interpreter: hunt for a
250293
# launcher on PATH. This runs under ``bash -l`` so the user's profile (where
251294
# conda's init block usually lives) is sourced first. The script is fed on
252295
# stdin via a quoted heredoc, which keeps it free of the nested shell
253296
# quoting the per-call-site copies of this ladder each had to get right.
254-
hunted = preamble + [
297+
hunted = preamble + _pythonpath_lines(rmg_path) + [
255298
'for _launcher in micromamba mamba conda; do',
256299
' if command -v "$_launcher" >/dev/null 2>&1; then',
257300
f' "$_launcher" run -n {env_name} python {py_args}{suffix}',

arc/job/env_run_test.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,5 +384,77 @@ def test_py_args_list_arkane_module_invocation_three_tokens(self):
384384
self.assertIn('python -m arkane input.py', script)
385385

386386

387+
class TestRmgEnvCommandPythonPath(unittest.TestCase):
388+
"""PYTHONPATH is normalized identically on all three branches: a launcher's
389+
``run`` never manages it, and the direct-interpreter branch has no launcher,
390+
so ARC's own PYTHONPATH would otherwise leak into the child. When RMG_PATH is
391+
set the child gets exactly that (so a source-tree RMG-Py/Arkane checkout
392+
reachable only via PYTHONPATH is importable); otherwise PYTHONPATH is
393+
scrubbed so rmg_env's own site-packages win."""
394+
395+
def setUp(self):
396+
# Force the RMG_PYTHON branch: no MAMBA_EXE, and RMG_PYTHON resolves
397+
# to a real file (RMG_PATH/RMG_PYTHON come from the patched settings
398+
# dict below; os.path.isfile still needs a real path on disk, so
399+
# point it at this test file itself).
400+
self.env_patch = patch.dict(os.environ, {}, clear=False)
401+
self.env_patch.start()
402+
self.addCleanup(self.env_patch.stop)
403+
os.environ.pop('MAMBA_EXE', None)
404+
self.fake_rmg_python = __file__
405+
406+
def test_pythonpath_reexported_from_rmg_path(self):
407+
settings_overrides = {'RMG_ENV_NAME': 'rmg_env', 'RMG_PYTHON': self.fake_rmg_python,
408+
'RMG_PATH': '/opt/RMG-Py'}
409+
with patch.dict('arc.job.env_run.settings', settings_overrides):
410+
script = rmg_env_command("-c 'pass'")
411+
self.assertIn(f'export PYTHONPATH={shlex.quote("/opt/RMG-Py")}', script)
412+
# The re-export must come after the unset, so it is not clobbered.
413+
unset_idx = script.index('unset ')
414+
export_idx = script.index('export PYTHONPATH=')
415+
self.assertLess(unset_idx, export_idx)
416+
417+
def test_no_pythonpath_export_when_rmg_path_falsy(self):
418+
settings_overrides = {'RMG_ENV_NAME': 'rmg_env', 'RMG_PYTHON': self.fake_rmg_python,
419+
'RMG_PATH': None}
420+
with patch.dict('arc.job.env_run.settings', settings_overrides):
421+
script = rmg_env_command("-c 'pass'")
422+
self.assertNotIn('export PYTHONPATH=', script)
423+
424+
def test_mamba_branch_exports_pythonpath_from_rmg_path(self):
425+
os.environ['MAMBA_EXE'] = __file__ # a real file forces the MAMBA_EXE branch
426+
settings_overrides = {'RMG_ENV_NAME': 'rmg_env', 'RMG_PATH': '/opt/RMG-Py'}
427+
with patch.dict('arc.job.env_run.settings', settings_overrides):
428+
script = rmg_env_command("-c 'pass'")
429+
self.assertIn(f'export PYTHONPATH={shlex.quote("/opt/RMG-Py")}', script)
430+
# Set before the launcher's ``run`` so the child inherits it.
431+
self.assertLess(script.index('export PYTHONPATH='), script.index('run -n rmg_env'))
432+
433+
def test_mamba_branch_scrubs_pythonpath_when_rmg_path_falsy(self):
434+
os.environ['MAMBA_EXE'] = __file__
435+
settings_overrides = {'RMG_ENV_NAME': 'rmg_env', 'RMG_PATH': None}
436+
with patch.dict('arc.job.env_run.settings', settings_overrides):
437+
script = rmg_env_command("-c 'pass'")
438+
self.assertIn('unset PYTHONPATH', script)
439+
self.assertNotIn('export PYTHONPATH=', script)
440+
441+
def test_hunt_branch_exports_pythonpath_from_rmg_path(self):
442+
os.environ.pop('MAMBA_EXE', None) # no MAMBA_EXE + no RMG_PYTHON forces the hunt branch
443+
settings_overrides = {'RMG_ENV_NAME': 'rmg_env', 'RMG_PYTHON': None, 'RMG_PATH': '/opt/RMG-Py'}
444+
with patch.dict('arc.job.env_run.settings', settings_overrides):
445+
script = rmg_env_command("-c 'pass'")
446+
self.assertIn(f'export PYTHONPATH={shlex.quote("/opt/RMG-Py")}', script)
447+
# Set inside the login-shell heredoc, before the launcher-hunt loop.
448+
self.assertLess(script.index('export PYTHONPATH='), script.index('for _launcher in'))
449+
450+
def test_hunt_branch_scrubs_pythonpath_when_rmg_path_falsy(self):
451+
os.environ.pop('MAMBA_EXE', None)
452+
settings_overrides = {'RMG_ENV_NAME': 'rmg_env', 'RMG_PYTHON': None, 'RMG_PATH': None}
453+
with patch.dict('arc.job.env_run.settings', settings_overrides):
454+
script = rmg_env_command("-c 'pass'")
455+
self.assertIn('unset PYTHONPATH', script)
456+
self.assertNotIn('export PYTHONPATH=', script)
457+
458+
387459
if __name__ == '__main__':
388460
unittest.main()

0 commit comments

Comments
 (0)