From afe50a8b0e21a0d4aee1a33175e65e73f2a6080a Mon Sep 17 00:00:00 2001 From: Andre Miras Date: Mon, 25 May 2026 15:43:23 +0000 Subject: [PATCH] :bug: Resolve Meson tools through recipe env MesonRecipe installs meson and ninja as hostpython prerequisites and PyProjectRecipe exposes those executables by prepending the hostpython bin directories to the recipe environment PATH. Recipes that call sh.meson or sh.ninja directly can fail before _env is applied when those tools are not also visible on the current Python process PATH. Add shared MesonRecipe helpers that resolve commands against the recipe env PATH and return explicit sh.Command instances. Use those helpers in the libthorvg and libcairo manual Meson/Ninja build steps. Add a regression test that reproduces the environment-sensitive lookup failure and verifies the helper resolves meson from env PATH. --- pythonforandroid/recipe.py | 12 +++++++ pythonforandroid/recipes/libcairo/__init__.py | 14 ++++++-- .../recipes/libthorvg/__init__.py | 14 ++++++-- tests/test_recipe.py | 32 ++++++++++++++++++- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/pythonforandroid/recipe.py b/pythonforandroid/recipe.py index 3132a5d39d..1d5e3e9104 100644 --- a/pythonforandroid/recipe.py +++ b/pythonforandroid/recipe.py @@ -1567,6 +1567,18 @@ def ensure_args(self, *args): if arg not in self.extra_build_args: self.extra_build_args.append(arg) + def get_recipe_env_command(self, command, env): + command_path = shutil.which(command, path=env["PATH"]) + if command_path is None: + raise sh.CommandNotFound(command) + return sh.Command(command_path) + + def get_meson_command(self, env): + return self.get_recipe_env_command("meson", env) + + def get_ninja_command(self, env): + return self.get_recipe_env_command("ninja", env) + def build_arch(self, arch): cross_file = join("/tmp", "android.meson.cross") info("Writing cross file at: {}".format(cross_file)) diff --git a/pythonforandroid/recipes/libcairo/__init__.py b/pythonforandroid/recipes/libcairo/__init__.py index 14ca37d531..fef4f39ddc 100644 --- a/pythonforandroid/recipes/libcairo/__init__.py +++ b/pythonforandroid/recipes/libcairo/__init__.py @@ -56,7 +56,7 @@ def build_arch(self, arch): _env=env ) - shprint(sh.meson, 'setup', 'builddir', + shprint(self.get_meson_command(env), 'setup', 'builddir', '--cross-file', join("/tmp", "android.meson.cross"), f'--prefix={install_dir}', '-Dpng=enabled', @@ -72,14 +72,22 @@ def build_arch(self, arch): f'-Dfreetype_lib_dir={lib_dir}', _env=env) - shprint(sh.ninja, '-C', 'builddir', '-j', str(cpu_count()), _env=env) + shprint( + self.get_ninja_command(env), + '-C', 'builddir', '-j', str(cpu_count()), + _env=env + ) # macOS fix: sometimes Ninja creates a dummy 'lib' file instead of a directory. # So we remove and recreate the install directory using shell commands, # since os.remove/os.makedirs behave inconsistently in this build env. shprint(sh.rm, '-rf', install_dir) shprint(sh.mkdir, install_dir) - shprint(sh.ninja, '-C', 'builddir', 'install', _env=env) + shprint( + self.get_ninja_command(env), + '-C', 'builddir', 'install', + _env=env + ) recipe = LibCairoRecipe() diff --git a/pythonforandroid/recipes/libthorvg/__init__.py b/pythonforandroid/recipes/libthorvg/__init__.py index bb05c37a1f..c9eaefc90f 100644 --- a/pythonforandroid/recipes/libthorvg/__init__.py +++ b/pythonforandroid/recipes/libthorvg/__init__.py @@ -54,7 +54,7 @@ def build_arch(self, arch): with current_directory(build_dir): shprint( - sh.meson, + self.get_meson_command(env), "setup", "builddir", "--cross-file", @@ -72,10 +72,18 @@ def build_arch(self, arch): _env=env, ) - shprint(sh.ninja, "-C", "builddir", "-j", str(cpu_count()), _env=env) + shprint( + self.get_ninja_command(env), + "-C", "builddir", "-j", str(cpu_count()), + _env=env, + ) shprint(sh.rm, "-rf", install_dir) shprint(sh.mkdir, install_dir) - shprint(sh.ninja, "-C", "builddir", "install", _env=env) + shprint( + self.get_ninja_command(env), + "-C", "builddir", "install", + _env=env, + ) # copy libomp.so arch_map = { diff --git a/tests/test_recipe.py b/tests/test_recipe.py index e2e0e9d826..d4e1dc551a 100644 --- a/tests/test_recipe.py +++ b/tests/test_recipe.py @@ -1,5 +1,6 @@ import os import pytest +import sh import tempfile import types import unittest @@ -7,7 +8,9 @@ from unittest import mock from pythonforandroid.build import Context -from pythonforandroid.recipe import Recipe, TargetPythonRecipe, import_recipe +from pythonforandroid.recipe import ( + MesonRecipe, Recipe, TargetPythonRecipe, import_recipe +) from pythonforandroid.archs import ArchAarch_64 from pythonforandroid.bootstrap import Bootstrap from tests.test_bootstrap import BaseClassSetupBootstrap @@ -198,6 +201,33 @@ class DummyTargetPythonRecipe(TargetPythonRecipe): assert recipe.major_minor_version_string == '1.2' +class TestMesonRecipe(unittest.TestCase): + + def test_get_recipe_env_command_uses_env_path(self): + """ + Meson commands can be installed in the hostpython environment without + being visible on the current Python process PATH. + """ + with tempfile.TemporaryDirectory() as temp_dir: + bin_dir = os.path.join(temp_dir, "bin") + os.mkdir(bin_dir) + meson_path = os.path.join(bin_dir, "meson") + with open(meson_path, "w") as file: + file.write("#!/bin/sh\necho fake meson\n") + os.chmod(meson_path, 0o755) + + env = {"PATH": bin_dir} + recipe = MesonRecipe() + + with mock.patch.dict(os.environ, {"PATH": os.devnull}): + with pytest.raises(sh.CommandNotFound): + sh.meson("--version", _env=env) + + meson = recipe.get_meson_command(env) + + assert meson("--version").strip() == "fake meson" + + class TestLibraryRecipe(BaseClassSetupBootstrap, unittest.TestCase): def setUp(self): """