From ab9b6a5aee276cdf1faed03e0c6f980cb4cd5ce1 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Tue, 10 Nov 2026 11:11:43 +0530 Subject: [PATCH] fix(build): Write the package name of each app to apps.txt The remote builder wrote the Press app name to apps.txt. `bench get-app` installs an app under the package name from pyproject.toml, setup.cfg or setup.py, and this name can be different. For example, the Press app `sheets_sync` installs as `sheets` at some commits. Frappe imports each app in apps.txt when it loads all the apps of a bench. On such a bench, `bench console`, `bench backup` and other commands failed with `ModuleNotFoundError`. Read the package name in the same order as bench, and write it to apps.txt. Press did this before the builds moved to the agent. --- agent/builder.py | 56 +++++++++++++++++++++++++++++++++---- agent/tests/test_builder.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/agent/builder.py b/agent/builder.py index 5f8a2280..05fd0f3f 100644 --- a/agent/builder.py +++ b/agent/builder.py @@ -1,5 +1,7 @@ from __future__ import annotations +import ast +import configparser import contextlib import os import re @@ -22,7 +24,7 @@ import semantic_version as sv from agent.base import PUBLISH_INTERVAL_SECONDS, Base, Throttle -from agent.build_utils.validations import check_python_syntax, get_package_manager_files +from agent.build_utils.validations import check_python_syntax, get_package_manager_files, load_pyproject from agent.exceptions import AgentException, RegistryDownException from agent.job import Job, Step, job, step from agent.utils import is_registry_healthy @@ -50,6 +52,19 @@ def stamp_step_end(line: str) -> str: return f"{stripped} {ended_at}\n" +def get_app_name_from_setup(contents: str) -> str | None: + """Return the `name` argument of the `setup()` call in setup.py, the same way as bench.""" + for node in ast.parse(contents).body: + if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)): + continue + if getattr(node.value.func, "id", None) != "setup": + continue + for keyword in node.value.keywords: + if keyword.arg == "name" and isinstance(keyword.value, ast.Constant): + return keyword.value.value + return None + + class AppInfo(TypedDict): app: str url: str @@ -330,6 +345,40 @@ def _inject_additional_packages(self, packages: list[str]): with open(dockerfile_path, "w") as f: f.write(content) + def _generate_apps_txt(self): + app_names = [self._get_app_name(app_info["app"]) for app_info in self.clone_instructions] + with open(os.path.join(self.build_directory, "apps.txt"), "w") as apps_file: + apps_file.write("\n".join(app_names) + "\n") + + def _get_app_name(self, app: str) -> str: + """Return the package name of the app from pyproject.toml, setup.cfg or setup.py. + + `bench get-app` installs the app under this name, and Frappe imports each app in + apps.txt by name. It can be different from the app name in Press. + """ + app_path = os.path.join(self.build_directory, "apps", app) + + pyproject_path = os.path.join(app_path, "pyproject.toml") + if os.path.exists(pyproject_path) and ( + app_name := load_pyproject(app, pyproject_path).get("project", {}).get("name") + ): + return app_name + + setup_cfg_path = os.path.join(app_path, "setup.cfg") + if os.path.exists(setup_cfg_path): + config = configparser.ConfigParser(interpolation=None) + config.read(setup_cfg_path) + if app_name := config.get("metadata", "name", fallback=None): + return app_name + + setup_py_path = os.path.join(app_path, "setup.py") + if os.path.exists(setup_py_path): + with open(setup_py_path) as f: + if app_name := get_app_name_from_setup(f.read()): + return app_name + + return app + @step("Prepare Build Context") def prepare_build_context(self): """Clone the apps passed from build instructions and return the build context directory path""" @@ -354,10 +403,7 @@ def prepare_build_context(self): with open(os.path.join(self.build_directory, "Dockerfile"), "w") as dockerfile: dockerfile.write(self.dockerfile) - with open(os.path.join(self.build_directory, "apps.txt"), "w") as apps_file: - apps_file.write( - "\n".join([app_info["app"] for app_info in self.clone_instructions]) + "\n", - ) + self._generate_apps_txt() additional_packages = self._parse_additional_packages() self._inject_additional_packages(additional_packages) diff --git a/agent/tests/test_builder.py b/agent/tests/test_builder.py index 55fb70d8..06dc44a2 100644 --- a/agent/tests/test_builder.py +++ b/agent/tests/test_builder.py @@ -269,3 +269,47 @@ def test_stored_assets_that_write_outside_their_folder_are_refused(self): with open(os.path.join(self.public, "frontend", "index.html")) as f: self.assertEqual(f.read(), "old") self.assertFalse(os.path.exists(os.path.join(self.bench, "apps", "builder", "hooks.py"))) + + +class TestAppsTxt(unittest.TestCase): + def write_app(self, build_directory: str, app: str, files: dict[str, str]): + app_path = os.path.join(build_directory, "apps", app) + os.makedirs(app_path) + for name, content in files.items(): + with open(os.path.join(app_path, name), "w") as f: + f.write(content) + + def test_apps_txt_lists_the_package_name_of_each_app(self): + from agent.builder import ContextManager + + with tempfile.TemporaryDirectory() as build_directory: + self.write_app(build_directory, "frappe", {"pyproject.toml": '[project]\nname = "frappe"\n'}) + self.write_app(build_directory, "sheets_sync", {"pyproject.toml": '[project]\nname = "sheets"\n'}) + self.write_app(build_directory, "cfg_app", {"setup.cfg": "[metadata]\nname = cfg_package\n"}) + self.write_app( + build_directory, + "py_app", + { + "pyproject.toml": "[build-system]\nrequires = []\n", + "setup.py": 'app_name = "wrong"\nsetup(name="py_package", version="1.0")\n', + }, + ) + self.write_app(build_directory, "bare_app", {"setup.py": "setup()\n"}) + + context = SimpleNamespace( + build_directory=build_directory, + clone_instructions=[ + {"app": "frappe"}, + {"app": "sheets_sync"}, + {"app": "cfg_app"}, + {"app": "py_app"}, + {"app": "bare_app"}, + ], + ) + context._get_app_name = lambda app: ContextManager._get_app_name(context, app) + ContextManager._generate_apps_txt(context) + + with open(os.path.join(build_directory, "apps.txt")) as f: + apps = f.read() + + self.assertEqual(apps, "frappe\nsheets\ncfg_package\npy_package\nbare_app\n")