Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Bootstrap.get_bootstrap_from_recipes() so it's smarter and deterministic, fixes #1875 #1887

Merged
merged 1 commit into from Jul 9, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 118 additions & 20 deletions pythonforandroid/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import functools
import glob
import importlib
import os
from os.path import (join, dirname, isdir, normpath, splitext, basename)
from os import listdir, walk, sep
import sh
import shlex
import glob
import importlib
import os
import shutil

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


default_recipe_priorities = [
"webview", "sdl2", "service_only" # last is highest
]
# ^^ NOTE: these are just the default priorities if no special rules
# apply (which you can find in the code below), so basically if no
# known graphical lib or web lib is used - in which case service_only
# is the most reasonable guess.


def _cmp_bootstraps_by_priority(a, b):
def rank_bootstrap(bootstrap):
""" Returns a ranking index for each bootstrap,
with higher priority ranked with higher number. """
if bootstrap.name in default_recipe_priorities:
return default_recipe_priorities.index(bootstrap.name) + 1
return 0

# Rank bootstraps in order:
rank_a = rank_bootstrap(a)
rank_b = rank_bootstrap(b)
if rank_a != rank_b:
return (rank_b - rank_a)
else:
if a.name < b.name: # alphabetic sort for determinism
return -1
else:
return 1


class Bootstrap(object):
'''An Android project template, containing recipe stuff for
compilation and templated fields for APK info.
Expand Down Expand Up @@ -138,36 +168,43 @@ def run_distribute(self):
self.distribution.save_info(self.dist_dir)

@classmethod
def list_bootstraps(cls):
def all_bootstraps(cls):
'''Find all the available bootstraps and return them.'''
forbidden_dirs = ('__pycache__', 'common')
bootstraps_dir = join(dirname(__file__), 'bootstraps')
result = set()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes also thought "this should be a set". But the fact that it was an iterator before made me more cautious. But seeing the code I'm not seeing why not being an iterator would be an issue. The result set would be anything enormous, right, so no risk to load it on the stack, correct?

for name in listdir(bootstraps_dir):
if name in forbidden_dirs:
continue
filen = join(bootstraps_dir, name)
if isdir(filen):
yield name
result.add(name)
return result

@classmethod
def get_bootstrap_from_recipes(cls, recipes, ctx):
'''Returns a bootstrap whose recipe requirements do not conflict with
the given recipes.'''
def get_usable_bootstraps_for_recipes(cls, recipes, ctx):
'''Returns all bootstrap whose recipe requirements do not conflict
with the given recipes, in no particular order.'''
info('Trying to find a bootstrap that matches the given recipes.')
bootstraps = [cls.get_bootstrap(name, ctx)
for name in cls.list_bootstraps()]
acceptable_bootstraps = []
for name in cls.all_bootstraps()]
acceptable_bootstraps = set()

# Find out which bootstraps are acceptable:
for bs in bootstraps:
if not bs.can_be_chosen_automatically:
continue
possible_dependency_lists = expand_dependencies(bs.recipe_depends)
possible_dependency_lists = expand_dependencies(bs.recipe_depends, ctx)
for possible_dependencies in possible_dependency_lists:
ok = True
# Check if the bootstap's dependencies have an internal conflict:
for recipe in possible_dependencies:
recipe = Recipe.get_recipe(recipe, ctx)
if any([conflict in recipes for conflict in recipe.conflicts]):
ok = False
break
# Check if bootstrap's dependencies conflict with chosen
# packages:
for recipe in recipes:
try:
recipe = Recipe.get_recipe(recipe, ctx)
Expand All @@ -180,14 +217,58 @@ def get_bootstrap_from_recipes(cls, recipes, ctx):
ok = False
break
if ok and bs not in acceptable_bootstraps:
acceptable_bootstraps.append(bs)
acceptable_bootstraps.add(bs)

info('Found {} acceptable bootstraps: {}'.format(
len(acceptable_bootstraps),
[bs.name for bs in acceptable_bootstraps]))
if acceptable_bootstraps:
info('Using the first of these: {}'
.format(acceptable_bootstraps[0].name))
return acceptable_bootstraps[0]
return acceptable_bootstraps

@classmethod
def get_bootstrap_from_recipes(cls, recipes, ctx):
'''Picks a single recommended default bootstrap out of
all_usable_bootstraps_from_recipes() for the given reicpes,
and returns it.'''

known_web_packages = {"flask"} # to pick webview over service_only
recipes_with_deps_lists = expand_dependencies(recipes, ctx)
acceptable_bootstraps = cls.get_usable_bootstraps_for_recipes(
recipes, ctx
)

def have_dependency_in_recipes(dep):
for dep_list in recipes_with_deps_lists:
if dep in dep_list:
return True
return False

# Special rule: return SDL2 bootstrap if there's an sdl2 dep:
if (have_dependency_in_recipes("sdl2") and
"sdl2" in [b.name for b in acceptable_bootstraps]
):
info('Using sdl2 bootstrap since it is in dependencies')
return cls.get_bootstrap("sdl2", ctx)

# Special rule: return "webview" if we depend on common web recipe:
for possible_web_dep in known_web_packages:
if have_dependency_in_recipes(possible_web_dep):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some thoughts without deep diving into it. So I'm not sure if we could get any better, but the time complexity of this one is concerning me.
Let me know if I'm seeing this correctly or not.
have_dependency_in_recipes() itself seems O(n²) because we have a in in a for loop.
Then we call have_dependency_in_recipes() itself in another for loop so that gives us a O(n³).
If by any chance dep_list is a set then the if dep in dep_list: is not O(n) anymore, but O(1). So then the overall complexity would be O(n²).

# We have a web package dep!
if "webview" in [b.name for b in acceptable_bootstraps]:
info('Using webview bootstrap since common web packages '
'were found {}'.format(
known_web_packages.intersection(recipes)
))
return cls.get_bootstrap("webview", ctx)

prioritized_acceptable_bootstraps = sorted(
list(acceptable_bootstraps),
key=functools.cmp_to_key(_cmp_bootstraps_by_priority)
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very interesting helper function. I understand you didn't want to expose it because it's a util function only being used in get_bootstrap_from_recipes(), but then:

  1. it makes the overall get_bootstrap_from_recipes() method larger
  2. the bootstrap_priority() helper is not unit testable

Maybe we should define it as a private top function inside the file bootstrap.py file? I have the feeling it would make the overall thing more readable and more testable


if prioritized_acceptable_bootstraps:
info('Using the highest ranked/first of these: {}'
.format(prioritized_acceptable_bootstraps[0].name))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The message say of these so I was expecting to see the list. But we actually show the one being picked up, which is fine, but then the log wording should change a bit

return prioritized_acceptable_bootstraps[0]
return None

@classmethod
Expand Down Expand Up @@ -299,9 +380,26 @@ def fry_eggs(self, sitepackages):
shprint(sh.rm, '-rf', d)


def expand_dependencies(recipes):
def expand_dependencies(recipes, ctx):
""" This function expands to lists of all different available
alternative recipe combinations, with the dependencies added in
ONLY for all the not-with-alternative recipes.
(So this is like the deps graph very simplified and incomplete, but
hopefully good enough for most basic bootstrap compatibility checks)
"""

# Add in all the deps of recipes where there is no alternative:
recipes_with_deps = list(recipes)
for entry in recipes:
if not isinstance(entry, (tuple, list)) or len(entry) == 1:
if isinstance(entry, (tuple, list)):
entry = entry[0]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's something fishy here 🐟 😄
First it doesn't seem covered, second it means expand_dependencies() would be a bit polymorphic in a way which can lead to unexpected behavior or code not so easy to follow.

Copy link
Author

@ghost ghost Jun 28, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean with polymorphic? This is the usual behavior of how all graph lists work, in fact I did not introduce this at all but just stuck to it as it already behaved (see the lower loop.) So no, pretty sure this is as it should be. (Dependencies can be a string or a tuple of alternatives, that is how things are used everywhere)

The coverage is a good point though, let me look into that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply. Great that you will look at the coverage, who knows maybe we will come across another old bug or dead code 😄

recipe = Recipe.get_recipe(entry, ctx)
recipes_with_deps += recipe.depends

# Split up lists by available alternatives:
recipe_lists = [[]]
for recipe in recipes:
for recipe in recipes_with_deps:
if isinstance(recipe, (tuple, list)):
new_recipe_lists = []
for alternative in recipe:
Expand All @@ -311,6 +409,6 @@ def expand_dependencies(recipes):
new_recipe_lists.append(new_list)
recipe_lists = new_recipe_lists
else:
for old_list in recipe_lists:
old_list.append(recipe)
for existing_list in recipe_lists:
existing_list.append(recipe)
return recipe_lists
121 changes: 113 additions & 8 deletions tests/test_bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

import os
import sh

import unittest

try:
Expand All @@ -9,12 +9,16 @@
# `Python 2` or lower than `Python 3.3` does not
# have the `unittest.mock` module built-in
import mock
from pythonforandroid.bootstrap import Bootstrap
from pythonforandroid.bootstrap import (
_cmp_bootstraps_by_priority, Bootstrap, expand_dependencies,
)
from pythonforandroid.distribution import Distribution
from pythonforandroid.recipe import Recipe
from pythonforandroid.archs import ArchARMv7_a
from pythonforandroid.build import Context

from test_graph import get_fake_recipe


class BaseClassSetupBootstrap(object):
"""
Expand Down Expand Up @@ -90,7 +94,7 @@ def test_build_dist_dirs(self):
- :meth:`~pythonforandroid.bootstrap.Bootstrap.get_dist_dir`
- :meth:`~pythonforandroid.bootstrap.Bootstrap.get_common_dir`
"""
bs = Bootstrap().get_bootstrap("sdl2", self.ctx)
bs = Bootstrap.get_bootstrap("sdl2", self.ctx)

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

def test_list_bootstraps(self):
def test__cmp_bootstraps_by_priority(self):
# Test service_only has higher priority than sdl2:
# (higher priority = smaller number/comes first)
self.assertTrue(_cmp_bootstraps_by_priority(
Bootstrap.get_bootstrap("service_only", self.ctx),
Bootstrap.get_bootstrap("sdl2", self.ctx)
) < 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome thanks!
Then we can write a simple test for covering the alphabetic sorting return (a.name - b.name) # alphabetic sort for determinism
So if basically if I understood correctly, we just need to comp two bootstraps that're not in default_recipe_priorities list. And if we don't have any, we can just patch the list to make it empty.
Would that make sense?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, neat idea! I'll add that, thanks 😍


# Test a random bootstrap is always lower priority than sdl2:
class _FakeBootstrap(object):
def __init__(self, name):
self.name = name
bs1 = _FakeBootstrap("alpha")
bs2 = _FakeBootstrap("zeta")
self.assertTrue(_cmp_bootstraps_by_priority(
bs1,
Bootstrap.get_bootstrap("sdl2", self.ctx)
) > 0)
self.assertTrue(_cmp_bootstraps_by_priority(
bs2,
Bootstrap.get_bootstrap("sdl2", self.ctx)
) > 0)

# Test bootstraps that aren't otherwise recognized are ranked
# alphabetically:
self.assertTrue(_cmp_bootstraps_by_priority(
bs2,
bs1,
) > 0)
self.assertTrue(_cmp_bootstraps_by_priority(
bs1,
bs2,
) < 0)

def test_all_bootstraps(self):
"""A test which will initialize a bootstrap and will check if the
method :meth:`~pythonforandroid.bootstrap.Bootstrap.list_bootstraps`
returns the expected values, which should be: `empty", `service_only`,
`webview` and `sdl2`
"""
expected_bootstraps = {"empty", "service_only", "webview", "sdl2"}
set_of_bootstraps = set(Bootstrap().list_bootstraps())
set_of_bootstraps = Bootstrap.all_bootstraps()
self.assertEqual(
expected_bootstraps, expected_bootstraps & set_of_bootstraps
)
self.assertEqual(len(expected_bootstraps), len(set_of_bootstraps))

def test_expand_dependencies(self):
# Test dependency expansion of a recipe with no alternatives:
expanded_result_1 = expand_dependencies(["pysdl2"], self.ctx)
self.assertTrue(
{"sdl2", "pysdl2", "python3"} in
[set(s) for s in expanded_result_1]
)

# Test expansion of a single element but as tuple:
expanded_result_1 = expand_dependencies([("pysdl2",)], self.ctx)
self.assertTrue(
{"sdl2", "pysdl2", "python3"} in
[set(s) for s in expanded_result_1]
)

# Test all alternatives are listed (they won't have dependencies
# expanded since expand_dependencies() is too simplistic):
expanded_result_2 = expand_dependencies([("pysdl2", "kivy")], self.ctx)
self.assertEqual([["pysdl2"], ["kivy"]], expanded_result_2)

def test_get_bootstraps_from_recipes(self):
"""A test which will initialize a bootstrap and will check if the
method :meth:`~pythonforandroid.bootstrap.Bootstrap.
get_bootstraps_from_recipes` returns the expected values
"""

import pythonforandroid.recipe
original_get_recipe = pythonforandroid.recipe.Recipe.get_recipe

# Test that SDL2 works with kivy:
recipes_sdl2 = {"sdl2", "python3", "kivy"}
bs = Bootstrap().get_bootstrap_from_recipes(recipes_sdl2, self.ctx)
bs = Bootstrap.get_bootstrap_from_recipes(recipes_sdl2, self.ctx)
self.assertEqual(bs.name, "sdl2")

# Test that pysdl2 or kivy alone will also yield SDL2 (dependency):
recipes_pysdl2_only = {"pysdl2"}
bs = Bootstrap.get_bootstrap_from_recipes(
recipes_pysdl2_only, self.ctx
)
self.assertEqual(bs.name, "sdl2")
recipes_kivy_only = {"kivy"}
bs = Bootstrap.get_bootstrap_from_recipes(
recipes_kivy_only, self.ctx
)
self.assertEqual(bs.name, "sdl2")

with mock.patch("pythonforandroid.recipe.Recipe.get_recipe") as \
mock_get_recipe:
# Test that something conflicting with sdl2 won't give sdl2:
def _add_sdl2_conflicting_recipe(name, ctx):
if name == "conflictswithsdl2":
if name not in pythonforandroid.recipe.Recipe.recipes:
pythonforandroid.recipe.Recipe.recipes[name] = (
get_fake_recipe("sdl2", conflicts=["sdl2"])
)
return original_get_recipe(name, ctx)
mock_get_recipe.side_effect = _add_sdl2_conflicting_recipe
recipes_with_sdl2_conflict = {"python3", "conflictswithsdl2"}
bs = Bootstrap.get_bootstrap_from_recipes(
recipes_with_sdl2_conflict, self.ctx
)
self.assertNotEqual(bs.name, "sdl2")

# Test using flask will default to webview:
recipes_with_flask = {"python3", "flask"}
bs = Bootstrap.get_bootstrap_from_recipes(
recipes_with_flask, self.ctx
)
self.assertEqual(bs.name, "webview")

# Test using random packages will default to service_only:
recipes_with_no_sdl2_or_web = {"python3", "numpy"}
bs = Bootstrap.get_bootstrap_from_recipes(
recipes_with_no_sdl2_or_web, self.ctx
)
self.assertEqual(bs.name, "service_only")

# test wrong recipes
# Test wrong recipes
wrong_recipes = {"python2", "python3", "pyjnius"}
bs = Bootstrap().get_bootstrap_from_recipes(wrong_recipes, self.ctx)
bs = Bootstrap.get_bootstrap_from_recipes(wrong_recipes, self.ctx)
self.assertIsNone(bs)

@mock.patch("pythonforandroid.bootstrap.ensure_dir")
Expand Down