Skip to content

Commit 98cfb8a

Browse files
author
Jonas Thiem
committed
Make test_get_bootstraps_from_recipes() deterministic and better
1 parent 06b5958 commit 98cfb8a

File tree

2 files changed

+231
-28
lines changed

2 files changed

+231
-28
lines changed

pythonforandroid/bootstrap.py

Lines changed: 118 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import functools
2+
import glob
3+
import importlib
4+
import os
15
from os.path import (join, dirname, isdir, normpath, splitext, basename)
26
from os import listdir, walk, sep
37
import sh
48
import shlex
5-
import glob
6-
import importlib
7-
import os
89
import shutil
910

1011
from pythonforandroid.logger import (warning, shprint, info, logger,
@@ -34,6 +35,35 @@ def copy_files(src_root, dest_root, override=True):
3435
os.makedirs(dest_file)
3536

3637

38+
default_recipe_priorities = [
39+
"webview", "sdl2", "service_only" # last is highest
40+
]
41+
# ^^ NOTE: these are just the default priorities if no special rules
42+
# apply (which you can find in the code below), so basically if no
43+
# known graphical lib or web lib is used - in which case service_only
44+
# is the most reasonable guess.
45+
46+
47+
def _cmp_bootstraps_by_priority(a, b):
48+
def rank_bootstrap(bootstrap):
49+
""" Returns a ranking index for each bootstrap,
50+
with higher priority ranked with higher number. """
51+
if bootstrap.name in default_recipe_priorities:
52+
return default_recipe_priorities.index(bootstrap.name) + 1
53+
return 0
54+
55+
# Rank bootstraps in order:
56+
rank_a = rank_bootstrap(a)
57+
rank_b = rank_bootstrap(b)
58+
if rank_a != rank_b:
59+
return (rank_b - rank_a)
60+
else:
61+
if a.name < b.name: # alphabetic sort for determinism
62+
return -1
63+
else:
64+
return 1
65+
66+
3767
class Bootstrap(object):
3868
'''An Android project template, containing recipe stuff for
3969
compilation and templated fields for APK info.
@@ -138,36 +168,43 @@ def run_distribute(self):
138168
self.distribution.save_info(self.dist_dir)
139169

140170
@classmethod
141-
def list_bootstraps(cls):
171+
def all_bootstraps(cls):
142172
'''Find all the available bootstraps and return them.'''
143173
forbidden_dirs = ('__pycache__', 'common')
144174
bootstraps_dir = join(dirname(__file__), 'bootstraps')
175+
result = set()
145176
for name in listdir(bootstraps_dir):
146177
if name in forbidden_dirs:
147178
continue
148179
filen = join(bootstraps_dir, name)
149180
if isdir(filen):
150-
yield name
181+
result.add(name)
182+
return result
151183

152184
@classmethod
153-
def get_bootstrap_from_recipes(cls, recipes, ctx):
154-
'''Returns a bootstrap whose recipe requirements do not conflict with
155-
the given recipes.'''
185+
def get_usable_bootstraps_for_recipes(cls, recipes, ctx):
186+
'''Returns all bootstrap whose recipe requirements do not conflict
187+
with the given recipes, in no particular order.'''
156188
info('Trying to find a bootstrap that matches the given recipes.')
157189
bootstraps = [cls.get_bootstrap(name, ctx)
158-
for name in cls.list_bootstraps()]
159-
acceptable_bootstraps = []
190+
for name in cls.all_bootstraps()]
191+
acceptable_bootstraps = set()
192+
193+
# Find out which bootstraps are acceptable:
160194
for bs in bootstraps:
161195
if not bs.can_be_chosen_automatically:
162196
continue
163-
possible_dependency_lists = expand_dependencies(bs.recipe_depends)
197+
possible_dependency_lists = expand_dependencies(bs.recipe_depends, ctx)
164198
for possible_dependencies in possible_dependency_lists:
165199
ok = True
200+
# Check if the bootstap's dependencies have an internal conflict:
166201
for recipe in possible_dependencies:
167202
recipe = Recipe.get_recipe(recipe, ctx)
168203
if any([conflict in recipes for conflict in recipe.conflicts]):
169204
ok = False
170205
break
206+
# Check if bootstrap's dependencies conflict with chosen
207+
# packages:
171208
for recipe in recipes:
172209
try:
173210
recipe = Recipe.get_recipe(recipe, ctx)
@@ -180,14 +217,58 @@ def get_bootstrap_from_recipes(cls, recipes, ctx):
180217
ok = False
181218
break
182219
if ok and bs not in acceptable_bootstraps:
183-
acceptable_bootstraps.append(bs)
220+
acceptable_bootstraps.add(bs)
221+
184222
info('Found {} acceptable bootstraps: {}'.format(
185223
len(acceptable_bootstraps),
186224
[bs.name for bs in acceptable_bootstraps]))
187-
if acceptable_bootstraps:
188-
info('Using the first of these: {}'
189-
.format(acceptable_bootstraps[0].name))
190-
return acceptable_bootstraps[0]
225+
return acceptable_bootstraps
226+
227+
@classmethod
228+
def get_bootstrap_from_recipes(cls, recipes, ctx):
229+
'''Picks a single recommended default bootstrap out of
230+
all_usable_bootstraps_from_recipes() for the given reicpes,
231+
and returns it.'''
232+
233+
known_web_packages = {"flask"} # to pick webview over service_only
234+
recipes_with_deps_lists = expand_dependencies(recipes, ctx)
235+
acceptable_bootstraps = cls.get_usable_bootstraps_for_recipes(
236+
recipes, ctx
237+
)
238+
239+
def have_dependency_in_recipes(dep):
240+
for dep_list in recipes_with_deps_lists:
241+
if dep in dep_list:
242+
return True
243+
return False
244+
245+
# Special rule: return SDL2 bootstrap if there's an sdl2 dep:
246+
if (have_dependency_in_recipes("sdl2") and
247+
"sdl2" in [b.name for b in acceptable_bootstraps]
248+
):
249+
info('Using sdl2 bootstrap since it is in dependencies')
250+
return cls.get_bootstrap("sdl2", ctx)
251+
252+
# Special rule: return "webview" if we depend on common web recipe:
253+
for possible_web_dep in known_web_packages:
254+
if have_dependency_in_recipes(possible_web_dep):
255+
# We have a web package dep!
256+
if "webview" in [b.name for b in acceptable_bootstraps]:
257+
info('Using webview bootstrap since common web packages '
258+
'were found {}'.format(
259+
known_web_packages.intersection(recipes)
260+
))
261+
return cls.get_bootstrap("webview", ctx)
262+
263+
prioritized_acceptable_bootstraps = sorted(
264+
list(acceptable_bootstraps),
265+
key=functools.cmp_to_key(_cmp_bootstraps_by_priority)
266+
)
267+
268+
if prioritized_acceptable_bootstraps:
269+
info('Using the highest ranked/first of these: {}'
270+
.format(prioritized_acceptable_bootstraps[0].name))
271+
return prioritized_acceptable_bootstraps[0]
191272
return None
192273

193274
@classmethod
@@ -299,9 +380,26 @@ def fry_eggs(self, sitepackages):
299380
shprint(sh.rm, '-rf', d)
300381

301382

302-
def expand_dependencies(recipes):
383+
def expand_dependencies(recipes, ctx):
384+
""" This function expands to lists of all different available
385+
alternative recipe combinations, with the dependencies added in
386+
ONLY for all the not-with-alternative recipes.
387+
(So this is like the deps graph very simplified and incomplete, but
388+
hopefully good enough for most basic bootstrap compatibility checks)
389+
"""
390+
391+
# Add in all the deps of recipes where there is no alternative:
392+
recipes_with_deps = list(recipes)
393+
for entry in recipes:
394+
if not isinstance(entry, (tuple, list)) or len(entry) == 1:
395+
if isinstance(entry, (tuple, list)):
396+
entry = entry[0]
397+
recipe = Recipe.get_recipe(entry, ctx)
398+
recipes_with_deps += recipe.depends
399+
400+
# Split up lists by available alternatives:
303401
recipe_lists = [[]]
304-
for recipe in recipes:
402+
for recipe in recipes_with_deps:
305403
if isinstance(recipe, (tuple, list)):
306404
new_recipe_lists = []
307405
for alternative in recipe:
@@ -311,6 +409,6 @@ def expand_dependencies(recipes):
311409
new_recipe_lists.append(new_list)
312410
recipe_lists = new_recipe_lists
313411
else:
314-
for old_list in recipe_lists:
315-
old_list.append(recipe)
412+
for existing_list in recipe_lists:
413+
existing_list.append(recipe)
316414
return recipe_lists

tests/test_bootstrap.py

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
12
import os
23
import sh
3-
44
import unittest
55

66
try:
@@ -9,12 +9,16 @@
99
# `Python 2` or lower than `Python 3.3` does not
1010
# have the `unittest.mock` module built-in
1111
import mock
12-
from pythonforandroid.bootstrap import Bootstrap
12+
from pythonforandroid.bootstrap import (
13+
_cmp_bootstraps_by_priority, Bootstrap, expand_dependencies,
14+
)
1315
from pythonforandroid.distribution import Distribution
1416
from pythonforandroid.recipe import Recipe
1517
from pythonforandroid.archs import ArchARMv7_a
1618
from pythonforandroid.build import Context
1719

20+
from test_graph import get_fake_recipe
21+
1822

1923
class BaseClassSetupBootstrap(object):
2024
"""
@@ -90,7 +94,7 @@ def test_build_dist_dirs(self):
9094
- :meth:`~pythonforandroid.bootstrap.Bootstrap.get_dist_dir`
9195
- :meth:`~pythonforandroid.bootstrap.Bootstrap.get_common_dir`
9296
"""
93-
bs = Bootstrap().get_bootstrap("sdl2", self.ctx)
97+
bs = Bootstrap.get_bootstrap("sdl2", self.ctx)
9498

9599
self.assertTrue(
96100
bs.get_build_dir().endswith("build/bootstrap_builds/sdl2-python3")
@@ -100,32 +104,133 @@ def test_build_dist_dirs(self):
100104
bs.get_common_dir().endswith("pythonforandroid/bootstraps/common")
101105
)
102106

103-
def test_list_bootstraps(self):
107+
def test__cmp_bootstraps_by_priority(self):
108+
# Test service_only has higher priority than sdl2:
109+
# (higher priority = smaller number/comes first)
110+
self.assertTrue(_cmp_bootstraps_by_priority(
111+
Bootstrap.get_bootstrap("service_only", self.ctx),
112+
Bootstrap.get_bootstrap("sdl2", self.ctx)
113+
) < 0)
114+
115+
# Test a random bootstrap is always lower priority than sdl2:
116+
class _FakeBootstrap(object):
117+
def __init__(self, name):
118+
self.name = name
119+
bs1 = _FakeBootstrap("alpha")
120+
bs2 = _FakeBootstrap("zeta")
121+
self.assertTrue(_cmp_bootstraps_by_priority(
122+
bs1,
123+
Bootstrap.get_bootstrap("sdl2", self.ctx)
124+
) > 0)
125+
self.assertTrue(_cmp_bootstraps_by_priority(
126+
bs2,
127+
Bootstrap.get_bootstrap("sdl2", self.ctx)
128+
) > 0)
129+
130+
# Test bootstraps that aren't otherwise recognized are ranked
131+
# alphabetically:
132+
self.assertTrue(_cmp_bootstraps_by_priority(
133+
bs2,
134+
bs1,
135+
) > 0)
136+
self.assertTrue(_cmp_bootstraps_by_priority(
137+
bs1,
138+
bs2,
139+
) < 0)
140+
141+
def test_all_bootstraps(self):
104142
"""A test which will initialize a bootstrap and will check if the
105143
method :meth:`~pythonforandroid.bootstrap.Bootstrap.list_bootstraps`
106144
returns the expected values, which should be: `empty", `service_only`,
107145
`webview` and `sdl2`
108146
"""
109147
expected_bootstraps = {"empty", "service_only", "webview", "sdl2"}
110-
set_of_bootstraps = set(Bootstrap().list_bootstraps())
148+
set_of_bootstraps = Bootstrap.all_bootstraps()
111149
self.assertEqual(
112150
expected_bootstraps, expected_bootstraps & set_of_bootstraps
113151
)
114152
self.assertEqual(len(expected_bootstraps), len(set_of_bootstraps))
115153

154+
def test_expand_dependencies(self):
155+
# Test dependency expansion of a recipe with no alternatives:
156+
expanded_result_1 = expand_dependencies(["pysdl2"], self.ctx)
157+
self.assertTrue(
158+
{"sdl2", "pysdl2", "python3"} in
159+
[set(s) for s in expanded_result_1]
160+
)
161+
162+
# Test expansion of a single element but as tuple:
163+
expanded_result_1 = expand_dependencies([("pysdl2",)], self.ctx)
164+
self.assertTrue(
165+
{"sdl2", "pysdl2", "python3"} in
166+
[set(s) for s in expanded_result_1]
167+
)
168+
169+
# Test all alternatives are listed (they won't have dependencies
170+
# expanded since expand_dependencies() is too simplistic):
171+
expanded_result_2 = expand_dependencies([("pysdl2", "kivy")], self.ctx)
172+
self.assertEqual([["pysdl2"], ["kivy"]], expanded_result_2)
173+
116174
def test_get_bootstraps_from_recipes(self):
117175
"""A test which will initialize a bootstrap and will check if the
118176
method :meth:`~pythonforandroid.bootstrap.Bootstrap.
119177
get_bootstraps_from_recipes` returns the expected values
120178
"""
179+
180+
import pythonforandroid.recipe
181+
original_get_recipe = pythonforandroid.recipe.Recipe.get_recipe
182+
183+
# Test that SDL2 works with kivy:
121184
recipes_sdl2 = {"sdl2", "python3", "kivy"}
122-
bs = Bootstrap().get_bootstrap_from_recipes(recipes_sdl2, self.ctx)
185+
bs = Bootstrap.get_bootstrap_from_recipes(recipes_sdl2, self.ctx)
186+
self.assertEqual(bs.name, "sdl2")
123187

188+
# Test that pysdl2 or kivy alone will also yield SDL2 (dependency):
189+
recipes_pysdl2_only = {"pysdl2"}
190+
bs = Bootstrap.get_bootstrap_from_recipes(
191+
recipes_pysdl2_only, self.ctx
192+
)
124193
self.assertEqual(bs.name, "sdl2")
194+
recipes_kivy_only = {"kivy"}
195+
bs = Bootstrap.get_bootstrap_from_recipes(
196+
recipes_kivy_only, self.ctx
197+
)
198+
self.assertEqual(bs.name, "sdl2")
199+
200+
with mock.patch("pythonforandroid.recipe.Recipe.get_recipe") as \
201+
mock_get_recipe:
202+
# Test that something conflicting with sdl2 won't give sdl2:
203+
def _add_sdl2_conflicting_recipe(name, ctx):
204+
if name == "conflictswithsdl2":
205+
if name not in pythonforandroid.recipe.Recipe.recipes:
206+
pythonforandroid.recipe.Recipe.recipes[name] = (
207+
get_fake_recipe("sdl2", conflicts=["sdl2"])
208+
)
209+
return original_get_recipe(name, ctx)
210+
mock_get_recipe.side_effect = _add_sdl2_conflicting_recipe
211+
recipes_with_sdl2_conflict = {"python3", "conflictswithsdl2"}
212+
bs = Bootstrap.get_bootstrap_from_recipes(
213+
recipes_with_sdl2_conflict, self.ctx
214+
)
215+
self.assertNotEqual(bs.name, "sdl2")
216+
217+
# Test using flask will default to webview:
218+
recipes_with_flask = {"python3", "flask"}
219+
bs = Bootstrap.get_bootstrap_from_recipes(
220+
recipes_with_flask, self.ctx
221+
)
222+
self.assertEqual(bs.name, "webview")
223+
224+
# Test using random packages will default to service_only:
225+
recipes_with_no_sdl2_or_web = {"python3", "numpy"}
226+
bs = Bootstrap.get_bootstrap_from_recipes(
227+
recipes_with_no_sdl2_or_web, self.ctx
228+
)
229+
self.assertEqual(bs.name, "service_only")
125230

126-
# test wrong recipes
231+
# Test wrong recipes
127232
wrong_recipes = {"python2", "python3", "pyjnius"}
128-
bs = Bootstrap().get_bootstrap_from_recipes(wrong_recipes, self.ctx)
233+
bs = Bootstrap.get_bootstrap_from_recipes(wrong_recipes, self.ctx)
129234
self.assertIsNone(bs)
130235

131236
@mock.patch("pythonforandroid.bootstrap.ensure_dir")

0 commit comments

Comments
 (0)