Repository: kivy/python-for-android

Issue:
get_bootstrap_from_recipes() result consistency
In https://github.com/kivy/python-for-android/pull/1872#discussion_r294949284 we realised get_bootstrap_from_recipes() was not always returning consistent result across setups.
This is an investigation task to determine if it's a bug or a feature and if it's a bug to fix it.


Discussion:
Basically on my setup and in 0c9ef2e calling `list(Bootstrap().list_bootstraps())` returns the following: `['webview', 'sdl2', 'service_only', 'empty']` I guess for @opacam and Travis it returned something with "service_only" being first.
@AndreMiras `listdir` depends on inode ordering and is not alphabetically sorted:
```python
    @classmethod
    def list_bootstraps(cls):
        '''Find all the available bootstraps and return them.'''
        forbidden_dirs = ('__pycache__', 'common')
        bootstraps_dir = join(dirname(__file__), 'bootstraps')
        for name in listdir(bootstraps_dir):
            if name in forbidden_dirs:
                continue
            filen = join(bootstraps_dir, name)
            if isdir(filen):
                yield name
```
I think a simple `sorted(listdir(bootstraps_dir))` may fix it and make it behave deterministically

Resolving patch:
diff --git a/pythonforandroid/bootstrap.py b/pythonforandroid/bootstrap.py
--- a/pythonforandroid/bootstrap.py
+++ b/pythonforandroid/bootstrap.py
@@ -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,
@@ -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.
@@ -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()
         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)
@@ -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):
+                # 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)
+        )
+
+        if prioritized_acceptable_bootstraps:
+            info('Using the highest ranked/first of these: {}'
+                 .format(prioritized_acceptable_bootstraps[0].name))
+            return prioritized_acceptable_bootstraps[0]
         return None
 
     @classmethod
@@ -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]
+            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:
@@ -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


Test patch:
diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py
--- a/tests/test_bootstrap.py
+++ b/tests/test_bootstrap.py
@@ -1,6 +1,6 @@
+
 import os
 import sh
-
 import unittest
 
 try:
@@ -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):
     """
@@ -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")
@@ -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)
+
+        # 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")