Skip to content

Commit e8cdba4

Browse files
committed
PoC, use dedicated contextes for environment
1 parent 00eb94f commit e8cdba4

File tree

6 files changed

+191
-15
lines changed

6 files changed

+191
-15
lines changed

easybuild/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949

5050
# IMPORTANT this has to be the first easybuild import as it customises the logging
5151
# expect missing log output when this not the case!
52+
from easybuild import os_hook # Imported to inject hook that replaces system os with our wrapped version
53+
os_hook.install_os_hook()
54+
5255
from easybuild.tools.build_log import EasyBuildError, print_error_and_exit, print_msg, print_warning, stop_logging
5356
from easybuild.tools.build_log import EasyBuildExit
5457

easybuild/os_hook.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import importlib
2+
import importlib.abc
3+
import importlib.util
4+
import sys
5+
import types
6+
7+
8+
class OSProxy(types.ModuleType):
9+
"""Proxy module to intercept os attribute access."""
10+
overrides = {}
11+
def __init__(self, real_os):
12+
super().__init__("os")
13+
self._real_os = real_os
14+
15+
def __getattr__(self, name):
16+
# Intercept specific attributes
17+
return OSProxy.overrides.get(name, getattr(self._real_os, name))
18+
19+
def __dir__(self):
20+
return dir(self._real_os)
21+
22+
@classmethod
23+
def register_override(cls, name, value):
24+
cls.overrides[name] = value
25+
26+
class OSFinder(importlib.abc.MetaPathFinder):
27+
"""Meta path finder to intercept imports of 'os' and return our proxy."""
28+
def find_spec(self, fullname, path, target=None):
29+
if fullname == "os":
30+
return importlib.util.spec_from_loader(fullname, OSLoader())
31+
return None
32+
33+
34+
class OSLoader(importlib.abc.Loader):
35+
"""Loader to create our OSProxy instead of the real os module."""
36+
def create_module(self, spec):
37+
# Import real os safely
38+
sys.meta_path = [f for f in sys.meta_path if not isinstance(f, OSFinder)]
39+
real_os = importlib.import_module("os")
40+
sys.meta_path.insert(0, OSFinder())
41+
42+
# Return proxy instead of real module
43+
return OSProxy(real_os)
44+
45+
46+
def install_os_hook():
47+
"""Install the os hooking mechanism to intercept imports of 'os' and return our proxy."""
48+
sys.meta_path.insert(0, OSFinder())
49+
50+
# If already imported, replace in place
51+
if "os" in sys.modules:
52+
real_os = sys.modules["os"]
53+
sys.modules["os"] = OSProxy(real_os)

easybuild/tools/environment.py

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
"""
3333
import copy
3434
import os
35+
from contextlib import contextmanager
3536

37+
from easybuild.os_hook import OSProxy
3638
from easybuild.base import fancylogger
3739
from easybuild.tools.build_log import EasyBuildError, dry_run_msg
3840
from easybuild.tools.config import build_option
@@ -45,16 +47,35 @@
4547

4648
_log = fancylogger.getLogger('environment', fname=False)
4749

48-
_changes = {}
50+
_contextes = {'': {}}
51+
_curr_context = ''
4952

53+
def set_context(context_name, context = {}):
54+
"""
55+
Set context for tracking environment changes.
56+
"""
57+
global _curr_context
58+
_curr_context = context_name
59+
if context_name not in _contextes:
60+
if context is not None:
61+
context = copy.deepcopy(context)
62+
else:
63+
context = {}
64+
_contextes[context_name] = context
65+
66+
def get_context():
67+
"""
68+
Return current context for tracking environment changes.
69+
"""
70+
return _contextes[_curr_context]
5071

5172
def write_changes(filename):
5273
"""
5374
Write current changes to filename and reset environment afterwards
5475
"""
5576
try:
5677
with open(filename, 'w') as script:
57-
for key, changed_value in _changes.items():
78+
for key, changed_value in get_context().items():
5879
script.write('export %s=%s\n' % (key, shell_quote(changed_value)))
5980
except IOError as err:
6081
raise EasyBuildError("Failed to write to %s: %s", filename, err)
@@ -65,32 +86,79 @@ def reset_changes():
6586
"""
6687
Reset the changes tracked by this module
6788
"""
68-
global _changes
69-
_changes = {}
89+
get_context().clear()
7090

7191

7292
def get_changes():
7393
"""
7494
Return tracked changes made in environment.
7595
"""
76-
return _changes
96+
return get_context()
97+
98+
def apply_context(context = None):
99+
"""Return the current environment with the changes tracked in the context applied.
100+
101+
Args:
102+
context (str, optional): The context to apply. Defaults to the current onee.
103+
"""
104+
if context is None:
105+
context = _curr_context
106+
changes = get_context()
107+
# print(f'Applying context {context} with changes: {changes}')
108+
curr_env = ORIG_OS_ENVIRON.copy()
109+
for key, changed_value in changes.items():
110+
if changed_value is None:
111+
curr_env.pop(key, None)
112+
else:
113+
curr_env[key] = changed_value
114+
return curr_env
115+
116+
117+
def getvar(key, default=''):
118+
"""
119+
Return value of key in the environment, or default if not found
120+
"""
121+
return get_context().get(key, os._real_os.environ.get(key, default))
122+
123+
124+
@contextmanager
125+
def with_environment(copy_current=False):
126+
"""Context manager to run code in a dedicated context"""
127+
# Get a key that does not exist in _contextes
128+
base = '_context_'
129+
cnt = 0
130+
while (context := f"{base}{cnt}") in _contextes:
131+
cnt += 1
132+
133+
prev_context = _curr_context
134+
kwargs = {}
135+
if copy_current:
136+
kwargs['context'] = get_context()
137+
set_context(context, **kwargs)
138+
try:
139+
yield
140+
finally:
141+
set_context(prev_context)
142+
_contextes.pop(context, None)
77143

78144

79-
def setvar(key, value, verbose=True, log_changes=True):
145+
def setvar(key, value, verbose=True, log_changes=True, force_env=False):
80146
"""
81147
put key in the environment with value
82148
tracks added keys until write_changes has been called
83149
84150
:param verbose: include message in dry run output for defining this environment variable
85151
:param log_changes: show the change in the log
152+
:param force_env: if True, also set the variable in os.environ, eg for calls to python functions that rely
153+
on some specific environment such as TMPDIR for tempfile.
86154
"""
87155
try:
88156
oldval_info = "previous value: '%s'" % os.environ[key]
89157
except KeyError:
90158
oldval_info = "previously undefined"
91-
# os.putenv() is not necessary. os.environ will call this.
92-
os.environ[key] = value
93-
_changes[key] = value
159+
if force_env:
160+
os._real_os.environ[key] = value
161+
get_context()[key] = value
94162
if log_changes:
95163
_log.info("Environment variable %s set to %s (%s)", key, value, oldval_info)
96164

@@ -101,7 +169,27 @@ def setvar(key, value, verbose=True, log_changes=True):
101169
dry_run_msg(" export %s=%s" % (key, quoted_value), silent=build_option('silent'))
102170

103171

104-
def unset_env_vars(keys, verbose=True):
172+
def appendvar(key, value, sep=os.pathsep, verbose=True, log_changes=True, force_env=False):
173+
"""append value to key in the environment, using sep as separator"""
174+
oldval = apply_context().get(key)
175+
if oldval:
176+
newval = oldval + sep + value
177+
else:
178+
newval = value
179+
setvar(key, newval, verbose=verbose, log_changes=log_changes, force_env=force_env)
180+
181+
182+
def prependvar(key, value, sep=os.pathsep, verbose=True, log_changes=True, force_env=False):
183+
"""prepend value to key in the environment, using sep as separator"""
184+
oldval = apply_context().get(key)
185+
if oldval:
186+
newval = value + sep + oldval
187+
else:
188+
newval = value
189+
setvar(key, newval, verbose=verbose, log_changes=log_changes, force_env=force_env)
190+
191+
192+
def unset_env_vars(keys, verbose=True, force_env=False):
105193
"""
106194
Unset the keys given in the environment
107195
Returns a dict with the old values of the unset keys
@@ -115,7 +203,9 @@ def unset_env_vars(keys, verbose=True):
115203
if key in os.environ:
116204
_log.info("Unsetting environment variable %s (value: %s)" % (key, os.environ[key]))
117205
old_environ[key] = os.environ[key]
118-
del os.environ[key]
206+
if force_env:
207+
del os._real_os.environ[key]
208+
get_context()[key] = None
119209
if verbose and build_option('extended_dry_run'):
120210
dry_run_msg(" unset %s # value was: %s" % (key, old_environ[key]), silent=build_option('silent'))
121211

@@ -223,3 +313,29 @@ def sanitize_env():
223313
# unset all $PYTHON* environment variables
224314
keys_to_unset = [key for key in os.environ if key.startswith('PYTHON')]
225315
unset_env_vars(keys_to_unset, verbose=False)
316+
317+
318+
class MockEnviron(dict):
319+
"""Hook into os.environ and replace it with calls from this module to track changes to the environment."""
320+
def __getitem__(self, key):
321+
return getvar(key)
322+
323+
def __setitem__(self, key, value):
324+
setvar(key, value, verbose=False, log_changes=False)
325+
326+
def __delitem__(self, key):
327+
unset_env_vars([key], verbose=False)
328+
329+
def get(self, key, default=None):
330+
return getvar(key, default)
331+
332+
def copy(self):
333+
return apply_context()
334+
335+
def __deepcopy__(self, memo):
336+
return apply_context()
337+
338+
339+
OSProxy.register_override('environ', MockEnviron())
340+
OSProxy.register_override('getenv', lambda key, default=None: getvar(key, default))
341+
OSProxy.register_override('unsetenv', lambda key: unset_env_vars([key], verbose=False))

easybuild/tools/options.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2148,7 +2148,7 @@ def set_tmpdir(tmpdir=None, raise_error=False):
21482148
_log.info("Temporary directory used in this EasyBuild run: %s" % current_tmpdir)
21492149

21502150
for var in ['TMPDIR', 'TEMP', 'TMP']:
2151-
env.setvar(var, current_tmpdir, verbose=False)
2151+
env.setvar(var, current_tmpdir, verbose=False, force_env=True)
21522152

21532153
# reset to make sure tempfile picks up new temporary directory to use
21542154
tempfile.tempdir = None

easybuild/tools/run.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
from easybuild.tools.hooks import RUN_SHELL_CMD, load_hooks, run_hook
6969
from easybuild.tools.output import COLOR_RED, COLOR_YELLOW, colorize, escape_for_rich, print_error
7070
from easybuild.tools.utilities import trace_msg
71+
from easybuild.tools.environment import apply_context
7172

7273

7374
_log = fancylogger.getLogger('run', fname=False)
@@ -406,6 +407,9 @@ def to_cmd_str(cmd):
406407

407408
return cmd_str
408409

410+
if env is None:
411+
env = apply_context()
412+
409413
# make sure that qa_patterns is a list of 2-tuples (not a dict, or something else)
410414
if qa_patterns:
411415
if not isinstance(qa_patterns, list) or any(not isinstance(x, tuple) or len(x) != 2 for x in qa_patterns):

easybuild/tools/toolchain/toolchain.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
from easybuild.base import fancylogger
6262
from easybuild.tools.build_log import EasyBuildError, dry_run_msg, print_warning
6363
from easybuild.tools.config import build_option, install_path
64-
from easybuild.tools.environment import setvar
64+
from easybuild.tools.environment import setvar, prependvar
6565
from easybuild.tools.filetools import adjust_permissions, copy_file, find_eb_script, mkdir, read_file, which, write_file
6666
from easybuild.tools.module_generator import dependencies_for
6767
from easybuild.tools.modules import get_software_root, get_software_root_env_var_name
@@ -830,7 +830,7 @@ def symlink_commands(self, paths):
830830
symlink_dir = tempfile.mkdtemp()
831831

832832
# prepend location to symlinks to $PATH
833-
setvar('PATH', '%s:%s' % (symlink_dir, os.getenv('PATH')))
833+
prependvar('PATH', symlink_dir)
834834

835835
for (path, cmds) in paths.values():
836836
for cmd in cmds:
@@ -1145,7 +1145,7 @@ def prepare_rpath_wrappers(self, rpath_filter_dirs=None, rpath_include_dirs=None
11451145
adjust_permissions(cmd_wrapper, stat.S_IXUSR)
11461146

11471147
# prepend location to this wrapper to $PATH
1148-
setvar('PATH', '%s:%s' % (wrapper_dir, os.getenv('PATH')))
1148+
prependvar('PATH', wrapper_dir)
11491149

11501150
self.log.info("RPATH wrapper script for %s: %s (log: %s)", orig_cmd, which(cmd), rpath_wrapper_log)
11511151
else:

0 commit comments

Comments
 (0)