Repository: pypa/pip

Issue:
pip 20.2 - ValueError exception on improperly formatted package
**Environment**

* pip version: 20.2
* Python version: 3.6
* OS: Ubuntu 18.04

**Description**

`pip install` now fails to install improperly packaged python project.  The project has installed a file to 'purelib', rather than under the 'purelib' directory.  While the broken project needs to be fixed, pip is now throwing an exception without a hint.

This change in behavior is from #8562 (https://github.com/pypa/pip/pull/8562#discussion_r462531471).

**Expected behavior**

Prior to 20.2, pip install worked fine.  The purelib file was not required and was seemingly ignored.

**How to Reproduce**

Using Ubuntu 18.04:
`pip install -v http://archive.ubuntu.com/ubuntu/pool/main/p/python-apt/python-apt_1.6.5ubuntu0.2.tar.xz`

**Output**

```
ERROR: Exception:
Traceback (most recent call last):
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/cli/base_command.py", line 216, in _main
    status = self.run(options, args)
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/cli/req_command.py", line 182, in wrapper
    return func(self, options, args)
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/commands/install.py", line 421, in run
    pycompile=options.compile,
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/req/__init__.py", line 90, in install_given_reqs
    pycompile=pycompile,
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/req/req_install.py", line 831, in install
    requested=self.user_supplied,
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/operations/install/wheel.py", line 830, in install_wheel
    requested=requested,
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/operations/install/wheel.py", line 658, in _install_wheel
    for file in files:
  File "/tmp/venv/lib/python3.6/site-packages/pip/_internal/operations/install/wheel.py", line 587, in make_data_scheme_file
    _, scheme_key, dest_subpath = normed_path.split(os.path.sep, 2)
ValueError: not enough values to unpack (expected 3, got 2)
```

In this case, normed_path is `python_apt-0.0.0.data/purelib`, and points to a file (not a directory).


Discussion:
To add to the reproduction instructions:

```
cd "$(mktemp -d)"
python -m venv .env
./.env/bin/python -m pip install --upgrade pip
./.env/bin/python -m pip install wheel
sudo apt install libapt-pkg-dev
./.env/bin/python -m pip install -v http://archive.ubuntu.com/ubuntu/pool/main/p/python-apt/python-apt_1.6.5ubuntu0.2.tar.xz
```

which results in

```
ERROR: Exception:
Traceback (most recent call last):
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/cli/base_command.py", line 216, in _main
    status = self.run(options, args)
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/cli/req_command.py", line 182, in wrapper
    return func(self, options, args)
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/commands/install.py", line 412, in run
    installed = install_given_reqs(
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/req/__init__.py", line 82, in install_given_reqs
    requirement.install(
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/req/req_install.py", line 823, in install
    install_wheel(
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/operations/install/wheel.py", line 821, in install_wheel
    _install_wheel(
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/operations/install/wheel.py", line 657, in _install_wheel
    for file in files:
  File "/tmp/user/1000/tmp.wTL19RjBCa/.env/lib/python3.8/site-packages/pip/_internal/operations/install/wheel.py", line 586, in make_data_scheme_file
    _, scheme_key, dest_subpath = normed_path.split(os.path.sep, 2)
ValueError: not enough values to unpack (expected 3, got 2)
```

Resolving patch:
diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py
--- a/src/pip/_internal/operations/install/wheel.py
+++ b/src/pip/_internal/operations/install/wheel.py
@@ -583,8 +583,28 @@ def data_scheme_file_maker(zip_file, scheme):
         def make_data_scheme_file(record_path):
             # type: (RecordPath) -> File
             normed_path = os.path.normpath(record_path)
-            _, scheme_key, dest_subpath = normed_path.split(os.path.sep, 2)
-            scheme_path = scheme_paths[scheme_key]
+            try:
+                _, scheme_key, dest_subpath = normed_path.split(os.path.sep, 2)
+            except ValueError:
+                message = (
+                    "Unexpected file in {}: {!r}. .data directory contents"
+                    " should be named like: '<scheme key>/<path>'."
+                ).format(wheel_path, record_path)
+                raise InstallationError(message)
+
+            try:
+                scheme_path = scheme_paths[scheme_key]
+            except KeyError:
+                valid_scheme_keys = ", ".join(sorted(scheme_paths))
+                message = (
+                    "Unknown scheme key used in {}: {} (for file {!r}). .data"
+                    " directory contents should be in subdirectories named"
+                    " with a valid scheme key ({})"
+                ).format(
+                    wheel_path, scheme_key, record_path, valid_scheme_keys
+                )
+                raise InstallationError(message)
+
             dest_path = os.path.join(scheme_path, dest_subpath)
             assert_no_path_traversal(scheme_path, dest_path)
             return ZipBackedFile(record_path, dest_path, zip_file)


Test patch:
diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py
--- a/tests/functional/test_install_wheel.py
+++ b/tests/functional/test_install_wheel.py
@@ -681,3 +681,36 @@ def test_correct_package_name_while_creating_wheel_bug(script, package_name):
     package = create_basic_wheel_for_package(script, package_name, '1.0')
     wheel_name = os.path.basename(package)
     assert wheel_name == 'simple_package-1.0-py2.py3-none-any.whl'
+
+
+@pytest.mark.parametrize("name", ["purelib", "abc"])
+def test_wheel_with_file_in_data_dir_has_reasonable_error(
+    script, tmpdir, name
+):
+    """Normally we expect entities in the .data directory to be in a
+    subdirectory, but if they are not then we should show a reasonable error
+    message that includes the path.
+    """
+    wheel_path = make_wheel(
+        "simple", "0.1.0", extra_data_files={name: "hello world"}
+    ).save_to_dir(tmpdir)
+
+    result = script.pip(
+        "install", "--no-index", str(wheel_path), expect_error=True
+    )
+    assert "simple-0.1.0.data/{}".format(name) in result.stderr
+
+
+def test_wheel_with_unknown_subdir_in_data_dir_has_reasonable_error(
+    script, tmpdir
+):
+    wheel_path = make_wheel(
+        "simple",
+        "0.1.0",
+        extra_data_files={"unknown/hello.txt": "hello world"}
+    ).save_to_dir(tmpdir)
+
+    result = script.pip(
+        "install", "--no-index", str(wheel_path), expect_error=True
+    )
+    assert "simple-0.1.0.data/unknown/hello.txt" in result.stderr