Repository: pypa/virtualenv

Issue:
[20.0.8] import hook assumes existence of `exec_module` but the api is optional according to PEP 302/451
A contained example is https://github.com/asottile/pymonkey

the import machinery in virtualenv 20.0.8 assumes all loaders have an `exec_module` attribute, however that's optional (and not part of PEP 302 at all)

The crash in my case looks like:

```
Traceback (most recent call last):
  File "<frozen importlib._bootstrap>", line 888, in _find_spec
AttributeError: 'PymonkeyImportHook' object has no attribute 'find_spec'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/tmp/pymonkey/testing/pkg1/patchingmod_main.py", line 8, in <module>
    exit(main())
  File "/tmp/pymonkey/pymonkey.py", line 270, in entry
    tuple(patches) + ('--', original_entry_point) + tuple(argv)
  File "/tmp/pymonkey/pymonkey.py", line 258, in main
    return entry.load()()
  File "/tmp/pymonkey/.tox/py36/lib/python3.6/site-packages/pkg_resources/__init__.py", line 2445, in load
    return self.resolve()
  File "/tmp/pymonkey/.tox/py36/lib/python3.6/site-packages/pkg_resources/__init__.py", line 2451, in resolve
    module = __import__(self.module_name, fromlist=['__name__'], level=0)
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 955, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 656, in _load_unlocked
  File "<frozen importlib._bootstrap>", line 626, in _load_backward_compatible
  File "/tmp/pymonkey/pymonkey.py", line 173, in load_module
    module = importmod(fullname)
  File "/tmp/pymonkey/pymonkey.py", line 94, in importmod
    return __import__(mod, fromlist=[str('__name__')], level=0)
  File "/tmp/pymonkey/testing/pkg2/targetmod.py", line 4, in <module>
    import setuptools  # Some weird import hook
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 955, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 656, in _load_unlocked
  File "<frozen importlib._bootstrap>", line 626, in _load_backward_compatible
  File "/tmp/pymonkey/pymonkey.py", line 173, in load_module
    module = importmod(fullname)
  File "/tmp/pymonkey/pymonkey.py", line 94, in importmod
    return __import__(mod, fromlist=[str('__name__')], level=0)
  File "/tmp/pymonkey/.tox/py36/lib/python3.6/site-packages/setuptools/__init__.py", line 5, in <module>
    import distutils.core
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 955, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 656, in _load_unlocked
  File "<frozen importlib._bootstrap>", line 626, in _load_backward_compatible
  File "/tmp/pymonkey/pymonkey.py", line 173, in load_module
    module = importmod(fullname)
  File "/tmp/pymonkey/pymonkey.py", line 94, in importmod
    return __import__(mod, fromlist=[str('__name__')], level=0)
  File "/usr/lib/python3.6/distutils/core.py", line 16, in <module>
    from distutils.dist import Distribution
  File "<frozen importlib._bootstrap>", line 971, in _find_and_load
  File "<frozen importlib._bootstrap>", line 951, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 890, in _find_spec
  File "<frozen importlib._bootstrap>", line 864, in _find_spec_legacy
  File "/tmp/pymonkey/pymonkey.py", line 162, in find_module
    elif self._module_exists(fullname, path):
  File "/tmp/pymonkey/pymonkey.py", line 122, in _module_exists
    getattr(entry, 'find_spec', _noop)(module, path) or
  File "/tmp/pymonkey/.tox/py36/lib/python3.6/site-packages/_virtualenv.py", line 58, in find_spec
    old = spec.loader.exec_module
AttributeError: 'PymonkeyImportHook' object has no attribute 'exec_module'
```


Discussion:
here's the sample loading code from PEP 451: https://www.python.org/dev/peps/pep-0451/#how-loading-will-work

Resolving patch:
diff --git a/src/virtualenv/create/via_global_ref/_virtualenv.py b/src/virtualenv/create/via_global_ref/_virtualenv.py
--- a/src/virtualenv/create/via_global_ref/_virtualenv.py
+++ b/src/virtualenv/create/via_global_ref/_virtualenv.py
@@ -3,6 +3,7 @@
 
 import os
 import sys
+from copy import deepcopy
 
 VIRTUALENV_PATCH_FILE = os.path.join(__file__)
 
@@ -55,13 +56,24 @@ def find_spec(self, fullname, path, target=None):
                     try:
                         spec = find_spec(fullname, path)
                         if spec is not None:
-                            old = spec.loader.exec_module
+                            # https://www.python.org/dev/peps/pep-0451/#how-loading-will-work
+                            spec.loader = deepcopy(spec.loader)  # loaders may be shared, create new that also patches
+                            func_name = "exec_module" if hasattr(spec.loader, "exec_module") else "load_module"
+                            if func_name == "exec_module":  # new API
 
-                            def exec_module(module):
-                                old(module)
-                                patch_dist(module)
+                                def patch_module_load(module):
+                                    old(module)
+                                    patch_dist(module)
 
-                            spec.loader.exec_module = exec_module
+                            else:  # legacy API
+
+                                def patch_module_load(name):
+                                    module = old(name)
+                                    patch_dist(module)
+                                    return module
+
+                            old = getattr(spec.loader, func_name)
+                            setattr(spec.loader, func_name, patch_module_load)
                             return spec
                     finally:
                         self.fullname = None


Test patch: