python-for-android icon indicating copy to clipboard operation
python-for-android copied to clipboard

Flask recipe doesn't work anymore

Open HugoPu opened this issue 1 year ago • 1 comments

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

HugoPu avatar Dec 13 '24 00:12 HugoPu

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.

XcantloadX avatar Aug 03 '25 05:08 XcantloadX