Skip to content

Commit 0e2e7a5

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 428b1b9 commit 0e2e7a5

File tree

18 files changed

+130
-18
lines changed

18 files changed

+130
-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: 63 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
@@ -273,6 +274,8 @@ def __init__(self, build_dir, src_dir, download_dir, upgrade=False,
273274
if wheel_download_dir:
274275
wheel_download_dir = normalize_path(wheel_download_dir)
275276
self.wheel_download_dir = wheel_download_dir
277+
# Maps from install_req -> dependencies_of_install_req
278+
self._dependencies = defaultdict(list)
276279

277280
def __str__(self):
278281
reqs = [req for req in self.requirements.values()
@@ -287,29 +290,55 @@ def __repr__(self):
287290
return ('<%s object; %d requirement(s): %s>'
288291
% (self.__class__.__name__, len(reqs), reqs_str))
289292

290-
def add_requirement(self, install_req):
291-
if not install_req.match_markers():
293+
def add_requirement(self, install_req, parent_req_name=None):
294+
"""Add install_req as a requirement to install.
295+
296+
:param parent_req_name: The name of the requirement that needed this
297+
added. The name is used because when multiple unnamed requirements
298+
resolve to the same name, we could otherwise end up with dependency
299+
links that point outside the Requirements set. parent_req must
300+
already be added.
301+
:return: Additional requirements to scan. That is either [] if
302+
the requirement is not applicable, or [install_req] if the
303+
requirement is applicable and has just been added.
304+
"""
305+
name = install_req.name
306+
if ((not name or not self.has_requirement(name)) and not
307+
install_req.match_markers()):
308+
# Only log if we haven't already got install_req from somewhere.
292309
logger.debug("Ignore %s: markers %r don't match",
293310
install_req.name, install_req.markers)
294-
return
311+
return []
295312

296-
name = install_req.name
297313
install_req.as_egg = self.as_egg
298314
install_req.use_user_site = self.use_user_site
299315
install_req.target_dir = self.target_dir
300316
install_req.pycompile = self.pycompile
301317
if not name:
302318
# url or path requirement w/o an egg fragment
303319
self.unnamed_requirements.append(install_req)
320+
return [install_req]
304321
else:
305-
if self.has_requirement(name):
322+
if parent_req_name is None and self.has_requirement(name):
306323
raise InstallationError(
307324
'Double requirement given: %s (already in %s, name=%r)'
308325
% (install_req, self.get_requirement(name), name))
309-
self.requirements[name] = install_req
310-
# FIXME: what about other normalizations? E.g., _ vs. -?
311-
if name.lower() != name:
312-
self.requirement_aliases[name.lower()] = name
326+
if not self.has_requirement(name):
327+
# Add requirement
328+
self.requirements[name] = install_req
329+
# FIXME: what about other normalizations? E.g., _ vs. -?
330+
if name.lower() != name:
331+
self.requirement_aliases[name.lower()] = name
332+
result = [install_req]
333+
else:
334+
# Canonicalise to the already-added object
335+
install_req = self.get_requirement(name)
336+
# No need to scan, this is a duplicate requirement.
337+
result = []
338+
if parent_req_name:
339+
parent_req = self.get_requirement(parent_req_name)
340+
self._dependencies[parent_req].append(install_req)
341+
return result
313342

314343
def has_requirement(self, project_name):
315344
for name in project_name, project_name.lower():
@@ -610,23 +639,20 @@ def _prepare_file(self, finder, req_to_install):
610639
more_reqs = []
611640

612641
def add_req(subreq):
613-
if self.has_requirement(subreq.project_name):
614-
# FIXME: check for conflict
615-
return
616-
subreq = InstallRequirement(
642+
sub_install_req = InstallRequirement(
617643
str(subreq),
618644
req_to_install,
619645
isolated=self.isolated,
620646
)
621-
more_reqs.append(subreq)
622-
self.add_requirement(subreq)
647+
more_reqs.extend(self.add_requirement(
648+
sub_install_req, req_to_install.name))
623649

624650
# We add req_to_install before its dependencies, so that when
625651
# to_install is calculated, which reverses the order,
626652
# req_to_install is installed after its dependencies.
627653
if not self.has_requirement(req_to_install.name):
628654
# 'unnamed' requirements will get added here
629-
self.add_requirement(req_to_install)
655+
self.add_requirement(req_to_install, None)
630656

631657
if not self.ignore_dependencies:
632658
if getattr(dist, 'setup_requires', None):
@@ -688,13 +714,32 @@ def _pip_has_created_build_dir(self):
688714
)
689715
)
690716

717+
def _to_install(self):
718+
"""Create the installation order.
719+
720+
We install the user specified things in the order given, except when
721+
dependencies require putting other things first.
722+
"""
723+
order = []
724+
ordered_reqs = set()
725+
726+
def schedule(req):
727+
if req.satisfied_by or req in ordered_reqs:
728+
return
729+
ordered_reqs.add(req)
730+
for dep in self._dependencies[req]:
731+
schedule(dep)
732+
order.append(req)
733+
for install_req in self.requirements.values():
734+
schedule(install_req)
735+
return order
736+
691737
def install(self, install_options, global_options=(), *args, **kwargs):
692738
"""
693739
Install everything in this set (after having downloaded and unpacked
694740
the packages)
695741
"""
696-
to_install = [r for r in self.requirements.values()[::-1]
697-
if not r.satisfied_by]
742+
to_install = self._to_install()
698743

699744
# DISTRIBUTE TO SETUPTOOLS UPGRADE HACK (1 of 3 parts)
700745
# 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.
766 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+
setup-requires =
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.
783 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+
setup-requires =
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+
install-requires =
4+
TopoRequires2
5+
TopoRequires3
6+
setup-requires =
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)