Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions agent/builder.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import ast
import configparser
import contextlib
import os
import re
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand All @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions agent/tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading