Skip to content

Commit 6a8a4c4

Browse files
authored
Merge pull request #251 from PyAr/save-upgraded-venv
Select the best env from the stored ones in the case of multiple matching.
2 parents 4d0da65 + 4ba6c21 commit 6a8a4c4

File tree

3 files changed

+213
-34
lines changed

3 files changed

+213
-34
lines changed

README.rst

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,8 @@ don't specify a version, it will install the latest one from PyPI.
236236
For example, you do ``fades -d foobar`` and it installs foobar in
237237
version 7. At some point, there is a new version of foobar in PyPI,
238238
version 8, but if do ``fades -d foobar`` it will just reuse previously
239-
created virtualenv, with version 7, not using the new one!
239+
created virtualenv, with version 7, not downloading the new version and
240+
creating a new virtualenv with it!
240241

241242
You can tell fades to do otherwise, just do::
242243

@@ -245,9 +246,13 @@ You can tell fades to do otherwise, just do::
245246
...and *fades* will search updates for the package on PyPI, and as it will
246247
found version 8, will create a new virtualenv using the latest version.
247248

248-
You can even use this parameter when specifying the package version. Say
249-
you call ``fades -d foobar==7``, *fades* will install version 7 no matter
250-
which one is the latest. But if you do::
249+
From this moment on, if you request ``fades -d foobar`` it will bring the
250+
virtualenv with the new version. If you want to get a virtualenv with
251+
not-the-latest version for any dependency, just specify the proper versions.
252+
253+
You can even use the ``--check-updates`` parameter when specifying the package
254+
version. Say you call ``fades -d foobar==7``, *fades* will install version 7 no
255+
matter which one is the latest. But if you do::
251256

252257
fades -d foobar==7 --check-updates
253258

@@ -270,6 +275,7 @@ Examples:
270275

271276
``fades --python-options=-B foo.py``
272277

278+
273279
Setting options using config files
274280
----------------------------------
275281

@@ -327,6 +333,7 @@ ie, add a cron task that perform this command::
327333

328334
fades --clean-unused-venvs=42
329335

336+
330337
Some command line examples
331338
--------------------------
332339

fades/cache.py

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,14 @@ def _venv_match(self, installed, requirements):
5151
if not requirements:
5252
# special case for no requirements, where we can't actually
5353
# check anything: the venv is useful if nothing installed too
54-
return not bool(installed)
54+
return None if installed else []
5555

56+
satisfying_deps = []
5657
for repo, req_deps in requirements.items():
5758
useful_inst = set()
5859
if repo not in installed:
5960
# the venv doesn't even have the repo
60-
return False
61+
return None
6162

6263
if repo == REPO_VCS:
6364
inst_deps = {VCSDependency(url) for url in installed[repo].keys()}
@@ -71,43 +72,96 @@ def _venv_match(self, installed, requirements):
7172
break
7273
else:
7374
# nothing installed satisfied that requirement
74-
return False
75+
return None
7576

7677
# assure *all* that is installed is useful for the requirements
77-
if useful_inst != inst_deps:
78-
return False
78+
if useful_inst == inst_deps:
79+
satisfying_deps.extend(inst_deps)
80+
else:
81+
return None
7982

8083
# it did it through!
81-
return True
84+
return satisfying_deps
85+
86+
def _match_by_uuid(self, current_venvs, uuid):
87+
"""Select a venv matching exactly by uuid."""
88+
for venv_str in current_venvs:
89+
venv = json.loads(venv_str)
90+
env_path = venv.get('metadata', {}).get('env_path')
91+
_, env_uuid = os.path.split(env_path)
92+
if env_uuid == uuid:
93+
return venv
94+
95+
def _select_better_fit(self, matching_venvs):
96+
"""Receive a list of matching venvs, and decide which one is the best fit."""
97+
# keep the venvs in a separate array, to pick up the winner, and the (sorted, to compare
98+
# each dependency with its equivalent) in other structure to later compare
99+
venvs = []
100+
to_compare = []
101+
for matching, venv in matching_venvs:
102+
to_compare.append(sorted(matching, key=lambda req: getattr(req, 'key', '')))
103+
venvs.append(venv)
104+
105+
# compare each n-tuple of dependencies to see which one is bigger, and add score to the
106+
# position of the winner
107+
scores = [0] * len(venvs)
108+
for dependencies in zip(*to_compare):
109+
if not isinstance(dependencies[0], Distribution):
110+
# only distribution URLs can be compared
111+
continue
112+
113+
winner = dependencies.index(max(dependencies))
114+
scores[winner] = scores[winner] + 1
115+
116+
# get the rightmost winner (in case of ties, to select the latest venv)
117+
winner_pos = None
118+
winner_score = -1
119+
for i, score in enumerate(scores):
120+
if score >= winner_score:
121+
winner_score = score
122+
winner_pos = i
123+
return venvs[winner_pos]
124+
125+
def _match_by_requirements(self, current_venvs, requirements, interpreter, options):
126+
"""Select a venv matching interpreter and options, complying with requirements.
127+
128+
Several venvs can be found in this case, will return the better fit.
129+
"""
130+
matching_venvs = []
131+
for venv_str in current_venvs:
132+
venv = json.loads(venv_str)
133+
134+
# simple filter, need to have exactly same options and interpreter
135+
if venv.get('options') != options or venv.get('interpreter') != interpreter:
136+
continue
137+
138+
# requirements complying: result can be None (no comply) or a score to later sort
139+
matching = self._venv_match(venv['installed'], requirements)
140+
if matching is not None:
141+
matching_venvs.append((matching, venv))
142+
143+
if not matching_venvs:
144+
logger.debug("No matching venv found :(")
145+
return
146+
147+
return self._select_better_fit(matching_venvs)
82148

83149
def _select(self, current_venvs, requirements=None, interpreter='', uuid='', options=None):
84150
"""Select which venv satisfy the received requirements."""
85-
def get_match_by_uuid(uuid):
86-
def match_by_uuid(env):
87-
env_path = env.get('metadata', {}).get('env_path')
88-
_, env_uuid = os.path.split(env_path)
89-
return env_uuid == uuid
90-
return match_by_uuid
91-
92-
def match_by_req_and_interpreter(env):
93-
return (env.get('options') == options and
94-
env.get('interpreter') == interpreter and
95-
self._venv_match(venv['installed'], requirements))
96-
97151
if uuid:
98152
logger.debug("Searching a venv by uuid: %s", uuid)
99-
match = get_match_by_uuid(uuid)
153+
venv = self._match_by_uuid(current_venvs, uuid)
100154
else:
101-
logger.debug("Searching a venv for reqs: %s and interpreter: %s",
102-
requirements, interpreter)
103-
match = match_by_req_and_interpreter
155+
logger.debug("Searching a venv for: reqs=%s interpreter=%s options=%s",
156+
requirements, interpreter, options)
157+
venv = self._match_by_requirements(current_venvs, requirements, interpreter, options)
104158

105-
for venv_str in current_venvs:
106-
venv = json.loads(venv_str)
107-
if match(venv):
108-
logger.debug("Found a matching venv! %s", venv)
109-
return venv['metadata']
110-
logger.debug("No matching venv found :(")
159+
if venv is None:
160+
logger.debug("No matching venv found :(")
161+
return
162+
163+
logger.debug("Found a matching venv! %s", venv)
164+
return venv['metadata']
111165

112166
def get_venv(self, requirements=None, interpreter='', uuid='', options=None):
113167
"""Find a venv that serves these requirements, if any."""

tests/test_cache.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2015-2016 Facundo Batista, Nicolás Demarchi
1+
# Copyright 2015-2017 Facundo Batista, Nicolás Demarchi
22
#
33
# This program is free software: you can redistribute it and/or modify it
44
# under the terms of the GNU General Public License version 3, as published
@@ -25,7 +25,7 @@
2525
from threading import Thread
2626
from unittest.mock import patch
2727

28-
from pkg_resources import parse_requirements
28+
from pkg_resources import parse_requirements, Distribution
2929

3030
from fades import cache, helpers, parsing
3131

@@ -35,6 +35,11 @@ def get_req(text):
3535
return list(parse_requirements(text))
3636

3737

38+
def get_distrib(*dep_ver_pairs):
39+
"""Build some Distributions with indicated info."""
40+
return [Distribution(project_name=dep, version=ver) for dep, ver in dep_ver_pairs]
41+
42+
3843
class TempfileTestCase(unittest.TestCase):
3944
"""Basic functionality tests."""
4045

@@ -359,6 +364,32 @@ def test_middle_match(self):
359364
'interpreter': 'pythonX.Y',
360365
'options': {'foo': 'bar'}
361366
})
367+
venv3 = json.dumps({
368+
'metadata': 'venv3',
369+
'installed': {'pypi': {'dep': '7'}},
370+
'interpreter': 'pythonX.Y',
371+
'options': {'foo': 'bar'}
372+
})
373+
resp = self.venvscache._select([venv1, venv2, venv3], reqs, interpreter, uuid='',
374+
options=options)
375+
self.assertEqual(resp, 'venv2')
376+
377+
def test_multiple_match_bigger_version(self):
378+
reqs = {'pypi': get_req('dep')}
379+
interpreter = 'pythonX.Y'
380+
options = {'foo': 'bar'}
381+
venv1 = json.dumps({
382+
'metadata': 'venv1',
383+
'installed': {'pypi': {'dep': '3'}},
384+
'interpreter': 'pythonX.Y',
385+
'options': {'foo': 'bar'}
386+
})
387+
venv2 = json.dumps({
388+
'metadata': 'venv2',
389+
'installed': {'pypi': {'dep': '7'}},
390+
'interpreter': 'pythonX.Y',
391+
'options': {'foo': 'bar'}
392+
})
362393
venv3 = json.dumps({
363394
'metadata': 'venv3',
364395
'installed': {'pypi': {'dep': '5'}},
@@ -367,6 +398,8 @@ def test_middle_match(self):
367398
})
368399
resp = self.venvscache._select([venv1, venv2, venv3], reqs, interpreter, uuid='',
369400
options=options)
401+
# matches venv2 because it has the bigger version for 'dep' (even if it's not the
402+
# latest virtualenv created)
370403
self.assertEqual(resp, 'venv2')
371404

372405
def test_multiple_deps_ok(self):
@@ -582,3 +615,88 @@ def test_crazy_picky(self):
582615
self.assertEqual(self.check('>1.6,<1.9,!=1.9.6', '1.6.7'), 'ok')
583616
self.assertEqual(self.check('>1.6,<1.9,!=1.8.6', '1.8.7'), 'ok')
584617
self.assertEqual(self.check('>1.6,<1.9,!=1.9.6', '1.9.6'), None)
618+
619+
620+
class BestFitTestCase(TempfileTestCase):
621+
"""Check the venv best fitting decissor."""
622+
623+
def setUp(self):
624+
super().setUp()
625+
self.venvscache = cache.VEnvsCache(self.tempfile)
626+
627+
def check(self, possible_venvs):
628+
"""Assert that the selected venv is the best fit one."""
629+
self.assertEqual(self.venvscache._select_better_fit(possible_venvs), 'venv_best_fit')
630+
631+
def test_one_simple(self):
632+
self.check([
633+
(get_distrib(('dep', '3')), 'venv_best_fit'),
634+
])
635+
636+
def test_one_double(self):
637+
self.check([
638+
(get_distrib(('dep1', '3'), ('dep2', '3')), 'venv_best_fit'),
639+
])
640+
641+
def test_two_simple(self):
642+
self.check([
643+
(get_distrib(('dep', '5')), 'venv_best_fit'),
644+
(get_distrib(('dep', '3')), 'venv_1'),
645+
])
646+
647+
def test_two_double_evident(self):
648+
self.check([
649+
(get_distrib(('dep1', '5'), ('dep2', '7')), 'venv_best_fit'),
650+
(get_distrib(('dep1', '3'), ('dep2', '6')), 'venv_1'),
651+
])
652+
653+
def test_two_double_mixed_1(self):
654+
# tie! the one chosen is the last one
655+
self.check([
656+
(get_distrib(('dep1', '3'), ('dep2', '9')), 'venv_1'),
657+
(get_distrib(('dep1', '5'), ('dep2', '7')), 'venv_best_fit'),
658+
])
659+
660+
def test_two_double_mixed_2(self):
661+
# tie! the one chosen is the last one
662+
self.check([
663+
(get_distrib(('dep1', '5'), ('dep2', '7')), 'venv_1'),
664+
(get_distrib(('dep1', '3'), ('dep2', '9')), 'venv_best_fit'),
665+
])
666+
667+
def test_two_triple(self):
668+
self.check([
669+
(get_distrib(('dep1', '3'), ('dep2', '9'), ('dep3', '4')), 'venv_best_fit'),
670+
(get_distrib(('dep1', '5'), ('dep2', '7'), ('dep3', '2')), 'venv_1'),
671+
])
672+
673+
def test_unordered(self):
674+
# assert it compares each dependency with its equivalent
675+
self.check([
676+
(get_distrib(('dep2', '3'), ('dep1', '2'), ('dep3', '8')), 'venv_best_fit'),
677+
(get_distrib(('dep1', '7'), ('dep3', '5'), ('dep2', '2')), 'venv_1'),
678+
])
679+
680+
def test_big(self):
681+
self.check([
682+
(get_distrib(('dep1', '3'), ('dep2', '2')), 'venv_1'),
683+
(get_distrib(('dep1', '4'), ('dep2', '2')), 'venv_2'),
684+
(get_distrib(('dep1', '5'), ('dep2', '7')), 'venv_best_fit'),
685+
(get_distrib(('dep1', '5'), ('dep2', '6')), 'venv_3'),
686+
])
687+
688+
def test_vcs_alone(self):
689+
self.check([
690+
([parsing.VCSDependency('someurl')], 'venv_best_fit'),
691+
])
692+
693+
def test_vcs_mixed_simple(self):
694+
self.check([
695+
([parsing.VCSDependency('someurl')] + get_distrib(('dep', '3')), 'venv_best_fit'),
696+
])
697+
698+
def test_vcs_mixed_multiple(self):
699+
self.check([
700+
([parsing.VCSDependency('someurl')] + get_distrib(('dep', '3')), 'venv_best_fit'),
701+
([parsing.VCSDependency('someurl')] + get_distrib(('dep', '1')), 'venv_1'),
702+
])

0 commit comments

Comments
 (0)