Skip to content

Commit 9eb0e4a

Browse files
authored
Merge pull request #4539 from Micket/ebpythonprefixes2
prepend to `$PYTHONPATH` or `$EBPYTHONPREFIXES` in generated module files by automatically scanning for python site package directories
2 parents dc3826d + c3b5011 commit 9eb0e4a

File tree

6 files changed

+132
-5
lines changed

6 files changed

+132
-5
lines changed

easybuild/framework/easyblock.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
from easybuild.tools.build_details import get_build_stats
7373
from easybuild.tools.build_log import EasyBuildError, EasyBuildExit, dry_run_msg, dry_run_warning, dry_run_set_dirs
7474
from easybuild.tools.build_log import print_error, print_msg, print_warning
75-
from easybuild.tools.config import CHECKSUM_PRIORITY_JSON, DEFAULT_ENVVAR_USERS_MODULES
75+
from easybuild.tools.config import CHECKSUM_PRIORITY_JSON, DEFAULT_ENVVAR_USERS_MODULES, PYTHONPATH, EBPYTHONPREFIXES
7676
from easybuild.tools.config import FORCE_DOWNLOAD_ALL, FORCE_DOWNLOAD_PATCHES, FORCE_DOWNLOAD_SOURCES
7777
from easybuild.tools.config import EASYBUILD_SOURCES_URL # noqa
7878
from easybuild.tools.config import build_option, build_path, get_log_filename, get_repository, get_repositorypath
@@ -1390,6 +1390,49 @@ def make_module_description(self):
13901390
"""
13911391
return self.module_generator.get_description()
13921392

1393+
def make_module_pythonpath(self):
1394+
"""
1395+
Add lines for module file to update $PYTHONPATH or $EBPYTHONPREFIXES,
1396+
if they aren't already present and the standard lib/python*/site-packages subdirectory exists
1397+
"""
1398+
lines = []
1399+
if not os.path.isfile(os.path.join(self.installdir, 'bin', 'python')): # only needed when not a python install
1400+
python_subdir_pattern = os.path.join(self.installdir, 'lib', 'python*', 'site-packages')
1401+
candidate_paths = (os.path.relpath(path, self.installdir) for path in glob.glob(python_subdir_pattern))
1402+
python_paths = [path for path in candidate_paths if re.match(r'lib/python\d+\.\d+/site-packages', path)]
1403+
1404+
# determine whether Python is a runtime dependency;
1405+
# if so, we assume it was installed with EasyBuild, and hence is aware of $EBPYTHONPREFIXES
1406+
runtime_deps = [dep['name'] for dep in self.cfg.dependencies(runtime_only=True)]
1407+
1408+
# don't use $EBPYTHONPREFIXES unless we can and it's preferred or necesary (due to use of multi_deps)
1409+
use_ebpythonprefixes = False
1410+
multi_deps = self.cfg['multi_deps']
1411+
1412+
if 'Python' in runtime_deps:
1413+
self.log.info("Found Python runtime dependency, so considering $EBPYTHONPREFIXES...")
1414+
1415+
if build_option('prefer_python_search_path') == EBPYTHONPREFIXES:
1416+
self.log.info("Preferred Python search path is $EBPYTHONPREFIXES, so using that")
1417+
use_ebpythonprefixes = True
1418+
1419+
elif multi_deps and 'Python' in multi_deps:
1420+
self.log.info("Python is listed in 'multi_deps', so using $EBPYTHONPREFIXES instead of $PYTHONPATH")
1421+
use_ebpythonprefixes = True
1422+
1423+
if python_paths:
1424+
# add paths unless they were already added
1425+
if use_ebpythonprefixes:
1426+
path = '' # EBPYTHONPREFIXES are relative to the install dir
1427+
if path not in self.module_generator.added_paths_per_key[EBPYTHONPREFIXES]:
1428+
lines.append(self.module_generator.prepend_paths(EBPYTHONPREFIXES, path))
1429+
else:
1430+
for python_path in python_paths:
1431+
if python_path not in self.module_generator.added_paths_per_key[PYTHONPATH]:
1432+
lines.append(self.module_generator.prepend_paths(PYTHONPATH, python_path))
1433+
1434+
return lines
1435+
13931436
def make_module_extra(self, altroot=None, altversion=None):
13941437
"""
13951438
Set extra stuff in module file, e.g. $EBROOT*, $EBVERSION*, etc.
@@ -1438,6 +1481,9 @@ def make_module_extra(self, altroot=None, altversion=None):
14381481
value, type(value))
14391482
lines.append(self.module_generator.append_paths(key, value, allow_abs=self.cfg['allow_append_abs_path']))
14401483

1484+
# add lines to update $PYTHONPATH or $EBPYTHONPREFIXES
1485+
lines.extend(self.make_module_pythonpath())
1486+
14411487
modloadmsg = self.cfg['modloadmsg']
14421488
if modloadmsg:
14431489
# add trailing newline to prevent that shell prompt is 'glued' to module load message

easybuild/framework/easyconfig/easyconfig.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,15 +1105,19 @@ def filter_deps(self, deps):
11051105

11061106
return retained_deps
11071107

1108-
def dependencies(self, build_only=False):
1108+
def dependencies(self, build_only=False, runtime_only=False):
11091109
"""
11101110
Returns an array of parsed dependencies (after filtering, if requested)
11111111
dependency = {'name': '', 'version': '', 'system': (False|True), 'versionsuffix': '', 'toolchain': ''}
11121112
Iterable builddependencies are flattened when not iterating.
11131113
11141114
:param build_only: only return build dependencies, discard others
1115+
:param runtime_only: only return runtime dependencies, discard others
11151116
"""
1116-
deps = self.builddependencies()
1117+
if runtime_only:
1118+
deps = []
1119+
else:
1120+
deps = self.builddependencies()
11171121

11181122
if not build_only:
11191123
# use += rather than .extend to get a new list rather than updating list of build deps in place...

easybuild/tools/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@
175175
OUTPUT_STYLE_RICH = 'rich'
176176
OUTPUT_STYLES = (OUTPUT_STYLE_AUTO, OUTPUT_STYLE_BASIC, OUTPUT_STYLE_NO_COLOR, OUTPUT_STYLE_RICH)
177177

178+
PYTHONPATH = 'PYTHONPATH'
179+
EBPYTHONPREFIXES = 'EBPYTHONPREFIXES'
180+
PYTHON_SEARCH_PATH_TYPES = [PYTHONPATH, EBPYTHONPREFIXES]
181+
178182

179183
class Singleton(ABCMeta):
180184
"""Serves as metaclass for classes that should implement the Singleton pattern.
@@ -407,6 +411,9 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
407411
OUTPUT_STYLE_AUTO: [
408412
'output_style',
409413
],
414+
PYTHONPATH: [
415+
'prefer_python_search_path',
416+
]
410417
}
411418
# build option that do not have a perfectly matching command line option
412419
BUILD_OPTIONS_OTHER = {

easybuild/tools/module_generator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import os
4040
import re
4141
import tempfile
42+
from collections import defaultdict
4243
from contextlib import contextmanager
4344
from easybuild.tools import LooseVersion
4445
from textwrap import wrap
@@ -153,7 +154,7 @@ def start_module_creation(self):
153154
raise EasyBuildError('Module creation already in process. '
154155
'You cannot create multiple modules at the same time!')
155156
# Mapping of keys/env vars to paths already added
156-
self.added_paths_per_key = dict()
157+
self.added_paths_per_key = defaultdict(set)
157158
txt = self.MODULE_SHEBANG
158159
if txt:
159160
txt += '\n'
@@ -212,7 +213,7 @@ def _filter_paths(self, key, paths):
212213
print_warning('Module creation has not been started. Call start_module_creation first!')
213214
return paths
214215

215-
added_paths = self.added_paths_per_key.setdefault(key, set())
216+
added_paths = self.added_paths_per_key[key]
216217
# paths can be a string
217218
if isinstance(paths, str):
218219
if paths in added_paths:

easybuild/tools/options.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
from easybuild.tools.config import OUTPUT_STYLE_AUTO, OUTPUT_STYLES, WARN
7979
from easybuild.tools.config import get_pretend_installpath, init, init_build_options, mk_full_default_path
8080
from easybuild.tools.config import BuildOptions, ConfigurationVariables
81+
from easybuild.tools.config import PYTHON_SEARCH_PATH_TYPES, PYTHONPATH
8182
from easybuild.tools.configobj import ConfigObj, ConfigObjError
8283
from easybuild.tools.docs import FORMAT_JSON, FORMAT_MD, FORMAT_RST, FORMAT_TXT
8384
from easybuild.tools.docs import avail_cfgfile_constants, avail_easyconfig_constants, avail_easyconfig_licenses
@@ -490,6 +491,10 @@ def override_options(self):
490491
None, 'store_true', False),
491492
'pre-create-installdir': ("Create installation directory before submitting build jobs",
492493
None, 'store_true', True),
494+
'prefer-python-search-path': (("Prefer using specified environment variable when possible to specify where"
495+
" Python packages were installed; see also "
496+
"https://docs.easybuild.io/python-search-path"),
497+
'choice', 'store_or_None', PYTHONPATH, PYTHON_SEARCH_PATH_TYPES),
493498
'pretend': (("Does the build/installation in a test directory located in $HOME/easybuildinstall"),
494499
None, 'store_true', False, 'p'),
495500
'read-only-installdir': ("Set read-only permissions on installation directory after installation",

test/framework/toy_build.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4238,6 +4238,70 @@ def test_eb_error(self):
42384238
stderr = stderr.getvalue()
42394239
self.assertTrue(regex.search(stderr), f"Pattern '{regex.pattern}' should be found in {stderr}")
42404240

4241+
def test_toy_python(self):
4242+
"""
4243+
Test whether $PYTHONPATH or $EBPYTHONPREFIXES are set correctly.
4244+
"""
4245+
# generate fake Python modules that we can use as runtime dependency for toy
4246+
# (required condition for use of $EBPYTHONPREFIXES)
4247+
fake_mods_path = os.path.join(self.test_prefix, 'modules')
4248+
for pyver in ('2.7', '3.6'):
4249+
fake_python_mod = os.path.join(fake_mods_path, 'Python', pyver)
4250+
if get_module_syntax() == 'Lua':
4251+
fake_python_mod += '.lua'
4252+
write_file(fake_python_mod, '')
4253+
else:
4254+
write_file(fake_python_mod, '#%Module')
4255+
self.modtool.use(fake_mods_path)
4256+
4257+
test_ecs = os.path.join(os.path.dirname(__file__), 'easyconfigs', 'test_ecs')
4258+
toy_ec = os.path.join(test_ecs, 't', 'toy', 'toy-0.0.eb')
4259+
4260+
test_ec_txt = read_file(toy_ec)
4261+
test_ec_txt += "\npostinstallcmds.append('mkdir -p %(installdir)s/lib/python3.6/site-packages')"
4262+
test_ec_txt += "\npostinstallcmds.append('touch %(installdir)s/lib/python3.6/site-packages/foo.py')"
4263+
4264+
test_ec = os.path.join(self.test_prefix, 'test.eb')
4265+
write_file(test_ec, test_ec_txt)
4266+
self.run_test_toy_build_with_output(ec_file=test_ec)
4267+
4268+
toy_mod = os.path.join(self.test_installpath, 'modules', 'all', 'toy', '0.0')
4269+
if get_module_syntax() == 'Lua':
4270+
toy_mod += '.lua'
4271+
toy_mod_txt = read_file(toy_mod)
4272+
4273+
pythonpath_regex = re.compile('^prepend.path.*PYTHONPATH.*lib/python3.6/site-packages', re.M)
4274+
4275+
self.assertTrue(pythonpath_regex.search(toy_mod_txt),
4276+
f"Pattern '{pythonpath_regex.pattern}' found in: {toy_mod_txt}")
4277+
4278+
# also check when opting in to use $EBPYTHONPREFIXES instead of $PYTHONPATH
4279+
args = ['--prefer-python-search-path=EBPYTHONPREFIXES']
4280+
self.run_test_toy_build_with_output(ec_file=test_ec, extra_args=args)
4281+
toy_mod_txt = read_file(toy_mod)
4282+
# if Python is not listed as a runtime dependency then $PYTHONPATH is still used,
4283+
# because the Python dependency used must be aware of $EBPYTHONPREFIXES
4284+
# (see sitecustomize.py installed by Python easyblock)
4285+
self.assertTrue(pythonpath_regex.search(toy_mod_txt),
4286+
f"Pattern '{pythonpath_regex.pattern}' found in: {toy_mod_txt}")
4287+
4288+
# if Python is listed as runtime dependency, then $EBPYTHONPREFIXES is used if it's preferred
4289+
write_file(test_ec, test_ec_txt + "\ndependencies = [('Python', '3.6', '', SYSTEM)]")
4290+
self.run_test_toy_build_with_output(ec_file=test_ec, extra_args=args)
4291+
toy_mod_txt = read_file(toy_mod)
4292+
4293+
ebpythonprefixes_regex = re.compile('^prepend.path.*EBPYTHONPREFIXES.*root', re.M)
4294+
self.assertTrue(ebpythonprefixes_regex.search(toy_mod_txt),
4295+
f"Pattern '{ebpythonprefixes_regex.pattern}' found in: {toy_mod_txt}")
4296+
4297+
# if Python is listed in multi_deps, then $EBPYTHONPREFIXES is used, even if it's not explicitely preferred
4298+
write_file(test_ec, test_ec_txt + "\nmulti_deps = {'Python': ['2.7', '3.6']}")
4299+
self.run_test_toy_build_with_output(ec_file=test_ec)
4300+
toy_mod_txt = read_file(toy_mod)
4301+
4302+
self.assertTrue(ebpythonprefixes_regex.search(toy_mod_txt),
4303+
f"Pattern '{ebpythonprefixes_regex.pattern}' found in: {toy_mod_txt}")
4304+
42414305

42424306
def suite():
42434307
""" return all the tests in this file """

0 commit comments

Comments
 (0)