Skip to content

Commit

Permalink
Correctly normalize relative paths for 'pip show'
Browse files Browse the repository at this point in the history
  • Loading branch information
uranusjr committed Jul 31, 2021
1 parent aaba499 commit 08eee0b
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 49 deletions.
56 changes: 49 additions & 7 deletions src/pip/_internal/commands/show.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import csv
import logging
import os
import pathlib
from optparse import Values
from typing import Iterator, List, NamedTuple, Optional
from typing import Iterator, List, NamedTuple, Optional, Tuple

from pip._vendor.packaging.utils import canonicalize_name

Expand Down Expand Up @@ -66,6 +66,33 @@ class _PackageInfo(NamedTuple):
files: Optional[List[str]]


def _covert_legacy_entry(entry: Tuple[str, ...], info: Tuple[str, ...]) -> str:
"""Convert a legacy installed-files.txt path into modern RECORD path.
The legacy format stores paths relative to the info directory, while the
modern format stores paths relative to the package root, e.g. the
site-packages directory.
:param entry: Path parts of the installed-files.txt entry.
:param info: Path parts of the egg-info directory relative to package root.
:returns: The converted entry.
For best compatibility with symlinks, this does not use ``abspath()`` or
``Path.resolve()``, but tries to work with path parts:
1. While ``entry`` starts with ``..``, remove the equal amounts of parts
from ``info``; if ``info`` is empty, start appending ``..`` instead.
2. Join the two directly.
"""
while entry and entry[0] == "..":
if not info or info[-1] == "..":
info += ("..",)
else:
info = info[:-1]
entry = entry[1:]
return str(pathlib.Path(*info, *entry))


def search_packages_info(query: List[str]) -> Iterator[_PackageInfo]:
"""
Gather details from installed distributions. Print distribution name,
Expand Down Expand Up @@ -100,14 +127,29 @@ def _files_from_record(dist: BaseDistribution) -> Optional[Iterator[str]]:
text = dist.read_text('RECORD')
except FileNotFoundError:
return None
return (row[0] for row in csv.reader(text.splitlines()))
# This extra Path-str cast normalizes entries.
return (str(pathlib.Path(row[0])) for row in csv.reader(text.splitlines()))

def _files_from_installed_files(dist: BaseDistribution) -> Optional[Iterator[str]]:
def _files_from_legacy(dist: BaseDistribution) -> Optional[Iterator[str]]:
try:
text = dist.read_text('installed-files.txt')
except FileNotFoundError:
return None
return (p for p in text.splitlines(keepends=False) if p)
paths = (p for p in text.splitlines(keepends=False) if p)
root = dist.location
info = dist.info_directory
if root is None or info is None:
return paths
try:
info_rel = pathlib.Path(info).relative_to(root)
except ValueError: # info is not relative to root.
return paths
if not info_rel.parts: # info *is* root.
return paths
return (
_covert_legacy_entry(pathlib.Path(p).parts, info_rel.parts)
for p in paths
)

for query_name in query_names:
try:
Expand All @@ -121,11 +163,11 @@ def _files_from_installed_files(dist: BaseDistribution) -> Optional[Iterator[str
except FileNotFoundError:
entry_points = []

files_iter = _files_from_record(dist) or _files_from_installed_files(dist)
files_iter = _files_from_record(dist) or _files_from_legacy(dist)
if files_iter is None:
files: Optional[List[str]] = None
else:
files = sorted(os.path.relpath(p, dist.location) for p in files_iter)
files = sorted(files_iter)

metadata = dist.metadata

Expand Down
20 changes: 20 additions & 0 deletions src/pip/_internal/metadata/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ def location(self) -> Optional[str]:
A string value is not necessarily a filesystem path, since distributions
can be loaded from other sources, e.g. arbitrary zip archives. ``None``
means the distribution is created in-memory.
Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If
this is a symbolic link, we want to preserve the relative path between
it and files in the distribution.
"""
raise NotImplementedError()

@property
def info_directory(self) -> Optional[str]:
"""Location of the .[egg|dist]-info directory.
Similarly to ``location``, a string value is not necessarily a
filesystem path. ``None`` means the distribution is created in-memory.
For a modern .dist-info installation on disk, this should be something
like ``{location}/{raw_name}-{version}.dist-info``.
Do not canonicalize this value with e.g. ``pathlib.Path.resolve()``. If
this is a symbolic link, we want to preserve the relative path between
it and other files in the distribution.
"""
raise NotImplementedError()

Expand Down
4 changes: 4 additions & 0 deletions src/pip/_internal/metadata/pkg_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def from_wheel(cls, path: str, name: str) -> "Distribution":
def location(self) -> Optional[str]:
return self._dist.location

@property
def info_directory(self) -> Optional[str]:
return self._dist.egg_info

@property
def canonical_name(self) -> "NormalizedName":
return canonicalize_name(self._dist.project_name)
Expand Down
75 changes: 41 additions & 34 deletions src/pip/_internal/operations/install/legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,46 @@ def __init__(self):
self.parent = sys.exc_info()


def write_installed_files_from_setuptools_record(
record_lines: List[str],
root: Optional[str],
req_description: str,
) -> None:
def prepend_root(path):
# type: (str) -> str
if root is None or not os.path.isabs(path):
return path
else:
return change_root(root, path)

for line in record_lines:
directory = os.path.dirname(line)
if directory.endswith('.egg-info'):
egg_info_dir = prepend_root(directory)
break
else:
message = (
"{} did not indicate that it installed an "
".egg-info directory. Only setup.py projects "
"generating .egg-info directories are supported."
).format(req_description)
raise InstallationError(message)

new_lines = []
for line in record_lines:
filename = line.strip()
if os.path.isdir(filename):
filename += os.path.sep
new_lines.append(
os.path.relpath(prepend_root(filename), egg_info_dir)
)
new_lines.sort()
ensure_dir(egg_info_dir)
inst_files_path = os.path.join(egg_info_dir, 'installed-files.txt')
with open(inst_files_path, 'w') as f:
f.write('\n'.join(new_lines) + '\n')


def install(
install_options, # type: List[str]
global_options, # type: Sequence[str]
Expand Down Expand Up @@ -88,38 +128,5 @@ def install(
with open(record_filename) as f:
record_lines = f.read().splitlines()

def prepend_root(path):
# type: (str) -> str
if root is None or not os.path.isabs(path):
return path
else:
return change_root(root, path)

for line in record_lines:
directory = os.path.dirname(line)
if directory.endswith('.egg-info'):
egg_info_dir = prepend_root(directory)
break
else:
message = (
"{} did not indicate that it installed an "
".egg-info directory. Only setup.py projects "
"generating .egg-info directories are supported."
).format(req_description)
raise InstallationError(message)

new_lines = []
for line in record_lines:
filename = line.strip()
if os.path.isdir(filename):
filename += os.path.sep
new_lines.append(
os.path.relpath(prepend_root(filename), egg_info_dir)
)
new_lines.sort()
ensure_dir(egg_info_dir)
inst_files_path = os.path.join(egg_info_dir, 'installed-files.txt')
with open(inst_files_path, 'w') as f:
f.write('\n'.join(new_lines) + '\n')

write_installed_files_from_setuptools_record(record_lines, root, req_description)
return True
36 changes: 28 additions & 8 deletions tests/functional/test_show.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
import re

import pytest

from pip import __version__
from pip._internal.commands.show import search_packages_info
from pip._internal.operations.install.legacy import (
write_installed_files_from_setuptools_record,
)
from pip._internal.utils.unpacking import untar_file
from tests.lib import create_test_package_with_setup


Expand Down Expand Up @@ -41,7 +43,7 @@ def test_show_with_files_not_found(script, data):

def test_show_with_files_from_wheel(script, data):
"""
Test that a wheel's files can be listed
Test that a wheel's files can be listed.
"""
wheel_file = data.packages.joinpath('simple.dist-0.1-py2.py3-none-any.whl')
script.pip('install', '--no-index', wheel_file)
Expand All @@ -50,18 +52,36 @@ def test_show_with_files_from_wheel(script, data):
assert 'Name: simple.dist' in lines
assert 'Cannot locate RECORD or installed-files.txt' not in lines[6], lines[6]
assert re.search(r"Files:\n( .+\n)+", result.stdout)
assert f" simpledist{os.sep}__init__.py" in lines[6:]


@pytest.mark.network
def test_show_with_all_files(script):
def test_show_with_files_from_legacy(tmp_path, script, data):
"""
Test listing all files in the show command.
Test listing files in the show command (legacy installed-files.txt).
"""
script.pip('install', 'initools==0.2')
result = script.pip('show', '--files', 'initools')
# Since 'pip install' now always tries to build a wheel from sdist, it
# cannot properly generate a setup. The legacy code path is basically
# 'setup.py install' plus installed-files.txt, which we manually generate.
source_dir = tmp_path.joinpath("unpacked-sdist")
setuptools_record = tmp_path.joinpath("installed-record.txt")
untar_file(data.packages.joinpath("simple-1.0.tar.gz"), str(source_dir))
script.run(
"python", "setup.py", "install",
"--single-version-externally-managed",
"--record", str(setuptools_record),
cwd=source_dir,
)
write_installed_files_from_setuptools_record(
setuptools_record.read_text().splitlines(),
root=None,
req_description="simple==1.0",
)

result = script.pip('show', '--files', 'simple')
lines = result.stdout.splitlines()
assert 'Cannot locate RECORD or installed-files.txt' not in lines[6], lines[6]
assert re.search(r"Files:\n( .+\n)+", result.stdout)
assert f" simple{os.sep}__init__.py" in lines[6:]


def test_missing_argument(script):
Expand Down

0 comments on commit 08eee0b

Please sign in to comment.