Skip to content

Commit b1ea8a7

Browse files
committed
Fix resolve to track packages cached mid-resolve.
Before the fix the `_ResolvableSet` was blind to any packages built and cached in earlier loops of a complex resolve. This change makes the resolvable set aware by tracking built packages and replacing unbuilt packages with them in the resolvable set. Failing tests were added that now pass with the fixes. Testing Done: CI went green here: https://travis-ci.org/pantsbuild/pex/builds/69049830 Bugs closed: 120, 121 Reviewed at https://rbcommons.com/s/twitter/r/2341/
1 parent ac3c7f1 commit b1ea8a7

File tree

7 files changed

+80
-11
lines changed

7 files changed

+80
-11
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ CHANGES
1313
* Bug fix: PEXBuilder did not respect the target interpreter when compiling source to bytecode.
1414
Fixes `#127 <https://github.com/pantsbuild/pex/issues/127>`_.
1515

16+
* Bug fix: Fix complex resolutions when using a cache.
17+
Fixes: `#120 <https://github.com/pantsbuild/pex/issues/120>`_.
18+
1619
-----
1720
1.0.0
1821
-----

pex/link.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ def __init__(self, url):
7676
purl = urlparse.urlparse(self._normalize(url))
7777
self._url = purl
7878

79+
def __ne__(self, other):
80+
return not self.__eq__(other)
81+
7982
def __eq__(self, link):
8083
return self.__class__ == link.__class__ and self._url == link._url
8184

pex/resolver.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .resolvable import ResolvableRequirement, resolvables_from_iterable
1919
from .resolver_options import ResolverOptionsBuilder
2020
from .tracer import TRACER
21+
from .util import DistributionHelper
2122

2223

2324
class Untranslateable(Exception):
@@ -55,9 +56,9 @@ def merge(self, other):
5556

5657

5758
class _ResolvableSet(object):
58-
def __init__(self):
59+
def __init__(self, tuples=None):
5960
# A list of _ResolvedPackages
60-
self.__tuples = []
61+
self.__tuples = tuples or []
6162

6263
def _collapse(self):
6364
# Collapse all resolvables by name along with the intersection of all compatible packages.
@@ -112,6 +113,18 @@ def extras(self, name):
112113
return set.union(
113114
*[set(tup.resolvable.extras()) for tup in self.__tuples if tup.resolvable.name == name])
114115

116+
def replace_built(self, built_packages):
117+
"""Return a copy of this resolvable set but with built packages.
118+
119+
:param dict built_packages: A mapping from a resolved package to its locally built package.
120+
:returns: A new resolvable set with built package replacements made.
121+
"""
122+
def map_packages(resolved_packages):
123+
packages = OrderedSet(built_packages.get(p, p) for p in resolved_packages.packages)
124+
return _ResolvedPackages(resolved_packages.resolvable, packages, resolved_packages.parent)
125+
126+
return _ResolvableSet([map_packages(rp) for rp in self.__tuples])
127+
115128

116129
class Resolver(object):
117130
"""Interface for resolving resolvable entities into python packages."""
@@ -165,6 +178,7 @@ def resolve(self, resolvables, resolvable_set=None):
165178
resolvable_set.merge(resolvable, packages, parent)
166179
processed_resolvables.add(resolvable)
167180

181+
built_packages = {}
168182
for resolvable, packages, parent in resolvable_set.packages():
169183
assert len(packages) > 0, 'ResolvableSet.packages(%s) should not be empty' % resolvable
170184
package = next(iter(packages))
@@ -174,13 +188,19 @@ def resolve(self, resolvables, resolvable_set=None):
174188
raise self.Error('Ambiguous resolvable: %s' % resolvable)
175189
continue
176190
if package not in distributions:
177-
distributions[package] = self.build(package, resolvable.options)
191+
dist = self.build(package, resolvable.options)
192+
built_package = Package.from_href(dist.location)
193+
built_packages[package] = built_package
194+
distributions[built_package] = dist
195+
package = built_package
196+
178197
distribution = distributions[package]
179198
processed_packages[resolvable.name] = package
180199
new_parent = '%s->%s' % (parent, resolvable) if parent else str(resolvable)
181200
resolvables.extend(
182201
(ResolvableRequirement(req, resolvable.options), new_parent) for req in
183202
distribution.requires(extras=resolvable_set.extras(resolvable.name)))
203+
resolvable_set = resolvable_set.replace_built(built_packages)
184204

185205
return list(distributions.values())
186206

@@ -233,7 +253,8 @@ def build(self, package, options):
233253
shutil.copyfile(dist.location, target + '~')
234254
os.rename(target + '~', target)
235255
os.utime(target, None)
236-
return dist
256+
257+
return DistributionHelper.distribution_from_path(target)
237258

238259

239260
def resolve(

pex/testing.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ def write_zipfile(directory, dest, reverse=False):
7777
'scripts/shell_script',
7878
],
7979
package_data={'my_package': ['package_data/*.dat']},
80+
install_requires=%(install_requires)r,
8081
)
8182
'''),
8283
'scripts/hello_world': '#!/usr/bin/env python\nprint("hello world!")\n',
@@ -89,21 +90,23 @@ def write_zipfile(directory, dest, reverse=False):
8990

9091

9192
@contextlib.contextmanager
92-
def make_installer(name='my_project', installer_impl=EggInstaller, zip_safe=True):
93-
interp = {'project_name': name, 'zip_safe': zip_safe}
93+
def make_installer(name='my_project', installer_impl=EggInstaller, zip_safe=True,
94+
install_reqs=None):
95+
interp = {'project_name': name, 'zip_safe': zip_safe, 'install_requires': install_reqs or []}
9496
with temporary_content(PROJECT_CONTENT, interp=interp) as td:
9597
yield installer_impl(td)
9698

9799

98100
@contextlib.contextmanager
99-
def make_source_dir(name='my_project'):
100-
interp = {'project_name': name, 'zip_safe': True}
101+
def make_source_dir(name='my_project', install_reqs=None):
102+
interp = {'project_name': name, 'zip_safe': True, 'install_requires': install_reqs or []}
101103
with temporary_content(PROJECT_CONTENT, interp=interp) as td:
102104
yield td
103105

104106

105-
def make_sdist(name='my_project', zip_safe=True):
106-
with make_installer(name=name, installer_impl=Packager, zip_safe=zip_safe) as packager:
107+
def make_sdist(name='my_project', zip_safe=True, install_reqs=None):
108+
with make_installer(name=name, installer_impl=Packager, zip_safe=zip_safe,
109+
install_reqs=install_reqs) as packager:
107110
return packager.sdist()
108111

109112

pex/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright 2015 Pants project contributors (see CONTRIBUTORS.md).
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = '1.0.0'
4+
__version__ = '1.0.1.dev1'
55

66
SETUPTOOLS_REQUIREMENT = 'setuptools>=2.2,<16'
77
WHEEL_REQUIREMENT = 'wheel>=0.24.0,<0.25.0'

tests/test_link.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,8 @@ def test_link_schemes():
5252
assert link.scheme == 'file'
5353
assert link.local
5454
assert link.path == os.path.realpath('/foo/bar')
55+
56+
57+
def test_link_equality():
58+
assert Link('http://www.google.com') == Link('http://www.google.com')
59+
assert Link('http://www.google.com') != Link('http://www.twitter.com')

tests/test_resolver.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ def test_simple_local_resolve():
3434
assert len(dists) == 1
3535

3636

37+
def test_diamond_local_resolve_cached():
38+
# This exercises the issue described here: https://github.com/pantsbuild/pex/issues/120
39+
project1_sdist = make_sdist(name='project1', install_reqs=['project2<1.0.0'])
40+
project2_sdist = make_sdist(name='project2')
41+
42+
with temporary_dir() as dd:
43+
for sdist in (project1_sdist, project2_sdist):
44+
safe_copy(sdist, os.path.join(dd, os.path.basename(sdist)))
45+
fetchers = [Fetcher([dd])]
46+
with temporary_dir() as cd:
47+
dists = resolve(['project1', 'project2'], fetchers=fetchers, cache=cd, cache_ttl=1000)
48+
assert len(dists) == 2
49+
50+
3751
def test_resolvable_set():
3852
builder = ResolverOptionsBuilder()
3953
rs = _ResolvableSet()
@@ -56,4 +70,24 @@ def test_resolvable_set():
5670
rs.merge(rq, [binary_pkg])
5771

5872

73+
def test_resolvable_set_built():
74+
builder = ResolverOptionsBuilder()
75+
rs = _ResolvableSet()
76+
rq = ResolvableRequirement.from_string('foo', builder)
77+
source_pkg = SourcePackage.from_href('foo-2.3.4.tar.gz')
78+
binary_pkg = EggPackage.from_href('foo-2.3.4-py3.4.egg')
79+
80+
rs.merge(rq, [source_pkg])
81+
assert rs.get('foo') == set([source_pkg])
82+
assert rs.packages() == [(rq, set([source_pkg]), None)]
83+
84+
with pytest.raises(Unsatisfiable):
85+
rs.merge(rq, [binary_pkg])
86+
87+
updated_rs = rs.replace_built({source_pkg: binary_pkg})
88+
updated_rs.merge(rq, [binary_pkg])
89+
assert updated_rs.get('foo') == set([binary_pkg])
90+
assert updated_rs.packages() == [(rq, set([binary_pkg]), None)]
91+
92+
5993
# TODO(wickman) Write more than simple resolver test.

0 commit comments

Comments
 (0)