diff --git a/news/8954.feature.rst b/news/8954.feature.rst new file mode 100644 index 00000000000..05ec68d048e --- /dev/null +++ b/news/8954.feature.rst @@ -0,0 +1,9 @@ +When pip is asked to uninstall a project without the dist-info/RECORD file +it will no longer traceback with FileNotFoundError, +but it will provide a better error message instead, such as:: + + ERROR: Cannot uninstall foobar 0.1, RECORD file not found. You might be able to recover from this via: 'pip install --force-reinstall --no-deps foobar==0.1'. + +When dist-info/INSTALLER is present and contains some useful information, the info is included in the error message instead:: + + ERROR: Cannot uninstall foobar 0.1, RECORD file not found. Hint: The package was installed by rpm. diff --git a/src/pip/_internal/req/req_uninstall.py b/src/pip/_internal/req/req_uninstall.py index b72234175b2..a11b225ac64 100644 --- a/src/pip/_internal/req/req_uninstall.py +++ b/src/pip/_internal/req/req_uninstall.py @@ -74,8 +74,27 @@ def uninstallation_paths(dist): the .pyc and .pyo in the same directory. UninstallPathSet.add() takes care of the __pycache__ .py[co]. + + If RECORD is not found, raises UninstallationError, + with possible information from the INSTALLER file. + + https://packaging.python.org/specifications/recording-installed-packages/ """ - r = csv.reader(dist.get_metadata_lines('RECORD')) + try: + r = csv.reader(dist.get_metadata_lines('RECORD')) + except FileNotFoundError as missing_record_exception: + msg = 'Cannot uninstall {dist}, RECORD file not found.'.format(dist=dist) + try: + installer = next(dist.get_metadata_lines('INSTALLER')) + if not installer or installer == 'pip': + raise ValueError() + except (OSError, StopIteration, ValueError): + dep = '{}=={}'.format(dist.project_name, dist.version) + msg += (" You might be able to recover from this via: " + "'pip install --force-reinstall --no-deps {}'.".format(dep)) + else: + msg += ' Hint: The package was installed by {}.'.format(installer) + raise UninstallationError(msg) from missing_record_exception for row in r: path = os.path.join(dist.location, row[0]) yield path diff --git a/tests/functional/test_uninstall.py b/tests/functional/test_uninstall.py index 878e713ed9e..cbce8746a23 100644 --- a/tests/functional/test_uninstall.py +++ b/tests/functional/test_uninstall.py @@ -476,6 +476,51 @@ def test_uninstall_wheel(script, data): assert_all_changes(result, result2, []) +@pytest.mark.parametrize('installer', [FileNotFoundError, IsADirectoryError, + '', os.linesep, b'\xc0\xff\xee', 'pip', + 'MegaCorp Cloud Install-O-Matic']) +def test_uninstall_without_record_fails(script, data, installer): + """ + Test uninstalling a package installed without RECORD + """ + package = data.packages.joinpath("simple.dist-0.1-py2.py3-none-any.whl") + result = script.pip('install', package, '--no-index') + dist_info_folder = script.site_packages / 'simple.dist-0.1.dist-info' + result.did_create(dist_info_folder) + + # Remove RECORD + record_path = dist_info_folder / 'RECORD' + (script.base_path / record_path).unlink() + ignore_changes = [record_path] + + # Populate, remove or otherwise break INSTALLER + installer_path = dist_info_folder / 'INSTALLER' + ignore_changes += [installer_path] + installer_path = script.base_path / installer_path + if installer in (FileNotFoundError, IsADirectoryError): + installer_path.unlink() + if installer is IsADirectoryError: + installer_path.mkdir() + else: + if isinstance(installer, bytes): + installer_path.write_bytes(installer) + else: + installer_path.write_text(installer + os.linesep) + + result2 = script.pip('uninstall', 'simple.dist', '-y', expect_error=True) + expected_error_message = ('ERROR: Cannot uninstall simple.dist 0.1, ' + 'RECORD file not found.') + if not isinstance(installer, str) or not installer.strip() or installer == 'pip': + expected_error_message += (" You might be able to recover from this via: " + "'pip install --force-reinstall --no-deps " + "simple.dist==0.1'.") + elif installer: + expected_error_message += (' Hint: The package was installed by ' + '{}.'.format(installer)) + assert result2.stderr.rstrip() == expected_error_message + assert_all_changes(result.files_after, result2, ignore_changes) + + @pytest.mark.skipif("sys.platform == 'win32'") def test_uninstall_with_symlink(script, data, tmpdir): """