Repository: pypa/pip

Issue:
pip._internal.utils.glibc_version_string() can use os.confstr('CS_GNU_LIBC_VERSION') to avoid ctypes
Currently, the pip._internal.utils.glibc_version_string() function is implemented with ctypes to access gnu_get_libc_version() function. But os.confstr('CS_GNU_LIBC_VERSION') could be used instead to avoid ctypes.

I recently modified platform.libc_ver() on Python stdlib to use os.confstr('CS_GNU_LIBC_VERSION') is available:
* https://bugs.python.org/issue35389
* https://github.com/python/cpython/blob/d4efd917ac24940063a1ce80073fe3570c5f07f8/Lib/platform.py#L174-L183

```py
    if executable is None:
        try:
            ver = os.confstr('CS_GNU_LIBC_VERSION')
            # parse 'glibc 2.28' as ('glibc', '2.28')
            parts = ver.split(maxsplit=1)
            if len(parts) == 2:
                return tuple(parts)
        except (AttributeError, ValueError, OSError):
            # os.confstr() or CS_GNU_LIBC_VERSION value not available
            pass
```

Note: I noticed this issue when an user reported a traceback in pip when the ctypes is not available: https://mail.python.org/archives/list/python-dev@python.org/thread/MTIRNYFAZTQQPHKAQXXREP33NYV2TW2J/
Handle ImportError and OSError when importing ctypes (#6543)
Non-dynamic executables can raise OSError when importing ctypes
because dlopen(NULL) is called on module import and dlopen()
won't work on non-dynamic executables.

This commit teaches the glibc version sniffing module to
handle a missing or not working ctypes module.

With this change applied, `pip install` works on non-dynamic / fully statically linked Python executables on Linux.



Discussion:
Thanks for filing this @vstinner!

---

If anyone wants to tackle this and file a PR, that'd be awesome!
How does this impact PR #6544 / issue #6543?
> How does this impact PR #6544 / issue #6543?

The best would be if pip could avoid completely ctypes, but I'm not sure if it's possible. In the past, I reported issues times to times when pip broke when ctypes was not available, but it seems to be a lost battle: pip or pip dependencies commonly use ctypes. Instead, on the Python upstream CI, we now try to ensure that ctypes is always available on buildbots.

If you want to test a Python without ctypes, inject this code before using pip:

```
import sys
sys.modules['_ctypes'] = None
sys.modules['ctypes'] = None
```
I'm putting together a PR for this based on the usage of `os.confstr` above.


Resolving patch:
diff --git a/src/pip/_internal/utils/glibc.py b/src/pip/_internal/utils/glibc.py
--- a/src/pip/_internal/utils/glibc.py
+++ b/src/pip/_internal/utils/glibc.py
@@ -1,6 +1,6 @@
 from __future__ import absolute_import
 
-import ctypes
+import os
 import re
 import warnings
 
@@ -13,6 +13,33 @@
 def glibc_version_string():
     # type: () -> Optional[str]
     "Returns glibc version string, or None if not using glibc."
+    return glibc_version_string_confstr() or glibc_version_string_ctypes()
+
+
+def glibc_version_string_confstr():
+    # type: () -> Optional[str]
+    "Primary implementation of glibc_version_string using os.confstr."
+    # os.confstr is quite a bit faster than ctypes.DLL. It's also less likely
+    # to be broken or missing. This strategy is used in the standard library
+    # platform module:
+    # https://github.com/python/cpython/blob/fcf1d003bf4f0100c9d0921ff3d70e1127ca1b71/Lib/platform.py#L175-L183
+    try:
+        # os.confstr("CS_GNU_LIBC_VERSION") returns a string like "glibc 2.17":
+        _, version = os.confstr("CS_GNU_LIBC_VERSION").split()
+    except (AttributeError, OSError, ValueError):
+        # os.confstr() or CS_GNU_LIBC_VERSION not available (or a bad value)...
+        return None
+    return version
+
+
+def glibc_version_string_ctypes():
+    # type: () -> Optional[str]
+    "Fallback implementation of glibc_version_string using ctypes."
+
+    try:
+        import ctypes
+    except ImportError:
+        return None
 
     # ctypes.CDLL(None) internally calls dlopen(NULL), and as the dlopen
     # manpage says, "If filename is NULL, then the returned handle is for the
@@ -56,7 +83,7 @@ def check_glibc_version(version_str, required_major, minimum_minor):
 
 def have_compatible_glibc(required_major, minimum_minor):
     # type: (int, int) -> bool
-    version_str = glibc_version_string()  # type: Optional[str]
+    version_str = glibc_version_string()
     if version_str is None:
         return False
     return check_glibc_version(version_str, required_major, minimum_minor)


Test patch:
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -26,7 +26,10 @@
 )
 from pip._internal.utils.deprecation import PipDeprecationWarning, deprecated
 from pip._internal.utils.encoding import BOMS, auto_decode
-from pip._internal.utils.glibc import check_glibc_version
+from pip._internal.utils.glibc import (
+    check_glibc_version, glibc_version_string, glibc_version_string_confstr,
+    glibc_version_string_ctypes,
+)
 from pip._internal.utils.hashes import Hashes, MissingHashes
 from pip._internal.utils.misc import (
     call_subprocess, egg_link_path, ensure_dir, format_command_args,
@@ -668,6 +671,10 @@ def raising_mkdir(*args, **kwargs):
                     pass
 
 
+def raises(error):
+    raise error
+
+
 class TestGlibc(object):
     def test_manylinux_check_glibc_version(self):
         """
@@ -701,6 +708,35 @@ def test_manylinux_check_glibc_version(self):
                     # Didn't find the warning we were expecting
                     assert False
 
+    def test_glibc_version_string(self, monkeypatch):
+        monkeypatch.setattr(
+            os, "confstr", lambda x: "glibc 2.20", raising=False,
+        )
+        assert glibc_version_string() == "2.20"
+
+    def test_glibc_version_string_confstr(self, monkeypatch):
+        monkeypatch.setattr(
+            os, "confstr", lambda x: "glibc 2.20", raising=False,
+        )
+        assert glibc_version_string_confstr() == "2.20"
+
+    @pytest.mark.parametrize("failure", [
+        lambda x: raises(ValueError),
+        lambda x: raises(OSError),
+        lambda x: "XXX",
+    ])
+    def test_glibc_version_string_confstr_fail(self, monkeypatch, failure):
+        monkeypatch.setattr(os, "confstr", failure, raising=False)
+        assert glibc_version_string_confstr() is None
+
+    def test_glibc_version_string_confstr_missing(self, monkeypatch):
+        monkeypatch.delattr(os, "confstr", raising=False)
+        assert glibc_version_string_confstr() is None
+
+    def test_glibc_version_string_ctypes_missing(self, monkeypatch):
+        monkeypatch.setitem(sys.modules, "ctypes", None)
+        assert glibc_version_string_ctypes() is None
+
 
 @pytest.mark.parametrize('version_info, expected', [
     (None, None),