@@ -302,6 +302,13 @@ def extra_options():
302302 'ulimit_unlimited' : [False , "Ensure stack size limit is set to '%s' during build" % UNLIMITED , CUSTOM ],
303303 'use_lto' : [None , "Build with Link Time Optimization (>= v3.7.0, potentially unstable on some toolchains). "
304304 "If None: auto-detect based on toolchain compiler (version)" , CUSTOM ],
305+ 'patch_ctypes_ld_library_path' : [None ,
306+ "The ctypes module strongly relies on LD_LIBRARY_PATH to find "
307+ "libraries. This allows specifying a patch that will only be "
308+ "applied if EasyBuild is configured to filter LD_LIBRARY_PATH, in "
309+ "order to make sure ctypes can still find libraries without it. "
310+ "Please make sure to add the checksum for this patch to 'checksums'." ,
311+ CUSTOM ],
305312 }
306313 return ConfigureMake .extra_options (extra_vars )
307314
@@ -345,6 +352,64 @@ def _get_pip_ext_version(self):
345352 return ext [1 ]
346353 return None
347354
355+ def fetch_step (self , * args , ** kwargs ):
356+ """
357+ Custom fetch step for Python.
358+
359+ Add patch specified in patch_ctypes_ld_library_path to list of patches if
360+ EasyBuild is configured to filter $LD_LIBRARY_PATH (and is configured not to filter $LIBRARY_PATH).
361+ This needs to be done in (or before) the fetch step to ensure that those patches are also fetched.
362+ """
363+ # If we filter out $LD_LIBRARY_PATH (not unusual when using rpath), ctypes is not able to dynamically load
364+ # libraries installed with EasyBuild (see https://github.com/EESSI/software-layer/issues/192).
365+ # If EasyBuild is configured to filter $LD_LIBRARY_PATH the patch specified in 'patch_ctypes_ld_library_path'
366+ # are added to the list of patches. Also, we add the checksums_filter_ld_library_path to the checksums list in
367+ # that case.
368+ # This mechanism e.g. makes sure we can patch ctypes, which normally strongly relies on $LD_LIBRARY_PATH to find
369+ # libraries. But, we want to do the patching conditionally on EasyBuild configuration (i.e. which env vars
370+ # are filtered), hence this setup based on the custom easyconfig parameter 'patch_ctypes_ld_library_path'
371+ filtered_env_vars = build_option ('filter_env_vars' ) or []
372+ patch_ctypes_ld_library_path = self .cfg .get ('patch_ctypes_ld_library_path' )
373+ if (
374+ 'LD_LIBRARY_PATH' in filtered_env_vars and
375+ 'LIBRARY_PATH' not in filtered_env_vars and
376+ patch_ctypes_ld_library_path
377+ ):
378+ # Some sanity checking so we can raise an early and clear error if needed
379+ # We expect a (one) checksum for the patch_ctypes_ld_library_path
380+ checksums = self .cfg ['checksums' ]
381+ sources = self .cfg ['sources' ]
382+ patches = self .cfg .get ('patches' )
383+ len_patches = len (patches ) if patches else 0
384+ if len_patches + len (sources ) + 1 == len (checksums ):
385+ msg = "EasyBuild was configured to filter $LD_LIBRARY_PATH (and not to filter $LIBRARY_PATH). "
386+ msg += "The ctypes module relies heavily on $LD_LIBRARY_PATH for locating its libraries. "
387+ msg += "The following patch will be applied to make sure ctypes.CDLL, ctypes.cdll.LoadLibrary "
388+ msg += f"and ctypes.util.find_library will still work correctly: { patch_ctypes_ld_library_path } ."
389+ self .log .info (msg )
390+ self .log .info (f"Original list of patches: { self .cfg ['patches' ]} " )
391+ self .log .info (f"Patch to be added: { patch_ctypes_ld_library_path } " )
392+ self .cfg .update ('patches' , [patch_ctypes_ld_library_path ])
393+ self .log .info (f"Updated list of patches: { self .cfg ['patches' ]} " )
394+ else :
395+ msg = "The length of 'checksums' (%s) is not equal to the total amount of sources (%s) + patches (%s). "
396+ msg += "Did you forget to add a checksum for patch_ctypes_ld_library_path?"
397+ raise EasyBuildError (msg , len (checksums ), len (sources ), len (len_patches + 1 ))
398+ # If LD_LIBRARY_PATH is filtered, but no patch is specified, warn the user that his may not work
399+ elif (
400+ 'LD_LIBRARY_PATH' in filtered_env_vars and
401+ 'LIBRARY_PATH' not in filtered_env_vars and
402+ not patch_ctypes_ld_library_path
403+ ):
404+ msg = "EasyBuild was configured to filter $LD_LIBRARY_PATH (and not to filter $LIBRARY_PATH). "
405+ msg += "However, no patch for ctypes was specified through 'patch_ctypes_ld_library_path' in the "
406+ msg += "easyconfig. Note that ctypes.util.find_library, ctypes.CDLL and ctypes.cdll.LoadLibrary heavily "
407+ msg += "rely on $LD_LIBRARY_PATH. Without the patch, a setup without $LD_LIBRARY_PATH will likely not work "
408+ msg += "correctly."
409+ self .log .warning (msg )
410+
411+ super ().fetch_step (* args , ** kwargs )
412+
348413 def patch_step (self , * args , ** kwargs ):
349414 """
350415 Custom patch step for Python:
@@ -357,33 +422,6 @@ def patch_step(self, *args, **kwargs):
357422 # Ignore user site dir. -E ignores PYTHONNOUSERSITE, so we have to add -s
358423 apply_regex_substitutions ('configure' , [(r"(PYTHON_FOR_BUILD=.*-E)'" , r"\1 -s'" )])
359424
360- # If we filter out LD_LIBRARY_PATH (not unusual when using rpath), ctypes is not able to dynamically load
361- # libraries installed with EasyBuild (see https://github.com/EESSI/software-layer/issues/192).
362- # ctypes is using GCC (and therefore LIBRARY_PATH) to figure out the full location but then only returns the
363- # soname, instead let's return the full path in this particular scenario
364- filtered_env_vars = build_option ('filter_env_vars' ) or []
365- if 'LD_LIBRARY_PATH' in filtered_env_vars and 'LIBRARY_PATH' not in filtered_env_vars :
366- ctypes_util_py = os .path .join ("Lib" , "ctypes" , "util.py" )
367- orig_gcc_so_name = None
368- # Let's do this incrementally since we are going back in time
369- if LooseVersion (self .version ) >= "3.9.1" :
370- # From 3.9.1 to at least v3.12.4 there is only one match for this line
371- orig_gcc_so_name = "_get_soname(_findLib_gcc(name)) or _get_soname(_findLib_ld(name))"
372- if orig_gcc_so_name :
373- orig_gcc_so_name_regex = r'(\s*)' + re .escape (orig_gcc_so_name ) + r'(\s*)'
374- # _get_soname() takes the full path as an argument and uses objdump to get the SONAME field from
375- # the shared object file. The presence or absence of the SONAME field in the ELF header of a shared
376- # library is influenced by how the library is compiled and linked. For manually built libraries we
377- # may be lacking this field, this approach also solves that problem.
378- updated_gcc_so_name = (
379- "_findLib_gcc(name) or _findLib_ld(name)"
380- )
381- apply_regex_substitutions (
382- ctypes_util_py ,
383- [(orig_gcc_so_name_regex , r'\1' + updated_gcc_so_name + r'\2' )],
384- on_missing_match = ERROR
385- )
386-
387425 # if we're installing Python with an alternate sysroot,
388426 # we need to patch setup.py which includes hardcoded paths like /usr/include and /lib64;
389427 # this fixes problems like not being able to build the _ssl module ("Could not build the ssl module")
@@ -701,6 +739,57 @@ def install_step(self):
701739 symlink (target_lib_dynload , lib_dynload )
702740 change_dir (cwd )
703741
742+ def _sanity_check_ctypes_ld_library_path_patch (self ):
743+ """
744+ Check that ctypes.util.find_library and ctypes.CDLL work as expected.
745+ When $LD_LIBRARY_PATH is filtered, a patch is required for this to work correctly
746+ (see patch_ctypes_ld_library_path).
747+ """
748+ # Try find_library first, since ctypes.CDLL relies on that to work correctly
749+ cmd = "python -c 'from ctypes import util; print(util.find_library(\" libpython3.so\" ))'"
750+ res = run_shell_cmd (cmd )
751+ out = res .output .strip ()
752+ escaped_python_root = re .escape (self .installdir )
753+ pattern = rf"^{ escaped_python_root } .*libpython3\.so$"
754+ match = re .match (pattern , out )
755+ self .log .debug (f"Matching regular expression pattern { pattern } to string { out } " )
756+ if match :
757+ msg = "Call to ctypes.util.find_library('libpython3.so') successfully found libpython3.so under "
758+ msg += f"the installation prefix of the current Python installation ({ self .installdir } ). "
759+ if self .cfg .get ('patch_ctypes_ld_library_path' ):
760+ msg += "This indicates that the patch that fixes ctypes when EasyBuild is "
761+ msg += "configured to filter $LD_LIBRARY_PATH was applied succesfully."
762+ self .log .info (msg )
763+ else :
764+ msg = "Finding the library libpython3.so using ctypes.util.find_library('libpython3.so') failed. "
765+ msg += "The ctypes Python module requires a patch when EasyBuild is configured to filter $LD_LIBRARY_PATH. "
766+ msg += "Please check if you specified a patch through patch_ctypes_ld_library_path and check "
767+ msg += "the logs to see if it applied correctly."
768+ raise EasyBuildError (msg )
769+ # Now that we know find_library was patched correctly, check if ctypes.CDLL is also patched correctly
770+ cmd = "python -c 'import ctypes; print(ctypes.CDLL(\" libpython3.so\" ))'"
771+ res = run_shell_cmd (cmd )
772+ out = res .output .strip ()
773+ pattern = rf"^<CDLL '{ escaped_python_root } .*libpython3\.so', handle [a-f0-9]+ at 0x[a-f0-9]+>$"
774+ match = re .match (pattern , out )
775+ self .log .debug (f"Matching regular expression pattern { pattern } to string { out } " )
776+ if match :
777+ msg = "Call to ctypes.CDLL('libpython3.so') succesfully opened libpython3.so. "
778+ if self .cfg .get ('patch_ctypes_ld_library_path' ):
779+ msg += "This indicates that the patch that fixes ctypes when $LD_LIBRARY_PATH is not set "
780+ msg += "was applied successfully."
781+ self .log .info (msg )
782+ msg = "Call to ctypes.CDLL('libpython3.so') succesfully opened libpython3.so. "
783+ if self .cfg .get ('patch_ctypes_ld_library_path' ):
784+ msg += "This indicates that the patch that fixes ctypes when $LD_LIBRARY_PATH is not set "
785+ msg += "was applied successfully."
786+ else :
787+ msg = "Opening of libpython3.so using ctypes.CDLL('libpython3.so') failed. "
788+ msg += "The ctypes Python module requires a patch when EasyBuild is configured to filter $LD_LIBRARY_PATH. "
789+ msg += "Please check if you specified a patch through patch_ctypes_ld_library_path and check "
790+ msg += "the logs to see if it applied correctly."
791+ raise EasyBuildError (msg )
792+
704793 def _sanity_check_ebpythonprefixes (self ):
705794 """Check that EBPYTHONPREFIXES works"""
706795 temp_prefix = tempfile .mkdtemp (suffix = '-tmp-prefix' )
@@ -765,6 +854,12 @@ def sanity_check_step(self):
765854 if self .cfg .get ('ebpythonprefixes' ):
766855 self ._sanity_check_ebpythonprefixes ()
767856
857+ # If the conditions for applying the patch specified through patch_ctypes_ld_library_path are met,
858+ # check that a patch was applied and indeed fixed the issue
859+ filtered_env_vars = build_option ('filter_env_vars' ) or []
860+ if 'LD_LIBRARY_PATH' in filtered_env_vars and 'LIBRARY_PATH' not in filtered_env_vars :
861+ self ._sanity_check_ctypes_ld_library_path_patch ()
862+
768863 pyver = 'python' + self .pyshortver
769864 custom_paths = {
770865 'files' : [
0 commit comments