Skip to content

Commit 687ad6d

Browse files
committed
Add automatic expansion of --requirement list
Related to issue #2529
1 parent 4563f3e commit 687ad6d

File tree

3 files changed

+219
-10
lines changed

3 files changed

+219
-10
lines changed

pythonforandroid/toolchain.py

Lines changed: 129 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
66
This module defines the entry point for command line and programmatic use.
77
"""
8-
98
from os import environ
109
from pythonforandroid import __version__
1110
from pythonforandroid.pythonpackage import get_dep_names_of_package
@@ -651,9 +650,12 @@ def add_parser(subparsers, *args, **kwargs):
651650
"pyproject.toml"))):
652651
have_setup_py_or_similar = True
653652

654-
# Process requirements and put version in environ
655-
if hasattr(args, 'requirements'):
656-
requirements = []
653+
# Process requirements and put version in environ:
654+
if hasattr(args, 'requirements') and args.requirements:
655+
all_recipes = [
656+
recipe.lower() for recipe in
657+
set(Recipe.list_recipes(self.ctx))
658+
]
657659

658660
# Add dependencies from setup.py, but only if they are recipes
659661
# (because otherwise, setup.py itself will install them later)
@@ -672,10 +674,6 @@ def add_parser(subparsers, *args, **kwargs):
672674
)
673675
]
674676
info("Dependencies obtained: " + str(dependencies))
675-
all_recipes = [
676-
recipe.lower() for recipe in
677-
set(Recipe.list_recipes(self.ctx))
678-
]
679677
dependencies = set(dependencies).intersection(
680678
set(all_recipes)
681679
)
@@ -691,7 +689,126 @@ def add_parser(subparsers, *args, **kwargs):
691689
"package? Will continue WITHOUT setup.py deps."
692690
)
693691

694-
# Parse --requirements argument list:
692+
non_recipe_requirements = []
693+
for requirement in args.requirements.split(','):
694+
requirement_name = re.sub(r'==\d+(\.\d+)*', '', requirement)
695+
if requirement_name not in all_recipes:
696+
non_recipe_requirements.append(requirement)
697+
args.requirements = re.sub(
698+
r',?{}'.format(requirement), '', args.requirements)
699+
700+
# Compile "non-recipe" requirements' dependencies and add to list.
701+
# Otherwise, only recipe requirements' dependencies get installed.
702+
# More info https://github.com/kivy/python-for-android/issues/2529
703+
if non_recipe_requirements:
704+
info("Compiling dependencies for: "
705+
"{}".format(non_recipe_requirements))
706+
707+
output = shprint(
708+
sh.bash, '-c',
709+
"echo -e '{}' > requirements.in && "
710+
"pip-compile -v --dry-run --annotation-style=line && "
711+
"rm requirements.in".format(
712+
'\n'.join(non_recipe_requirements)))
713+
714+
# Parse pip-compile output
715+
parsed_requirement_info_list = []
716+
for line in output.splitlines():
717+
match_data = re.match(
718+
r'^([\w.-]+)==(\d+(\.\d+)*).*'
719+
r'#\s+via\s+([\w\s,.-]+)', line)
720+
721+
if match_data:
722+
parent_requirements = match_data.group(4).split(', ')
723+
requirement_name = match_data.group(1)
724+
requirement_version = match_data.group(2)
725+
726+
# Requirement is a "non-recipe" one we started with.
727+
if '-r requirements.in' in parent_requirements:
728+
parent_requirements.remove('-r requirements.in')
729+
730+
parsed_requirement_info_list.append([
731+
requirement_name,
732+
requirement_version,
733+
parent_requirements])
734+
735+
info("Requirements obtained from pip-compile: "
736+
"{}".format(["{}=={}".format(x[0], x[1])
737+
for x in parsed_requirement_info_list]))
738+
739+
# Remove indirect requirements ultimately installed by a recipe
740+
original_parsed_requirement_count = -1
741+
while len(parsed_requirement_info_list) != \
742+
original_parsed_requirement_count:
743+
744+
original_parsed_requirement_count = \
745+
len(parsed_requirement_info_list)
746+
747+
for i, parsed_requirement_info in \
748+
enumerate(reversed(parsed_requirement_info_list)):
749+
750+
index = original_parsed_requirement_count - i - 1
751+
requirement_name, requirement_version, \
752+
parent_requirements = parsed_requirement_info
753+
754+
# If any parent requirement has a recipe, this
755+
# requirement ought also to be installed by it.
756+
# Hence, it's better not to add this requirement the
757+
# expanded list.
758+
parent_requirements_with_recipe = list(
759+
set(parent_requirements).intersection(
760+
set(all_recipes)))
761+
762+
# Any parent requirement removed for the expanded list
763+
# implies that it and its own requirements (including
764+
# this requirement) will be installed by a recipe.
765+
# Hence, it's better not to add this requirement the
766+
# expanded list.
767+
requirement_name_list = \
768+
[x[0] for x in parsed_requirement_info_list]
769+
parent_requirements_still_in_list = list(
770+
set(parent_requirements).intersection(
771+
set(requirement_name_list)))
772+
773+
is_ultimately_installed_by_a_recipe = \
774+
len(parent_requirements) and \
775+
(parent_requirements_with_recipe or
776+
len(parent_requirements_still_in_list) !=
777+
len(parent_requirements))
778+
779+
if is_ultimately_installed_by_a_recipe:
780+
info(
781+
'{} will be installed by a recipe. Removing '
782+
'it from requirement list expansion.'.format(
783+
requirement_name))
784+
del parsed_requirement_info_list[index]
785+
786+
for parsed_requirement_info in parsed_requirement_info_list:
787+
requirement_name, requirement_version, \
788+
parent_requirements = parsed_requirement_info
789+
790+
# If the requirement has a recipe, don't use specific
791+
# version constraints determined by pip-compile. Some
792+
# recipes may not support the specified version. Therefor,
793+
# it's probably safer to just let them use their default
794+
# version. User can still force the usage of specific
795+
# version by explicitly declaring it with --requirements.
796+
requirement_has_recipe = requirement_name in all_recipes
797+
requirement_str = \
798+
requirement_name if requirement_has_recipe else \
799+
'{}=={}'.format(requirement_name, requirement_version)
800+
801+
requirement_names_arg = re.sub(
802+
r'==\d+(\.\d+)*', '', args.requirements).split(',')
803+
804+
# This expansion was carried out based on "non-recipe"
805+
# requirements. Hence,the counter-part, requirements
806+
# with a recipe, may already be part of list.
807+
if requirement_name not in requirement_names_arg:
808+
args.requirements += ',' + requirement_str
809+
810+
# Handle specific version requirement constraints (e.g. foo==x.y)
811+
requirements = []
695812
for requirement in split_argument_list(args.requirements):
696813
if "==" in requirement:
697814
requirement, version = requirement.split(u"==", 1)
@@ -701,6 +818,9 @@ def add_parser(subparsers, *args, **kwargs):
701818
requirements.append(requirement)
702819
args.requirements = u",".join(requirements)
703820

821+
info('Expanded Requirements List: '
822+
'{}'.format(args.requirements.split(',')))
823+
704824
self.warn_on_deprecated_args(args)
705825

706826
self.storage_dir = args.storage_dir

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
install_reqs = [
2323
'appdirs', 'colorama>=0.3.3', 'jinja2', 'six',
2424
'enum34; python_version<"3.4"', 'sh>=1.10; sys_platform!="nt"',
25-
'pep517<0.7.0', 'toml',
25+
'pep517<0.7.0', 'toml', 'pip-tools'
2626
]
2727
# (pep517 and toml are used by pythonpackage.py)
2828

tests/test_toolchain.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import io
2+
import os
23
import sys
34
import pytest
45
from unittest import mock
@@ -124,6 +125,94 @@ def test_create_no_sdk_dir(self):
124125
assert ex_info.value.message == (
125126
'Android SDK dir was not specified, exiting.')
126127

128+
def test_create_with_complex_requirements(self):
129+
requirements = [
130+
'python3==3.8.10', # direct requirement with recipe using version constraint
131+
'pandas', # direct requirement with recipe (no version constraint)
132+
'mfpymake==1.2.2', # direct requirement without recipe using version constraint
133+
'telenium', # direct requirement without recipe (no version constraint)
134+
'numpy==1.21.4', # indirect requirement with recipe using version constraint
135+
'mako==1.1.5', # indirect requirement without recipe using version constraint
136+
# There's no reason to specify an indirect requirement unless we want to install a specific version.
137+
]
138+
argv = [
139+
'toolchain.py',
140+
'create',
141+
'--sdk-dir=/tmp/android-sdk',
142+
'--ndk-dir=/tmp/android-ndk',
143+
'--bootstrap=service_only',
144+
'--requirements={}'.format(','.join(requirements)),
145+
'--dist-name=test_toolchain',
146+
'--activity-class-name=abc.myapp.android.CustomPythonActivity',
147+
'--service-class-name=xyz.myapp.android.CustomPythonService',
148+
]
149+
with patch_sys_argv(argv), mock.patch(
150+
'pythonforandroid.build.get_available_apis'
151+
) as m_get_available_apis, mock.patch(
152+
'pythonforandroid.build.get_toolchain_versions'
153+
) as m_get_toolchain_versions, mock.patch(
154+
'pythonforandroid.build.get_ndk_platform_dir'
155+
) as m_get_ndk_platform_dir, mock.patch(
156+
'pythonforandroid.toolchain.build_recipes'
157+
) as m_build_recipes, mock.patch(
158+
'pythonforandroid.bootstraps.service_only.'
159+
'ServiceOnlyBootstrap.assemble_distribution'
160+
) as m_run_distribute:
161+
m_get_available_apis.return_value = [27]
162+
m_get_toolchain_versions.return_value = (['4.9'], True)
163+
m_get_ndk_platform_dir.return_value = (
164+
'/tmp/android-ndk/platforms/android-21/arch-arm', True)
165+
tchain = ToolchainCL()
166+
assert tchain.ctx.activity_class_name == 'abc.myapp.android.CustomPythonActivity'
167+
assert tchain.ctx.service_class_name == 'xyz.myapp.android.CustomPythonService'
168+
assert m_get_available_apis.call_args_list in [
169+
[mock.call('/tmp/android-sdk')], # linux case
170+
[mock.call('/private/tmp/android-sdk')] # macos case
171+
]
172+
assert m_get_toolchain_versions.call_args_list in [
173+
[mock.call('/tmp/android-ndk', mock.ANY)], # linux case
174+
[mock.call('/private/tmp/android-ndk', mock.ANY)], # macos case
175+
]
176+
build_order = [
177+
'android', 'cython', 'genericndkbuild', 'hostpython3', 'libbz2',
178+
'libffi', 'liblzma', 'numpy', 'openssl', 'pandas', 'pyjnius',
179+
'python3', 'pytz', 'setuptools', 'six', 'sqlite3'
180+
]
181+
python_modules = [
182+
'certifi', 'charset-normalizer', 'cheroot', 'cherrypy',
183+
'idna', 'importlib-resources', 'jaraco.classes',
184+
'jaraco.collections', 'jaraco.functools', 'jaraco.text',
185+
'json-rpc', 'mako', 'markupsafe', 'mfpymake', 'more-itertools',
186+
'networkx', 'portend', 'python-dateutil', 'requests', 'telenium',
187+
'tempora', 'urllib3', 'werkzeug', 'ws4py', 'zc.lockfile', 'zipp'
188+
]
189+
context = mock.ANY
190+
project_dir = None
191+
# The pip-compile tool used to expanded the list of requirements
192+
# doesn't always return results in the same order.
193+
# _Call object and its properties are immutable, so we create a
194+
# new one with the same values.
195+
m_build_recipes_call_args = mock.call(
196+
sorted(m_build_recipes.call_args_list[0][0][0]),
197+
sorted(m_build_recipes.call_args_list[0][0][1]),
198+
m_build_recipes.call_args_list[0][0][2],
199+
m_build_recipes.call_args_list[0][0][3],
200+
ignore_project_setup_py=m_build_recipes.call_args_list[0][1]['ignore_project_setup_py']
201+
)
202+
assert m_build_recipes_call_args == mock.call(
203+
sorted(build_order),
204+
sorted(python_modules),
205+
context,
206+
project_dir,
207+
ignore_project_setup_py=False
208+
)
209+
assert m_run_distribute.call_args_list == [mock.call()]
210+
assert 'VERSION_python3' in os.environ and os.environ['VERSION_python3'] == '3.8.10'
211+
assert 'VERSION_mfpymake' in os.environ and os.environ['VERSION_mfpymake'] == '1.2.2'
212+
assert 'VERSION_numpy' in os.environ and os.environ['VERSION_numpy'] == '1.21.4'
213+
assert 'VERSION_mako' in os.environ and os.environ['VERSION_mako'] == '1.1.5'
214+
215+
127216
@pytest.mark.skipif(sys.version_info < (3, 0), reason="requires python3")
128217
def test_recipes(self):
129218
"""

0 commit comments

Comments
 (0)