Skip to content

Commit f237aba

Browse files
committed
Issue pypa#2478 - topological install order.
This is needed for setup-requires, since without it its possible to cause installation to fail in sort-circuit scenarios such as the added functional test case demonstrates.
1 parent e99c9d7 commit f237aba

File tree

18 files changed

+129
-18
lines changed

18 files changed

+129
-18
lines changed

pip/req/req_install.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def url_name(self):
325325

326326
@property
327327
def setup_py(self):
328+
assert self.source_dir, "No source dir for %s" % self
328329
try:
329330
import setuptools # noqa
330331
except ImportError:

pip/req/req_set.py

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import absolute_import
22

3+
from collections import defaultdict
34
import functools
45
import itertools
56
import logging
@@ -260,6 +261,8 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False,
260261
if wheel_download_dir:
261262
wheel_download_dir = normalize_path(wheel_download_dir)
262263
self.wheel_download_dir = wheel_download_dir
264+
# Maps from install_req -> dependencies_of_install_req
265+
self._dependencies = defaultdict(list)
263266

264267
def __str__(self):
265268
reqs = [req for req in self.requirements.values()
@@ -274,29 +277,55 @@ def __repr__(self):
274277
return ('<%s object; %d requirement(s): %s>'
275278
% (self.__class__.__name__, len(reqs), reqs_str))
276279

277-
def add_requirement(self, install_req):
278-
if not install_req.match_markers():
280+
def add_requirement(self, install_req, parent_req_name=None):
281+
"""Add install_req as a requirement to install.
282+
283+
:param parent_req_name: The name of the requirement that needed this
284+
added. The name is used because when multiple unnamed requirements
285+
resolve to the same name, we could otherwise end up with dependency
286+
links that point outside the Requirements set. parent_req must
287+
already be added.
288+
:return: Additional requirements to scan. That is either [] if
289+
the requirement is not applicable, or [install_req] if the
290+
requirement is applicable and has just been added.
291+
"""
292+
name = install_req.name
293+
if ((not name or not self.has_requirement(name))
294+
and not install_req.match_markers()):
295+
# Only log if we haven't already got install_req from somewhere.
279296
logger.debug("Ignore %s: markers %r don't match",
280297
install_req.name, install_req.markers)
281-
return
298+
return []
282299

283-
name = install_req.name
284300
install_req.as_egg = self.as_egg
285301
install_req.use_user_site = self.use_user_site
286302
install_req.target_dir = self.target_dir
287303
install_req.pycompile = self.pycompile
288304
if not name:
289305
# url or path requirement w/o an egg fragment
290306
self.unnamed_requirements.append(install_req)
307+
return [install_req]
291308
else:
292-
if self.has_requirement(name):
309+
if parent_req_name is None and self.has_requirement(name):
293310
raise InstallationError(
294311
'Double requirement given: %s (already in %s, name=%r)'
295312
% (install_req, self.get_requirement(name), name))
296-
self.requirements[name] = install_req
297-
# FIXME: what about other normalizations? E.g., _ vs. -?
298-
if name.lower() != name:
299-
self.requirement_aliases[name.lower()] = name
313+
if not self.has_requirement(name):
314+
# Add requirement
315+
self.requirements[name] = install_req
316+
# FIXME: what about other normalizations? E.g., _ vs. -?
317+
if name.lower() != name:
318+
self.requirement_aliases[name.lower()] = name
319+
result = [install_req]
320+
else:
321+
# Canonicalise to the already-added object
322+
install_req = self.get_requirement(name)
323+
# No need to scan, this is a duplicate requirement.
324+
result = []
325+
if parent_req_name:
326+
parent_req = self.get_requirement(parent_req_name)
327+
self._dependencies[parent_req].append(install_req)
328+
return result
300329

301330
def has_requirement(self, project_name):
302331
for name in project_name, project_name.lower():
@@ -597,23 +626,20 @@ def _prepare_file(self, finder, req_to_install):
597626
more_reqs = []
598627

599628
def add_req(subreq):
600-
if self.has_requirement(subreq.project_name):
601-
# FIXME: check for conflict
602-
return
603-
subreq = InstallRequirement(
629+
sub_install_req = InstallRequirement(
604630
str(subreq),
605631
req_to_install,
606632
isolated=self.isolated,
607633
)
608-
more_reqs.append(subreq)
609-
self.add_requirement(subreq)
634+
more_reqs.extend(self.add_requirement(
635+
sub_install_req, req_to_install.name))
610636

611637
# We add req_to_install before its dependencies, so that when
612638
# to_install is calculated, which reverses the order,
613639
# req_to_install is installed after its dependencies.
614640
if not self.has_requirement(req_to_install.name):
615641
# 'unnamed' requirements will get added here
616-
self.add_requirement(req_to_install)
642+
self.add_requirement(req_to_install, None)
617643

618644
if not self.ignore_dependencies:
619645
if getattr(dist, 'setup_requires', None):
@@ -675,13 +701,31 @@ def _pip_has_created_build_dir(self):
675701
)
676702
)
677703

704+
def _to_install(self):
705+
"""Create the installation order.
706+
707+
We install the user specified things in the order given, except when
708+
dependencies require putting other things first.
709+
"""
710+
order = []
711+
ordered_reqs = set()
712+
def schedule(req):
713+
if req.satisfied_by or req in ordered_reqs:
714+
return
715+
ordered_reqs.add(req)
716+
for dep in self._dependencies[req]:
717+
schedule(dep)
718+
order.append(req)
719+
for install_req in self.requirements.values():
720+
schedule(install_req)
721+
return order
722+
678723
def install(self, install_options, global_options=(), *args, **kwargs):
679724
"""
680725
Install everything in this set (after having downloaded and unpacked
681726
the packages)
682727
"""
683-
to_install = [r for r in self.requirements.values()[::-1]
684-
if not r.satisfied_by]
728+
to_install = self._to_install()
685729

686730
# DISTRIBUTE TO SETUPTOOLS UPGRADE HACK (1 of 3 parts)
687731
# move the distribute-0.7.X wrapper to the end because it does not

tests/data/packages/README.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ requires SetupRequires2[a,b], as using extras for local paths is currently
103103
broken (issue 1236). Ideally SetupRequires3 would have the extras itself
104104
and no requires-dist (to test declarative extras as sole requirements).
105105

106+
TopoRequires[123][-0.0.1.tar.gz]
107+
--------------------------------
108+
109+
These are used for testing topological handling of requirements: we have
110+
TopoRequires, which is setup-required by TopoRequires2 and TopoRequires3
111+
and finally TopoRequires4 which install-requires both TopoRequires2 and 3
112+
and also setup-Requires TopoRequires.
113+
This creates a diamond where no matter which way we walk without topological
114+
awareness we'll end up attempting to install TopoRequires after one of
115+
TopoRequires2, TopoRequires3 or TopoRequires4. (prefix iteration works as its
116+
topological, suffix iteration likewise, infix breaks).
106117

107118
simple[2]-[123].0.tar.gz
108119
------------------------
746 Bytes
Binary file not shown.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from setuptools import setup
2+
3+
setup(
4+
name='TopoRequires',
5+
version='0.0.1',
6+
packages=['toporequires'],
7+
)

tests/data/packages/TopoRequires/toporequires/__init__.py

Whitespace-only changes.
769 Bytes
Binary file not shown.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[metadata]
2+
name = TopoRequires2
3+
requires-setup =
4+
TopoRequires
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from setuptools import setup
2+
import toporequires
3+
4+
setup(
5+
name='TopoRequires2',
6+
version='0.0.1',
7+
packages=['toporequires2'],
8+
)

tests/data/packages/TopoRequires2/toporequires2/__init__.py

Whitespace-only changes.
792 Bytes
Binary file not shown.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[metadata]
2+
name = TopoRequires3
3+
requires-setup =
4+
TopoRequires
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from setuptools import setup
2+
import toporequires
3+
4+
setup(
5+
name='TopoRequires3',
6+
version='0.0.1',
7+
packages=['toporequires3'],
8+
)

tests/data/packages/TopoRequires3/toporequires3/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[metadata]
2+
name = TopoRequires4
3+
requires-dist =
4+
TopoRequires2
5+
TopoRequires3
6+
requires-setup =
7+
TopoRequires
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from setuptools import setup
2+
import toporequires
3+
4+
setup(
5+
name='TopoRequires4',
6+
version='0.0.1',
7+
packages=['toporequires4'],
8+
)

tests/data/packages/TopoRequires4/toporequires4/__init__.py

Whitespace-only changes.

tests/functional/test_install.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,3 +764,12 @@ def test_install_declarative_extras(script, data):
764764
assert 'Running setup.py install for simple2\n' in str(res), str(res)
765765
assert 'Running setup.py install for SetupRequires3\n' in str(res), \
766766
str(res)
767+
768+
769+
def test_install_topological_sort(script, data):
770+
to_install = data.packages.join('TopoRequires4')
771+
args = ['install'] + [to_install, '-f', data.packages]
772+
res = str(script.pip(*args, expect_error=False))
773+
order1 = 'TopoRequires, TopoRequires2, TopoRequires3, TopoRequires4'
774+
order2 = 'TopoRequires, TopoRequires3, TopoRequires2, TopoRequires4'
775+
assert order1 in res or order2 in res, res

0 commit comments

Comments
 (0)