Flask recipe doesn't work anymore
Werkzerug is upgraded to 3.X, so flask 2.0.3 will use werkzerug 3.X by default, which will raise the following error:
ImportError: cannot import name 'url_quote' from 'werkzeug.urls'
To solve the problem, flask recipe requirements should be updated like this:
pythonforandroid/recipes/flask/__init__.py
python_depends = ['jinja2', 'werkzeug==2.2.2', 'markupsafe', 'itsdangerous', 'click']
or flask recipe shoud be updated to 3.X.
More details:
https://stackoverflow.com/questions/77213053/why-did-flask-start-failing-with-importerror-cannot-import-name-url-quote-fr
In case someone has the same problem, here's a working recipe for latest Flask(3.1.1):
./recipes/my-flask/__init__.py
This recipe backports PyProjectRecipe from `develop` branch to `master`. If you are at `develop` branch, only `FlaskRecipe` class is needed.import glob
import packaging
import sh
from unittest import mock
from os import listdir, unlink, environ, curdir, walk
from os.path import dirname, join, realpath, exists, isdir
from pythonforandroid.recipe import Recipe
from pythonforandroid.logger import (
info, shprint)
from pythonforandroid.util import (
current_directory, ensure_dir)
def patch_wheel_setuptools_logging():
"""
When setuptools is not present and the root logger has no handlers,
Wheels would configure the root logger with DEBUG level, refs:
- https://github.com/pypa/wheel/blob/0.44.0/src/wheel/util.py
- https://github.com/pypa/wheel/blob/0.44.0/src/wheel/_setuptools_logging.py
Both of these conditions are met in our CI, leading to very verbose
and unreadable `sh` logs. Patching it prevents that.
"""
return mock.patch("wheel._setuptools_logging.configure")
class MyPythonRecipe(Recipe):
site_packages_name = None
'''The name of the module's folder when installed in the Python
site-packages (e.g. for pyjnius it is 'jnius')'''
call_hostpython_via_targetpython = True
'''If True, tries to install the module using the hostpython binary
copied to the target (normally arm) python build dir. However, this
will fail if the module tries to import e.g. _io.so. Set this to False
to call hostpython from its own build dir, installing the module in
the right place via arguments to setup.py. However, this may not set
the environment correctly and so False is not the default.'''
install_in_hostpython = False
'''If True, additionally installs the module in the hostpython build
dir. This will make it available to other recipes if
call_hostpython_via_targetpython is False.
'''
install_in_targetpython = True
'''If True, installs the module in the targetpython installation dir.
This is almost always what you want to do.'''
setup_extra_args = []
'''List of extra arguments to pass to setup.py'''
depends = ['python3']
'''
.. note:: it's important to keep this depends as a class attribute outside
`__init__` because sometimes we only initialize the class, so the
`__init__` call won't be called and the deps would be missing
(which breaks the dependency graph computation)
.. warning:: don't forget to call `super().__init__()` in any recipe's
`__init__`, or otherwise it may not be ensured that it depends
on python2 or python3 which can break the dependency graph
'''
hostpython_prerequisites = []
'''List of hostpython packages required to build a recipe'''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'python3' not in self.depends:
# We ensure here that the recipe depends on python even it overrode
# `depends`. We only do this if it doesn't already depend on any
# python, since some recipes intentionally don't depend on/work
# with all python variants
depends = self.depends
depends.append('python3')
depends = list(set(depends))
self.depends = depends
def clean_build(self, arch=None):
super().clean_build(arch=arch)
name = self.folder_name
python_install_dirs = glob.glob(join(self.ctx.python_installs_dir, '*'))
for python_install in python_install_dirs:
site_packages_dir = glob.glob(join(python_install, 'lib', 'python*',
'site-packages'))
if site_packages_dir:
build_dir = join(site_packages_dir[0], name)
if exists(build_dir):
info('Deleted {}'.format(build_dir))
rmdir(build_dir)
@property
def real_hostpython_location(self):
host_name = 'host{}'.format(self.ctx.python_recipe.name)
if host_name == 'hostpython3':
python_recipe = Recipe.get_recipe(host_name, self.ctx)
return python_recipe.python_exe
else:
python_recipe = self.ctx.python_recipe
return 'python{}'.format(python_recipe.version)
@property
def hostpython_location(self):
if not self.call_hostpython_via_targetpython:
return self.real_hostpython_location
return self.ctx.hostpython
@property
def folder_name(self):
'''The name of the build folders containing this recipe.'''
name = self.site_packages_name
if name is None:
name = self.name
return name
def get_recipe_env(self, arch=None, with_flags_in_cc=True):
env = super().get_recipe_env(arch, with_flags_in_cc)
env['PYTHONNOUSERSITE'] = '1'
# Set the LANG, this isn't usually important but is a better default
# as it occasionally matters how Python e.g. reads files
env['LANG'] = "en_GB.UTF-8"
# Binaries made by packages installed by pip
env["PATH"] = join(self.hostpython_site_dir, "bin") + ":" + env["PATH"]
if not self.call_hostpython_via_targetpython:
env['CFLAGS'] += ' -I{}'.format(
self.ctx.python_recipe.include_root(arch.arch)
)
env['LDFLAGS'] += ' -L{} -lpython{}'.format(
self.ctx.python_recipe.link_root(arch.arch),
self.ctx.python_recipe.link_version,
)
hppath = []
hppath.append(join(dirname(self.hostpython_location), 'Lib'))
hppath.append(join(hppath[0], 'site-packages'))
builddir = join(dirname(self.hostpython_location), 'build')
if exists(builddir):
hppath += [join(builddir, d) for d in listdir(builddir)
if isdir(join(builddir, d))]
if len(hppath) > 0:
if 'PYTHONPATH' in env:
env['PYTHONPATH'] = ':'.join(hppath + [env['PYTHONPATH']])
else:
env['PYTHONPATH'] = ':'.join(hppath)
return env
def should_build(self, arch):
name = self.folder_name
if self.ctx.has_package(name, arch):
info('Python package already exists in site-packages')
return False
info('{} apparently isn\'t already in site-packages'.format(name))
return True
def build_arch(self, arch):
'''Install the Python module by calling setup.py install with
the target Python dir.'''
self.install_hostpython_prerequisites()
super().build_arch(arch)
self.install_python_package(arch)
def install_python_package(self, arch, name=None, env=None, is_dir=True):
'''Automate the installation of a Python package (or a cython
package where the cython components are pre-built).'''
# arch = self.filtered_archs[0] # old kivy-ios way
if name is None:
name = self.name
if env is None:
env = self.get_recipe_env(arch)
info('Installing {} into site-packages'.format(self.name))
hostpython = sh.Command(self.hostpython_location)
hpenv = env.copy()
with current_directory(self.get_build_dir(arch.arch)):
shprint(hostpython, 'setup.py', 'install', '-O2',
'--root={}'.format(self.ctx.get_python_install_dir(arch.arch)),
'--install-lib=.',
_env=hpenv, *self.setup_extra_args)
# If asked, also install in the hostpython build dir
if self.install_in_hostpython:
self.install_hostpython_package(arch)
def get_hostrecipe_env(self, arch):
env = environ.copy()
env['PYTHONPATH'] = self.hostpython_site_dir
return env
@property
def hostpython_site_dir(self):
return join(dirname(self.real_hostpython_location), 'Lib', 'site-packages')
def install_hostpython_package(self, arch):
env = self.get_hostrecipe_env(arch)
real_hostpython = sh.Command(self.real_hostpython_location)
shprint(real_hostpython, 'setup.py', 'install', '-O2',
'--root={}'.format(dirname(self.real_hostpython_location)),
'--install-lib=Lib/site-packages',
_env=env, *self.setup_extra_args)
@property
def python_major_minor_version(self):
parsed_version = packaging.version.parse(self.ctx.python_recipe.version)
return f"{parsed_version.major}.{parsed_version.minor}"
def install_hostpython_prerequisites(self, packages=None, force_upgrade=True):
if not packages:
packages = self.hostpython_prerequisites
if len(packages) == 0:
return
pip_options = [
"install",
*packages,
"--target", self.hostpython_site_dir, "--python-version",
self.ctx.python_recipe.version,
# Don't use sources, instead wheels
"--only-binary=:all:",
]
if force_upgrade:
pip_options.append("--upgrade")
# Use system's pip
shprint(sh.pip, *pip_options)
def restore_hostpython_prerequisites(self, packages):
_packages = []
for package in packages:
original_version = Recipe.get_recipe(package, self.ctx).version
_packages.append(package + "==" + original_version)
self.install_hostpython_prerequisites(packages=_packages)
class PyProjectRecipe(MyPythonRecipe):
"""Recipe for projects which contain `pyproject.toml`"""
# Extra args to pass to `python -m build ...`
extra_build_args = []
call_hostpython_via_targetpython = False
def get_recipe_env(self, arch, **kwargs):
# Custom hostpython
self.ctx.python_recipe.python_exe = join(
self.ctx.python_recipe.get_build_dir(arch), "android-build", "python3")
env = super().get_recipe_env(arch, **kwargs)
build_dir = self.get_build_dir(arch)
ensure_dir(build_dir)
build_opts = join(build_dir, "build-opts.cfg")
with open(build_opts, "w") as file:
file.write("[bdist_wheel]\nplat-name={}".format(
self.get_wheel_platform_tag(arch)
))
file.close()
env["DIST_EXTRA_CONFIG"] = build_opts
return env
def get_wheel_platform_tag(self, arch):
return "android_" + {
"armeabi-v7a": "arm",
"arm64-v8a": "aarch64",
"x86_64": "x86_64",
"x86": "i686",
}[arch.arch]
def install_wheel(self, arch, built_wheels):
with patch_wheel_setuptools_logging():
from wheel.cli.tags import tags as wheel_tags
from wheel.wheelfile import WheelFile
_wheel = built_wheels[0]
built_wheel_dir = dirname(_wheel)
# Fix wheel platform tag
wheel_tag = wheel_tags(
_wheel,
platform_tags=self.get_wheel_platform_tag(arch),
remove=True,
)
selected_wheel = join(built_wheel_dir, wheel_tag)
_dev_wheel_dir = environ.get("P4A_WHEEL_DIR", False)
if _dev_wheel_dir:
ensure_dir(_dev_wheel_dir)
shprint(sh.cp, selected_wheel, _dev_wheel_dir)
info(f"Installing built wheel: {wheel_tag}")
destination = self.ctx.get_python_install_dir(arch.arch)
with WheelFile(selected_wheel) as wf:
for zinfo in wf.filelist:
wf.extract(zinfo, destination)
wf.close()
def build_arch(self, arch):
self.install_hostpython_prerequisites(
packages=["build[virtualenv]", "pip"] + self.hostpython_prerequisites
)
build_dir = self.get_build_dir(arch.arch)
env = self.get_recipe_env(arch, with_flags_in_cc=True)
# make build dir separately
sub_build_dir = join(build_dir, "p4a_android_build")
ensure_dir(sub_build_dir)
# copy hostpython to built python to ensure correct selection of libs and includes
shprint(sh.cp, self.real_hostpython_location, self.ctx.python_recipe.python_exe)
build_args = [
"-m",
"build",
"--wheel",
"--config-setting",
"builddir={}".format(sub_build_dir),
] + self.extra_build_args
built_wheels = []
with current_directory(build_dir):
shprint(
sh.Command(self.ctx.python_recipe.python_exe), *build_args, _env=env
)
built_wheels = [realpath(whl) for whl in glob.glob("dist/*.whl")]
self.install_wheel(arch, built_wheels)
class FlaskRecipe(PyProjectRecipe):
version = '3.1.1'
url = 'https://github.com/pallets/flask/archive/{version}.zip'
depends = ['setuptools']
python_depends = ['jinja2', 'werkzeug', 'markupsafe', 'itsdangerous', 'click', 'blinker']
call_hostpython_via_targetpython = False
install_in_hostpython = False
recipe = FlaskRecipe()
If you are using buildozer, make the following edits:
[app]
requirements = python3,my-flask # flask -> my-flask
p4a.local_recipes =./recipes # path to your recipes root
It seems that local_recipes won't overwrite existing recipes, so you need a new name. Here I changed flask to my-flask.
Make sure to clean (buildozer -v android clean) before building.