Repository: pex-tool/pex

Issue:
PEP-376 reinstall does not account for `*.data/` entries.
The `<project_name>-<version>.data/` directory is handled specially in the "spread" phase of a wheel install as described here: https://www.python.org/dev/peps/pep-0427/#installing-a-wheel-distribution-1-0-py32-none-any-whl

This leaves an _updated_ RECORD file with the locations data files were spread to added but the original data files location in `<project_name>-<version>.data/` still recorded despite that directory having been removed as part of the spread:
```
../../bin/epylint,sha256=gWCAUcwzUZUyfgdvflyeFFehix1XTPqV9Hl4e_Je58o,209
../../bin/pylint,sha256=2iEC9b9ZdWNI_0w11yTdNV3azlEzF4IBBWpNUj8sBc8,207
../../bin/pyreverse,sha256=sxHCtv2bmwfJMN2cwQxamI36jMfOLXetJVZqp7ZqXJM,213
../../bin/symilar,sha256=SwozprAjc83BrSqO-4rhiX6S2JQroipmbuGxZY5Fo8A,209
pylint-1.9.5.data/scripts/epylint,sha256=ixZ-Def3hAP-hx6ZT9vLnK8GMPk2MrseCXzNpqMvxcU,50
pylint-1.9.5.data/scripts/pylint,sha256=jWwrvRhbHeEzaMyvUVbVrggaEQqMuAi5-T2LT1CE5IY,52
pylint-1.9.5.data/scripts/pyreverse,sha256=X-rZWORVwenc_jmraZN_juamsTqOPef1W40b2DL3-Ao,58
pylint-1.9.5.data/scripts/symilar,sha256=KaS6U6LHbe9oTqPbZbNeKOnFTwp9Fml_DUDo19q6rRI,54
pylint-1.9.5.dist-info/COPYING,sha256=w4runjyMTV1ZT_VIob4FRTAjAW1ihpMfZRLbIV7B_UI,17989
pylint-1.9.5.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
pylint-1.9.5.dist-info/METADATA,sha256=odbwlq-6FiDyHZi868R3qsUV6nWIqHEpvcOGxe-sDVg,4223
pylint-1.9.5.dist-info/RECORD,,
pylint-1.9.5.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
pylint-1.9.5.dist-info/WHEEL,sha256=h_aVn5OB2IERUjMbi2pucmR_zzWJtk303YXvhh60NJ8,110
pylint-1.9.5.dist-info/entry_points.txt,sha256=WUTwHM2ZcExO-VSvss18AMFsQL-XcWg6O3_MwobWfmw,137
pylint-1.9.5.dist-info/top_level.txt,sha256=j6Z9i__pIuaiCka6Ul9YIy6yI5aw5QbCtldLvZlMksE,7
pylint/__init__.py,sha256=kHTBUPFDHeu8wPp9fHI9bKzVgdyyFsPwVs8YH85Okjs,871
pylint/__main__.py,sha256=5oMFhCPbGHGwyWCtbq8V07lMjBFcKb_H1hhpeovGahc,206
pylint/__pkginfo__.py,sha256=kp-eTU54RyhwZOe9uJs-pZpWpI65BVkKj273Txb0wcU,4470
pylint/checkers/__init__.py,sha256=7dWxrJsIaB0rZbgte6dWqxvBJWspgNa1D4yCWJmEaws,3718
...
```

This leads to errors creating venvs with distributions that contain data files packaged this way: https://github.com/pantsbuild/pants/runs/5376750455?check_suite_focus=true#step:14:9446
```
E           Traceback (most recent call last):
E             File "/home/runner/.cache/pants/named_caches/pex_root/unzipped_pexes/a7d8e48450480e90eacc4584a9f14d1ea94a1c76/.bootstrap/pex/pex.py", line 503, in execute
E               exit_value = self._wrap_coverage(self._wrap_profiling, self._execute)
E             File "/home/runner/.cache/pants/named_caches/pex_root/unzipped_pexes/a7d8e48450480e90eacc4584a9f14d1ea94a1c76/.bootstrap/pex/pex.py", line 408, in _wrap_coverage
E               return runner(*args)
E             File "/home/runner/.cache/pants/named_caches/pex_root/unzipped_pexes/a7d8e48450480e90eacc4584a9f14d1ea94a1c76/.bootstrap/pex/pex.py", line 439, in _wrap_profiling
E               return runner(*args)
E             File "/home/runner/.cache/pants/named_caches/pex_root/unzipped_pexes/a7d8e48450480e90eacc4584a9f14d1ea94a1c76/.bootstrap/pex/pex.py", line 559, in _execute
E               return self.execute_entry(self._pex_info.entry_point)
E             File "/home/runner/.cache/pants/named_caches/pex_root/unzipped_pexes/a7d8e48450480e90eacc4584a9f14d1ea94a1c76/.bootstrap/pex/pex.py", line 695, in execute_entry
E               return self.execute_pkg_resources(entry_point)
E             File "/home/runner/.cache/pants/named_caches/pex_root/unzipped_pexes/a7d8e48450480e90eacc4584a9f14d1ea94a1c76/.bootstrap/pex/pex.py", line 727, in execute_pkg_resources
E               return runner()
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/bin/pex.py", line 693, in main
E               env=env,
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/bin/pex.py", line 733, in do_main
E               seed_info = seed_cache(options, pex, verbose=options.seed == Seed.VERBOSE)
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/bin/pex.py", line 771, in seed_cache
E               venv_pex = ensure_venv(pex)
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/pex_bootstrapper.py", line 469, in ensure_venv
E               symlink=not pex_info.venv_site_packages_copies,
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/venv/pex.py", line 120, in populate_venv
E               record_provenance(_populate_deps(venv, pex, venv_python, symlink))
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/venv/pex.py", line 116, in record_provenance
E               for src, dst in src_to_dst:
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/venv/pex.py", line 216, in _populate_deps
E               for src, dst in record.reinstall(venv, symlink=symlink, rel_extra_path=rel_extra_path):
E             File "/home/runner/.cache/pants/named_caches/pex_root/installed_wheels/ed73db6b151853905ab87d476bb04e3054308a1f/pex-2.1.69-py2.py3-none-any.whl/pex/pep_376.py", line 425, in reinstall
E               raise e
E           OSError: [Errno 2] No such file or directory
```


Discussion:
I think the right thing to do is continue to try and fail for all RECORD entries that are missing on disk but give a pass to `<project-name>-<version>.data/` directory entries that are missing since we know they are specced to be spread.

Resolving patch:
diff --git a/pex/pep_376.py b/pex/pep_376.py
--- a/pex/pep_376.py
+++ b/pex/pep_376.py
@@ -30,7 +30,17 @@
         from hashlib import _hash as _Hash
     else:
         from hashlib import _Hash
-    from typing import Callable, Dict, Iterable, Iterator, Optional, Protocol, Tuple, Union
+    from typing import (
+        Callable,
+        Container,
+        Dict,
+        Iterable,
+        Iterator,
+        Optional,
+        Protocol,
+        Tuple,
+        Union,
+    )
 
     import attr  # vendor:skip
 
@@ -161,11 +171,15 @@ def load(
                 )
             )
         metadata_dir = os.path.dirname(relative_path)
+        data_dir = "{project_name_and_version}.data".format(
+            project_name_and_version=metadata_dir[: -len(".dist-info")]
+        )
         return cls(
             project_name=dist.project_name,
             version=dist.version,
             base_location=dist.location,
             relative_path=relative_path,
+            data_dir=data_dir,
             metadata_listing=tuple(
                 path for path in listing if metadata_dir == os.path.dirname(path)
             ),
@@ -176,6 +190,7 @@ def load(
     version = attr.ib()  # type: str
     base_location = attr.ib()  # type: str
     relative_path = attr.ib()  # type: str
+    _data_dir = attr.ib()  # type: str
     _metadata_listing = attr.ib()  # type: Tuple[str, ...]
     install_scheme = attr.ib(default=InstallationScheme.TARGET)  # type: InstallationScheme.Value
 
@@ -329,6 +344,25 @@ def _fixup_record(self, modified_scripts=None):
                         )
                     )
 
+    def _handle_record_error(
+        self,
+        record_path,  # type: str
+        error,  # type: Union[IOError, OSError]
+        expected_errors=(errno.EEXIST,),  # type: Container[int]
+    ):
+        # type: (...) -> None
+        if error.errno in expected_errors:
+            return
+
+        # It's expected that `*.data/*` dir entries won't exist. These entries are left in the
+        # RECORD but the files they refer to are all "spread" to other locations during install.
+        #
+        # See: https://www.python.org/dev/peps/pep-0427/#installing-a-wheel-distribution-1-0-py32-none-any-whl
+        if error.errno == errno.ENOENT and record_path.startswith(self._data_dir):
+            return
+
+        raise error
+
     def reinstall(
         self,
         venv,  # type: Virtualenv
@@ -416,13 +450,15 @@ def reinstall(
                             os.link(src, dst)
                             continue
                         except OSError as e:
-                            if e.errno != errno.EXDEV:
-                                raise e
+                            self._handle_record_error(
+                                record_path=installed_file_relpath,
+                                error=e,
+                                expected_errors=(errno.EXDEV,),
+                            )
                             link = False
                     shutil.copy(src, dst)
-                except OSError as e:
-                    if e.errno != errno.EEXIST:
-                        raise e
+                except (IOError, OSError) as e:
+                    self._handle_record_error(record_path=installed_file_relpath, error=e)
 
         for top_level in symlinks:
             src = os.path.join(self.base_location, top_level)
@@ -434,5 +470,4 @@ def reinstall(
             try:
                 os.symlink(rel_src, dst)
             except OSError as e:
-                if e.errno != errno.EEXIST:
-                    raise e
+                self._handle_record_error(record_path=top_level, error=e)


Test patch:
diff --git a/tests/integration/venv_ITs/test_issue_1641.py b/tests/integration/venv_ITs/test_issue_1641.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/venv_ITs/test_issue_1641.py
@@ -0,0 +1,79 @@
+# Copyright 2022 Pants project contributors (see CONTRIBUTORS.md).
+# Licensed under the Apache License, Version 2.0 (see LICENSE).
+import os.path
+import subprocess
+from textwrap import dedent
+
+import pytest
+
+from pex.testing import PY_VER, run_pex_command
+from pex.typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Any, List
+
+
+@pytest.mark.skipif(
+    PY_VER >= (3, 7),
+    reason=(
+        "Newer versions of pylint do not use .data/ packaging and the older 1.9.5 version of "
+        "pylint requires Python <3.7"
+    ),
+)
+@pytest.mark.parametrize(
+    ["execution_mode_args"],
+    [
+        pytest.param([], id="zipapp"),
+        # N.B.: The pylint plugin discovery system does not work with --venv symlink mode.
+        pytest.param(["--venv", "--venv-site-packages-copies"], id="venv (site-packages copies)"),
+    ],
+)
+def test_missing_data_dir_entries(
+    tmpdir,  # type: Any
+    execution_mode_args,  # type: List[str]
+):
+    # type: (...) -> None
+
+    constraints = os.path.join(str(tmpdir), "constraints")
+    with open(constraints, "w") as fp:
+        # N.B.: This pinned resolve is known to work with Python 2.7 through 3.6 which suits our
+        # purposes for the range of tests run.
+        fp.write(
+            dedent(
+                """\
+                astroid==1.6.6
+                backports.functools-lru-cache==1.6.4
+                configparser==4.0.2
+                enum34==1.1.10
+                futures==3.3.0
+                isort==4.3.21
+                lazy-object-proxy==1.5.2
+                mccabe==0.6.1
+                singledispatch==3.7.0
+                six==1.16.0
+                wrapt==1.13.3
+                """
+            )
+        )
+    pex_root = os.path.join(str(tmpdir), "pex_root")
+    pylint_pex = os.path.join(str(tmpdir), "pylint.pex")
+    run_pex_command(
+        args=[
+            "pylint==1.9.5",
+            "setuptools==44.1.1",
+            "--constraints",
+            constraints,
+            "-c",
+            "pylint",
+            "-o",
+            pylint_pex,
+            "--pex-root",
+            pex_root,
+            "--runtime-pex-root",
+            pex_root,
+        ]
+        + execution_mode_args
+    ).assert_success()
+
+    output = subprocess.check_output(args=[pylint_pex, "--version"])
+    assert " 1.9.5," in output.decode("utf-8")