Skip to content

Commit

Permalink
Provide a better error message when uninstalling packages without dis…
Browse files Browse the repository at this point in the history
…t-info/RECORD

Fixes pypa#8954
  • Loading branch information
hroncok committed May 10, 2021
1 parent e6414d6 commit f77649e
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 1 deletion.
9 changes: 9 additions & 0 deletions news/8954.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 20 additions & 1 deletion src/pip/_internal/req/req_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions tests/functional/test_uninstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down

0 comments on commit f77649e

Please sign in to comment.