Repository: easybuilders/easybuild-easyblocks

Issue:
Python packages are being installed to local/lib/python*/dist-packages due to tweaks to sysconfig.py in OS (Ubuntu 22.04)
**Problem description**

Hi. Recently, one of my machines started experiencing problems with installing EasyBuild via the bootstrap method.

```
== 2022-11-26 22:18:10,235 easyblock.py:3774 INFO Running method sanity_check_step part of step sanitycheck
== 2022-11-26 22:18:10,281 build_log.py:169 ERROR EasyBuild crashed with an error (at easybuild/eb_tmp/local/lib/python3.10/dist-packages/easybuild/base/exceptions.py:124 in __init__): Failed to isolate installed egg dir for easybuild-framework (at easybuild/eb_tmp/local/lib/python3.10/dist-packages/easybuild/easyblocks/e/easybuildmeta.py:235 in sanity_check_step)
```

I use a custom build script I wrote based on the official instructions in the Lua, Lmod and EasyBuild documentations:
https://github.com/anselmicz/easybuild-installer/blob/main/easybuild.sh

My current builds fail during the sanity check phase, I'm enclosing the build log, environment at the time of installation, and trace output as a gist:
https://gist.github.com/anselmicz/72d1a0239cf67b685ed878aef2f61359

The error itself comes from https://github.com/boegel/easybuild-easyblocks/blob/develop/easybuild/easyblocks/e/easybuildmeta.py#L235

In a Slack conversation, @ocaisa mentioned that the error may be somehow connected to
https://github.com/easybuilders/easybuild-easyblocks/pull/2805

**System**

***OS***
Debian bookworm/sid

***Custom dependencies***
```
Lua/5.1.4.9
Lmod/8.5
```

***System packages***
```
python3/3.10.6
python3-pip/22.3
wget/1.21.3
gcc/12.2.0
make/4.3
rsync/3.2.6
tcl-dev/8.6.11
libreadline-dev/8.2
libibverbs-dev/43.0
xdot/1.2
```

***Python packages***
```
setuptools/65.6.3
python-graph-core/1.8.2
python-graph-dot/1.8.2
archspec/0.2.0
autopep8/2.0.0
GitPython/3.1.29
pep8/1.7.1
pycodestyle/2.10.0
Rich/12.6.0
```

Do you have any thoughts on this? I tried lowering both the temporary and to-be-installed versions of EasyBuild, but the issue persists both on `4.6.1` and  `4.6.0`, and I can't try `4.5.4` because then the `setuptools` and `setup.py` problems arise.


Discussion:
@anselmicz Can you share the contents of `/apps/all/EasyBuild/4.6.2`, in particular the contents of the `lib*/python*/site-packages` subdirectory that should be there?

I have also tried lowering the `setuptools` and `pip` versions to no avail.

@boegel I have updated the gist with a directory tree of the `/apps/all/EasyBuild/4.6.2`

`lib/python3.10/site-packages` is empty, however, everything seems to install under `/apps/all/EasyBuild/4.6.2/local`, including `lib`... this is probably the distribution difference, but here the path is `.../lib/python3.10/dist-packages`.

Same goes for the temporary EasyBuild installation, instead of installing under the prefix, `/tmp/$USER/eb_tmp`, the actual location of `bin`, `lib`, etc. is under `/tmp/$USER/eb_tmp/local`

I remember dealing with this problem before, but the issue has always been exclusively with the temporary installation of EasyBuild that could have always been fixed by accommodating the `PYTHONPATH`, i. e. setting `export PYTHONPATH=$(/bin/ls -rtd -1 $EB_TMPDIR/local/lib*/python*/dist-packages | tail -1):$PYTHONPATH`.
Here is a Dockerfile to reproduce the error using Ubuntu 22.04 LTS:

```
FROM ubuntu:jammy-20221130
#FROM ubuntu:focal-20221130

ARG DEBIAN_FRONTEND="noninteractive"

RUN set -eux \
  && apt-get update -qq \
  && apt-get -y upgrade \
  && apt-get install -y -q --no-install-recommends \
      lmod \
      python3-pip \
  && apt-get clean \
  && python3 -m pip install --ignore-installed --prefix /tmp/eb_tmp easybuild \
  && rm -rf /tmp/hsperfdata* /var/*/apt/*/partial /var/lib/apt/lists/* /var/log/apt/term*

RUN set -x \
  && export LOGNAME=root \
  && export PATH=$(find /tmp/eb_tmp -type d -name bin):$PATH \
  && export PYTHONPATH=$(find /tmp/eb_tmp/ -type d -name *-packages | tail -1) \
  && export EB_PYTHON=python3 \
  && . /etc/profile.d/lmod.sh \
  && eb --allow-use-as-root-and-accept-consequences --install-latest-eb-release -d --prefix /tmp/easybuild \
  ;  cat /tmp/eb-*/easybuild-EasyBuild-4.6.2-*.log

ENTRYPOINT ["/bin/bash"]
```

When you `docker build` the same thing using `FROM ubuntu:focal-20221130` it works. So there has to be something different with Ubuntu 22.04 LTS, which I haven't figured out, yet. One of the obvious things, as mentioned: `jammy` installs everything in `/tmp/eb_tmp/local/`, while `focal` installs directly into `/tmp/eb_tmp`.

@anselmicz, is it the latest Ubuntu LTS for you, too?

Any ideas? Is there a fix or config change needed in Ubuntu, or with EasyBuild?
@crubb my report deals with Debian Unstable

I confirm I can reproduce the error in `jammy`. However, this is not at all that surprising, because, based on my understanding of Ubuntu versioning, `jammy` is based on Debian `bookworm` (current `sid` / unstable). It also makes sense that it might work correctly in `focal`, given it's based on `bullseye` where it works too (at least for me).
root cause of the problems seems to be the following: https://discuss.python.org/t/linux-distro-patches-to-sysconfig-are-changing-pip-install-prefix-outside-virtual-environments/18240/14

Essentially, some distros patched Python's sysconfig to change the default installation scheme from `posix_prefix` to `posix_local`.

I found a bug report for Ubuntu, which essentially sits dormant after more or less stating that it's a design choice: https://bugs.launchpad.net/ubuntu/+source/python3.10/+bug/1967920
@crubb I can see how that affect the installation of EasyBuild with EasyBuild (since that's done with the system Python), but how does that affect installing Python packages with a Python that was installed with EasyBuild?!
@crubb Good catch! This (temporary) workaround seems to work for me:
```
--- /lib/python3.10/sysconfig.py.orig	2022-12-13 17:38:06.221735514 +0100
+++ /lib/python3.10/sysconfig.py	2022-12-13 17:40:28.549111500 +0100
@@ -271,7 +271,7 @@
         if deb_build in ('deb', 'deb_system'):
             prefix_scheme = 'deb_system'
         else:
-            prefix_scheme = 'posix_local'
+            prefix_scheme = 'posix_prefix'

     return {
         'prefix': prefix_scheme,
```

@boegel, looking at the links @crubb provided, it seems like in Debian, `sysconfig.py` got some extreme mods between the versions:
https://lists.debian.org/debian-python/2022/03/msg00039.html.
So far this seems to be distro specific, so the EasyBuild Python should be unaffected.
I actually borrowed from Debian and stole their `DEB_PYTHON_INSTALL_LAYOUT` environment variable. Set it to `eb`, and it uses `posix_prefix` as the `prefix_scheme`:

```
--- sysconfig.py-orig   2022-12-13 21:37:25.489016711 +0000
+++ sysconfig.py        2022-12-13 21:37:52.088975055 +0000
@@ -270,6 +270,8 @@
         deb_build = os.environ.get('DEB_PYTHON_INSTALL_LAYOUT', 'posix_local')
         if deb_build in ('deb', 'deb_system'):
             prefix_scheme = 'deb_system'
+        elif deb_build in ('eb'):
+            prefix_scheme = 'posix_prefix'
         else:
             prefix_scheme = 'posix_local'
```
I just run into the same problem while generating a test-report on a `ubuntu-22.04` container ([recipe](https://github.com/easybuilders/easybuild-containers/blob/main/ubuntu-22.04/Dockerfile), [image @ ghcr.io](https://github.com/easybuilders/easybuild-containers/pkgs/container/ubuntu-22.04) ) for `EasyBuild-4.7.0.eb`: https://github.com/easybuilders/easybuild-easyconfigs/pull/17065#issuecomment-1375629863

With the described workaround I was able to build it successfully.
Did some more digging, found some more info:

* ["sysconfig default scheme change in Python 3.10" (on `debian-python` mailing list)](https://lists.debian.org/debian-python/2022/03/msg00039.html);
* In https://github.com/pypa/pip/issues/10978 it's highlighted that this problem isn't specific to Debian/Ubuntu at all, it also affects Fedora 37 (so soon also RHEL & friends?), or even Fedora 36 (based on [this](https://bugzilla.redhat.com/show_bug.cgi?id=2026979) and [this](https://bugzilla.redhat.com/show_bug.cgi?id=2097183)), but there they didn't even bother to introduce a custom installation scheme like Debian/Ubuntu did with `posix_local` (which is not standard in Python, see https://docs.python.org/3/library/sysconfig.html#installation-paths), they just fiddled with the default `posix_prefix` instead, making it harder to detect;
  * [this](https://src.fedoraproject.org/rpms/python3.10/pull-request/124) seems to suggest the change was rolled back in Fedora though...
* Spack has been dealing with this too, see:
  * https://github.com/spack/spack/issues/37632

Resolving patch:
diff --git a/easybuild/easyblocks/generic/pythonpackage.py b/easybuild/easyblocks/generic/pythonpackage.py
--- a/easybuild/easyblocks/generic/pythonpackage.py
+++ b/easybuild/easyblocks/generic/pythonpackage.py
@@ -49,7 +49,7 @@
 from easybuild.framework.extensioneasyblock import ExtensionEasyBlock
 from easybuild.tools.build_log import EasyBuildError, print_msg
 from easybuild.tools.config import build_option
-from easybuild.tools.filetools import mkdir, remove_dir, which
+from easybuild.tools.filetools import change_dir, mkdir, remove_dir, symlink, which
 from easybuild.tools.modules import get_software_root
 from easybuild.tools.py2vs3 import string_type, subprocess_popen_text
 from easybuild.tools.run import run_cmd
@@ -64,6 +64,17 @@
 SETUP_PY_INSTALL_CMD = "%(python)s setup.py %(install_target)s --prefix=%(prefix)s %(installopts)s"
 UNKNOWN = 'UNKNOWN'
 
+# Python installation schemes, see https://docs.python.org/3/library/sysconfig.html#installation-paths;
+# posix_prefix is the default upstream installation scheme (and the want to want)
+PY_INSTALL_SCHEME_POSIX_PREFIX = 'posix_prefix'
+# posix_local is custom installation scheme on Debian/Ubuntu which implies additional action,
+# see https://github.com/easybuilders/easybuild-easyblocks/issues/2976
+PY_INSTALL_SCHEME_POSIX_LOCAL = 'posix_local'
+PY_INSTALL_SCHEMES = [
+    PY_INSTALL_SCHEME_POSIX_PREFIX,
+    PY_INSTALL_SCHEME_POSIX_LOCAL,
+]
+
 
 def det_python_version(python_cmd):
     """Determine version of specified 'python' command."""
@@ -224,6 +235,88 @@ def det_pip_version(python_cmd='python'):
     return pip_version
 
 
+def det_py_install_scheme(python_cmd='python'):
+    """
+    Try to determine active installation scheme used by Python.
+    """
+    # default installation scheme is 'posix_prefix',
+    # see also https://docs.python.org/3/library/sysconfig.html#installation-paths;
+    # on Debian/Ubuntu, we may be getting 'posix_local' as custom installation scheme,
+    # which injects /local as a subdirectory and cause trouble
+    # (see also https://github.com/easybuilders/easybuild-easyblocks/issues/2976)
+
+    log = fancylogger.getLogger('det_py_install_scheme', fname=False)
+
+    # sysconfig._get_default_scheme was renamed to sysconfig.get_default_scheme in Python 3.10
+    pyver = det_python_version(python_cmd)
+    if LooseVersion(pyver) >= LooseVersion('3.10'):
+        get_default_scheme = 'get_default_scheme'
+    else:
+        get_default_scheme = '_get_default_scheme'
+
+    cmd = "%s -c 'import sysconfig; print(sysconfig.%s())'" % (python_cmd, get_default_scheme)
+    log.debug("Determining active Python installation scheme with: %s", cmd)
+    out, _ = run_cmd(cmd, verbose=False, simple=False, trace=False)
+    py_install_scheme = out.strip()
+
+    if py_install_scheme in PY_INSTALL_SCHEMES:
+        log.info("Active Python installation scheme: %s", py_install_scheme)
+    else:
+        log.warning("Unknown Python installation scheme: %s", py_install_scheme)
+
+    return py_install_scheme
+
+
+def handle_local_py_install_scheme(install_dir):
+    """
+    Handle situation in which 'posix_local' installation scheme was used,
+    which implies that <prefix>/local/' rather than <prefix>/ was used as installation prefix...
+    """
+    # see also https://github.com/easybuilders/easybuild-easyblocks/issues/2976
+
+    log = fancylogger.getLogger('handle_local_py_install_scheme', fname=False)
+
+    install_dir_local = os.path.join(install_dir, 'local')
+    if os.path.exists(install_dir_local):
+        subdirs = os.listdir(install_dir)
+        log.info("Found 'local' subdirectory in installation prefix %s: %s", install_dir, subdirs)
+
+        local_subdirs = os.listdir(install_dir_local)
+        log.info("Subdirectories of %s: %s", install_dir_local, local_subdirs)
+
+        # symlink subdirectories of <prefix>/local directly into <prefix>
+        cwd = change_dir(install_dir)
+        for local_subdir in local_subdirs:
+            srcpath = os.path.join('local', local_subdir)
+            symlink(srcpath, os.path.join(install_dir, local_subdir), use_abspath_source=False)
+        change_dir(cwd)
+
+
+def symlink_dist_site_packages(install_dir, pylibdirs):
+    """
+    Symlink site-packages to dist-packages if only the latter is available in the specified directories.
+    """
+    # in some situations, for example when the default installation scheme is not the upstream default posix_prefix,
+    # as is the case in Ubuntu 22.04 (cfr. https://github.com/easybuilders/easybuild-easyblocks/issues/2976),
+    # Python packages may get installed in <prefix>/.../dist-packages rather than <prefix>/.../site-packages;
+    # we try to determine all possible paths in get_pylibdirs but we still may get it wrong,
+    # mostly because distutils.sysconfig.get_python_lib(..., prefix=...) isn't correct when posix_prefix
+    # is not the active installation scheme;
+    # so taking the coward way out: just symlink site-packages to dist-packages if only latter is available
+    dist_pkgs = 'dist-packages'
+    for pylibdir in pylibdirs:
+        dist_pkgs_path = os.path.join(install_dir, os.path.dirname(pylibdir), dist_pkgs)
+        site_pkgs_path = os.path.join(os.path.dirname(dist_pkgs_path), 'site-packages')
+
+        # site-packages may be there as empty directory (see mkdir loop in install_step);
+        # just remove it if that's the case so we can symlink to dist-packages
+        if os.path.exists(site_pkgs_path) and not os.listdir(site_pkgs_path):
+            remove_dir(site_pkgs_path)
+
+        if os.path.exists(dist_pkgs_path) and not os.path.exists(site_pkgs_path):
+            symlink(dist_pkgs, site_pkgs_path, use_abspath_source=False)
+
+
 class PythonPackage(ExtensionEasyBlock):
     """Builds and installs a Python package, and provides a dedicated module file."""
 
@@ -539,6 +632,20 @@ def compose_install_command(self, prefix, extrapath=None, installopts=None):
 
         return ' '.join(cmd)
 
+    def py_post_install_shenanigans(self, install_dir):
+        """
+        Run post-installation shenanigans on specified installation directory, incl:
+        * dealing with 'local' subdirectory in install directory in case 'posix_local' installation scheme was used;
+        * symlinking site-packages to dist-packages if only the former is available;
+        """
+        py_install_scheme = det_py_install_scheme(python_cmd=self.python_cmd)
+
+        if py_install_scheme == PY_INSTALL_SCHEME_POSIX_LOCAL:
+            handle_local_py_install_scheme(install_dir)
+
+        if py_install_scheme != PY_INSTALL_SCHEME_POSIX_PREFIX:
+            symlink_dist_site_packages(install_dir, self.all_pylibdirs)
+
     def extract_step(self):
         """Unpack source files, unless instructed otherwise."""
         if self._should_unpack_source():
@@ -658,7 +765,7 @@ def test_step(self, return_output_ec=False):
 
         if self.cfg['runtest'] and self.testcmd is not None:
             extrapath = ""
-            testinstalldir = None
+            test_installdir = None
 
             out, ec = (None, None)
 
@@ -666,21 +773,33 @@ def test_step(self, return_output_ec=False):
                 # install in test directory and export PYTHONPATH
 
                 try:
-                    testinstalldir = tempfile.mkdtemp()
+                    test_installdir = tempfile.mkdtemp()
+
+                    # if posix_local is the active installation scheme there will be
+                    # a 'local' subdirectory in the specified prefix;
+                    # see also https://github.com/easybuilders/easybuild-easyblocks/issues/2976
+                    py_install_scheme = det_py_install_scheme(python_cmd=self.python_cmd)
+                    if py_install_scheme == PY_INSTALL_SCHEME_POSIX_LOCAL:
+                        actual_installdir = os.path.join(test_installdir, 'local')
+                    else:
+                        actual_installdir = test_installdir
+
                     for pylibdir in self.all_pylibdirs:
-                        mkdir(os.path.join(testinstalldir, pylibdir), parents=True)
+                        mkdir(os.path.join(actual_installdir, pylibdir), parents=True)
                 except OSError as err:
                     raise EasyBuildError("Failed to create test install dir: %s", err)
 
                 # print Python search path (just debugging purposes)
                 run_cmd("%s -c 'import sys; print(sys.path)'" % self.python_cmd, verbose=False, trace=False)
 
-                abs_pylibdirs = [os.path.join(testinstalldir, pylibdir) for pylibdir in self.all_pylibdirs]
+                abs_pylibdirs = [os.path.join(actual_installdir, pylibdir) for pylibdir in self.all_pylibdirs]
                 extrapath = "export PYTHONPATH=%s &&" % os.pathsep.join(abs_pylibdirs + ['$PYTHONPATH'])
 
-                cmd = self.compose_install_command(testinstalldir, extrapath=extrapath)
+                cmd = self.compose_install_command(test_installdir, extrapath=extrapath)
                 run_cmd(cmd, log_all=True, simple=True, verbose=False)
 
+                self.py_post_install_shenanigans(test_installdir)
+
             if self.testcmd:
                 testcmd = self.testcmd % {'python': self.python_cmd}
                 cmd = ' '.join([
@@ -697,8 +816,8 @@ def test_step(self, return_output_ec=False):
                 else:
                     run_cmd(cmd, log_all=True, simple=True)
 
-            if testinstalldir:
-                remove_dir(testinstalldir)
+            if test_installdir:
+                remove_dir(test_installdir)
 
             if return_output_ec:
                 return (out, ec)
@@ -706,12 +825,21 @@ def test_step(self, return_output_ec=False):
     def install_step(self):
         """Install Python package to a custom path using setup.py"""
 
+        # if posix_local is the active installation scheme there will be
+        # a 'local' subdirectory in the specified prefix;
+        # see also https://github.com/easybuilders/easybuild-easyblocks/issues/2976
+        py_install_scheme = det_py_install_scheme(python_cmd=self.python_cmd)
+        if py_install_scheme == PY_INSTALL_SCHEME_POSIX_LOCAL:
+            actual_installdir = os.path.join(self.installdir, 'local')
+        else:
+            actual_installdir = self.installdir
+
         # create expected directories
-        abs_pylibdirs = [os.path.join(self.installdir, pylibdir) for pylibdir in self.all_pylibdirs]
+        abs_pylibdirs = [os.path.join(actual_installdir, pylibdir) for pylibdir in self.all_pylibdirs]
         for pylibdir in abs_pylibdirs:
             mkdir(pylibdir, parents=True)
 
-        abs_bindir = os.path.join(self.installdir, 'bin')
+        abs_bindir = os.path.join(actual_installdir, 'bin')
 
         # set PYTHONPATH and PATH as expected
         old_values = dict()
@@ -731,6 +859,8 @@ def install_step(self):
         # (for iterated installations over multiply Python versions)
         self.install_cmd_output += out
 
+        self.py_post_install_shenanigans(self.installdir)
+
         # fix shebangs if specified
         self.fix_shebang()
 


Test patch:
diff --git a/test/easyblocks/easyblock_specific.py b/test/easyblocks/easyblock_specific.py
--- a/test/easyblocks/easyblock_specific.py
+++ b/test/easyblocks/easyblock_specific.py
@@ -37,6 +37,7 @@
 from test.easyblocks.module import cleanup
 
 import easybuild.tools.options as eboptions
+import easybuild.easyblocks.generic.pythonpackage as pythonpackage
 from easybuild.base.testing import TestCase
 from easybuild.easyblocks.generic.cmakemake import det_cmake_version
 from easybuild.easyblocks.generic.toolchain import Toolchain
@@ -46,7 +47,7 @@
 from easybuild.tools.build_log import EasyBuildError
 from easybuild.tools.config import GENERAL_CLASS, get_module_syntax
 from easybuild.tools.environment import modify_env
-from easybuild.tools.filetools import adjust_permissions, remove_dir, write_file
+from easybuild.tools.filetools import adjust_permissions, mkdir, move_file, remove_dir, symlink, write_file
 from easybuild.tools.modules import modules_tool
 from easybuild.tools.options import set_tmpdir
 from easybuild.tools.py2vs3 import StringIO
@@ -265,6 +266,99 @@ def test_det_cmake_version(self):
         """))
         self.assertEqual(det_cmake_version(), '1.2.3-rc4')
 
+    def test_det_py_install_scheme(self):
+        """Test det_py_install_scheme function provided by PythonPackage easyblock."""
+        res = pythonpackage.det_py_install_scheme(sys.executable)
+        self.assertTrue(isinstance(res, str))
+
+        # symlink currently used python command to 'python', so we can also test det_py_install_scheme with default;
+        # this is required because 'python' command may not be available
+        symlink(sys.executable, os.path.join(self.tmpdir, 'python'))
+        os.environ['PATH'] = '%s:%s' % (self.tmpdir, os.getenv('PATH'))
+
+        res = pythonpackage.det_py_install_scheme()
+        self.assertTrue(isinstance(res, str))
+
+    def test_handle_local_py_install_scheme(self):
+        """Test handle_local_py_install_scheme function provided by PythonPackage easyblock."""
+
+        # test with empty dir, should be fine
+        pythonpackage.handle_local_py_install_scheme(self.tmpdir)
+        self.assertEqual(os.listdir(self.tmpdir), [])
+
+        # create normal structure (no 'local' subdir), shouldn't cause trouble
+        bindir = os.path.join(self.tmpdir, 'bin')
+        mkdir(bindir)
+        write_file(os.path.join(bindir, 'test'), 'test')
+        libdir = os.path.join(self.tmpdir, 'lib')
+        pyshortver = '.'.join(str(x) for x in sys.version_info[:2])
+        pylibdir = os.path.join(libdir, 'python' + pyshortver, 'site-packages')
+        mkdir(pylibdir, parents=True)
+        write_file(os.path.join(pylibdir, 'test.py'), "import os")
+
+        pythonpackage.handle_local_py_install_scheme(self.tmpdir)
+        self.assertEqual(sorted(os.listdir(self.tmpdir)), ['bin', 'lib'])
+
+        # move bin + lib into local/, check whether expected symlinks are created
+        local_subdir = os.path.join(self.tmpdir, 'local')
+        mkdir(local_subdir)
+        for subdir in (bindir, libdir):
+            move_file(subdir, os.path.join(local_subdir, os.path.basename(subdir)))
+        self.assertEqual(os.listdir(self.tmpdir), ['local'])
+
+        pythonpackage.handle_local_py_install_scheme(self.tmpdir)
+        self.assertEqual(sorted(os.listdir(self.tmpdir)), ['bin', 'lib', 'local'])
+        self.assertTrue(os.path.islink(bindir) and os.path.samefile(bindir, os.path.join(local_subdir, 'bin')))
+        self.assertTrue(os.path.islink(libdir) and os.path.samefile(libdir, os.path.join(local_subdir, 'lib')))
+        self.assertTrue(os.path.exists(os.path.join(bindir, 'test')))
+        local_test_py = os.path.join(libdir, 'python' + pyshortver, 'site-packages', 'test.py')
+        self.assertTrue(os.path.exists(local_test_py))
+
+    def test_symlink_dist_site_packages(self):
+        """Test symlink_dist_site_packages provided by PythonPackage easyblock."""
+        pyshortver = '.'.join(str(x) for x in sys.version_info[:2])
+        pylibdir_lib_dist = os.path.join('lib', 'python' + pyshortver, 'dist-packages')
+        pylibdir_lib64_site = os.path.join('lib64', 'python' + pyshortver, 'site-packages')
+        pylibdirs = [pylibdir_lib_dist, pylibdir_lib64_site]
+
+        # first test on empty dir
+        pythonpackage.symlink_dist_site_packages(self.tmpdir, pylibdirs)
+        self.assertEqual(os.listdir(self.tmpdir), [])
+
+        # check intended usage: dist-packages exists, site-packages doesn't => symlink created
+        lib64_site_path = os.path.join(self.tmpdir, pylibdir_lib_dist)
+        mkdir(os.path.join(self.tmpdir, pylibdir_lib_dist), parents=True)
+        # also create (empty) site-packages directory, which should get replaced by a symlink to dist-packages
+        mkdir(os.path.join(self.tmpdir, os.path.dirname(pylibdir_lib_dist), 'site-packages'), parents=True)
+
+        lib64_site_path = os.path.join(self.tmpdir, pylibdir_lib64_site)
+        mkdir(lib64_site_path, parents=True)
+        # populate site-packages under lib64, because it'll get removed if empty
+        write_file(os.path.join(lib64_site_path, 'test.py'), "import os")
+
+        pythonpackage.symlink_dist_site_packages(self.tmpdir, pylibdirs)
+
+        # check for expected directory structure
+        self.assertEqual(sorted(os.listdir(self.tmpdir)), ['lib', 'lib64'])
+        path = os.path.join(self.tmpdir, 'lib')
+        self.assertEqual(os.listdir(path), ['python' + pyshortver])
+        path = os.path.join(path, 'python' + pyshortver)
+        self.assertEqual(sorted(os.listdir(path)), ['dist-packages', 'site-packages'])
+
+        # check for site-packages -> dist-packages symlink
+        dist_pkgs = os.path.join(path, 'dist-packages')
+        self.assertTrue(os.path.isdir(dist_pkgs))
+        self.assertFalse(os.path.islink(dist_pkgs))
+        site_pkgs = os.path.join(path, 'site-packages')
+        self.assertTrue(os.path.isdir(site_pkgs))
+        self.assertTrue(os.path.islink(site_pkgs))
+
+        # if (non-empty) site-packages dir was there, no changes were made
+        lib64_path = os.path.dirname(lib64_site_path)
+        self.assertEqual(sorted(os.listdir(lib64_path)), ['site-packages'])
+        self.assertTrue(os.path.isdir(lib64_site_path))
+        self.assertFalse(os.path.islink(lib64_site_path))
+
 
 def suite():
     """Return all easyblock-specific tests."""