From 9a592fb5e85ec48d2f7546c43a4feffbbf4cab57 Mon Sep 17 00:00:00 2001 From: weiqiushi Date: Sat, 6 Jun 2026 16:19:59 +0800 Subject: [PATCH 1/2] Add app-scoped build and cargo timing option --- QUICK_START.md | 3 ++ USAGE_EXAMPLE.md | 9 ++++ src/build.py | 105 +++++++++++++++++++++++++++++++++--- src/build_rust.py | 19 ++++--- tests/test_build_options.py | 90 +++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 13 deletions(-) create mode 100644 tests/test_build_options.py diff --git a/QUICK_START.md b/QUICK_START.md index 4db7044..5219178 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -45,6 +45,9 @@ buckyos-build --help buckyos-build --no-build-web-modules # 跳过 web modules 构建 buckyos-build --no-install # 不安装 buckyos-build --target=x86_64-unknown-linux-musl # 指定目标 +buckyos-build --app demo_app # 只构建指定 app 使用的模块 +buckyos-build --app app1 app2 # 同时构建多个 app 使用的模块 +buckyos-build --timing # 生成 Cargo timing 报告 ``` ### 2. `buckyos-install` - 安装工具 diff --git a/USAGE_EXAMPLE.md b/USAGE_EXAMPLE.md index 1124956..2e79ef5 100644 --- a/USAGE_EXAMPLE.md +++ b/USAGE_EXAMPLE.md @@ -41,6 +41,15 @@ buckyos-build --no-install # 指定目标平台 buckyos-build --target=x86_64-unknown-linux-musl +# 只构建指定 app 使用的模块 +buckyos-build --app demo_app + +# 支持同时指定多个 app +buckyos-build --app demo_app admin_app + +# 生成 Cargo timing 报告 +buckyos-build --timing + # 构建 amd64 版本 buckyos-build amd64 diff --git a/src/build.py b/src/build.py index d915113..5886e9d 100644 --- a/src/build.py +++ b/src/build.py @@ -100,9 +100,15 @@ def _run(stdscr): } return selected -def _prompt_select_modules(project: BuckyProject, skip_web_module: bool) -> set[str]: +def _prompt_select_modules( + project: BuckyProject, + skip_web_module: bool, + selectable_modules: set[str] | None = None, +) -> set[str]: selectable = [] for module_name, module_info in project.modules.items(): + if selectable_modules is not None and module_name not in selectable_modules: + continue if skip_web_module and isinstance(module_info, WebModuleInfo): continue if isinstance(module_info, WebModuleInfo): @@ -124,10 +130,47 @@ def _prompt_select_modules(project: BuckyProject, skip_web_module: bool) -> set[ return _prompt_select_modules_line(selectable) -def build(project: BuckyProject, rust_target: str, skip_web_module: bool, selected_modules: set[str] | None = None): +def _split_option_values(value: str) -> list[str]: + return [item for item in value.replace(",", " ").split() if item] + +def _collect_app_modules(project: BuckyProject, app_names: list[str]) -> set[str]: + selected_modules: set[str] = set() + missing_apps = [] + skipped_modules: set[str] = set() + + for app_name in app_names: + app_info = project.apps.get(app_name) + if app_info is None: + missing_apps.append(app_name) + continue + + for module_name in app_info.modules.keys(): + if module_name in project.modules: + selected_modules.add(module_name) + else: + skipped_modules.add(module_name) + + if missing_apps: + raise ValueError(f"App not found in bucky_project.apps: {', '.join(sorted(missing_apps))}") + + if skipped_modules: + print( + "Warning: app modules not found in bucky_project.modules, skipped: " + + ", ".join(sorted(skipped_modules)) + ) + + return selected_modules + +def build( + project: BuckyProject, + rust_target: str, + skip_web_module: bool, + selected_modules: set[str] | None = None, + timing: bool = False, +): if not skip_web_module: build_web_modules(project, None if selected_modules is None else list(selected_modules)) - build_rust_modules(project, rust_target, None if selected_modules is None else list(selected_modules)) + build_rust_modules(project, rust_target, None if selected_modules is None else list(selected_modules), timing) copy_build_results(project, skip_web_module, rust_target, None if selected_modules is None else list(selected_modules)) def build_main(): @@ -140,8 +183,10 @@ def build_main(): target = "" select_mode = False selected_modules = None - if system == "Linux" and (arch == "x86_64" or arch == "AMD64"): - target = "x86_64-unknown-linux-musl" + app_names: list[str] = [] + timing = False + if system == "Linux" and (arch == "x86_64" or arch == "AMD64"): + target = "x86_64-unknown-linux-musl" elif system == "Windows" and (arch == "x86_64" or arch == "AMD64"): target = "x86_64-pc-windows-msvc" # elif system == "Linux" and (arch == "x86_64" or arch == "AMD64"): @@ -157,6 +202,30 @@ def build_main(): skip_web_module = True i += 1 continue + if arg == "--timing": + timing = True + i += 1 + continue + if arg == "--app": + apps = [] + j = i + 1 + while j < len(args) and not args[j].startswith("-"): + apps.extend(_split_option_values(args[j])) + j += 1 + if not apps: + print("Error: --app requires at least one app name") + sys.exit(1) + app_names.extend(apps) + i = j + continue + if arg.startswith("--app="): + apps = _split_option_values(arg.split("=", 1)[1]) + if not apps: + print("Error: --app requires at least one app name") + sys.exit(1) + app_names.extend(apps) + i += 1 + continue if arg == "--select" or arg == "-s": # Examples: "-s mod_a mod_b" builds those modules; "-s" enters interactive selection. modules = [] @@ -202,14 +271,36 @@ def build_main(): overlay_files.append(local_config_file) bucky_project = BuckyProject.from_file(config_file, overlay_files) + app_selected_modules = None + if app_names: + try: + app_selected_modules = _collect_app_modules(bucky_project, app_names) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + if not app_selected_modules: + print("No modules selected by --app; build skipped.") + return + if selected_modules is None and select_mode: - selected_modules = _prompt_select_modules(bucky_project, skip_web_module) + selected_modules = _prompt_select_modules(bucky_project, skip_web_module, app_selected_modules) if not selected_modules: print("No modules selected; build skipped.") return + if app_selected_modules is not None: + if selected_modules is None: + selected_modules = app_selected_modules + else: + selected_modules = selected_modules.intersection(app_selected_modules) + if not selected_modules: + print("No modules selected after applying --app filter; build skipped.") + return + print(f"Selected modules from --app: {', '.join(sorted(selected_modules))}") + print(f"Rust target is : {target}") - build(bucky_project, target, skip_web_module, selected_modules) + build(bucky_project, target, skip_web_module, selected_modules, timing) if __name__ == "__main__": build_main() diff --git a/src/build_rust.py b/src/build_rust.py index 4083cd4..c002344 100644 --- a/src/build_rust.py +++ b/src/build_rust.py @@ -402,7 +402,12 @@ def get_cross_compile_env_vars_by_target(target: str) -> Optional[Dict[str, str] return env_vars -def build_rust_modules(project: BuckyProject, rust_target: str, selected_modules: list[str] | None = None): +def build_rust_modules( + project: BuckyProject, + rust_target: str, + selected_modules: list[str] | None = None, + timing: bool = False, +): print(f"🚀 Building Rust code,target_dir is {project.rust_target_dir},target is {rust_target}") env = os.environ.copy() build_env = get_build_metadata(str(project.base_dir)) @@ -417,11 +422,13 @@ def build_rust_modules(project: BuckyProject, rust_target: str, selected_modules env_vars = get_env_vars_by_target(rust_target) env.update(env_vars) - cross_compile_env_vars = get_cross_compile_env_vars_by_target(rust_target) - cargo_args = ["cargo", "build", "--release", "--target-dir", str(project.rust_target_dir)] - if selected_modules is not None: - rust_modules = [ - module_name + cross_compile_env_vars = get_cross_compile_env_vars_by_target(rust_target) + cargo_args = ["cargo", "build", "--release", "--target-dir", str(project.rust_target_dir)] + if timing: + cargo_args.append("--timings") + if selected_modules is not None: + rust_modules = [ + module_name for module_name, module_info in project.modules.items() if isinstance(module_info, RustModuleInfo) ] diff --git a/tests/test_build_options.py b/tests/test_build_options.py new file mode 100644 index 0000000..a37e48f --- /dev/null +++ b/tests/test_build_options.py @@ -0,0 +1,90 @@ +import importlib +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from src.project import AppInfo, BuckyProject, RustModuleInfo, WebModuleInfo + +build = importlib.import_module("src.build") +build_rust = importlib.import_module("src.build_rust") + + +class BuildAppOptionTests(unittest.TestCase): + def make_project(self) -> BuckyProject: + return BuckyProject( + name="test-project", + version="0.1.0", + modules={ + "daemon": RustModuleInfo(name="daemon"), + "frontend": WebModuleInfo(name="frontend", src_dir=Path("web/frontend")), + "unused": RustModuleInfo(name="unused"), + }, + apps={ + "system": AppInfo( + name="system", + rootfs=Path("rootfs/system"), + default_target_rootfs=Path("/opt/system"), + modules={ + "daemon": "bin", + "frontend": "www", + "missing": "bin", + }, + ), + "tools": AppInfo( + name="tools", + rootfs=Path("rootfs/tools"), + default_target_rootfs=Path("/opt/tools"), + modules={ + "unused": "bin", + }, + ), + }, + ) + + def test_collect_app_modules_intersects_with_project_modules(self): + with patch("builtins.print"): + selected_modules = build._collect_app_modules(self.make_project(), ["system", "tools"]) + + self.assertEqual(selected_modules, {"daemon", "frontend", "unused"}) + + def test_collect_app_modules_rejects_unknown_app(self): + with self.assertRaisesRegex(ValueError, "missing-app"): + build._collect_app_modules(self.make_project(), ["missing-app"]) + + +class BuildRustTimingTests(unittest.TestCase): + def test_build_rust_modules_adds_cargo_timings_flag(self): + with tempfile.TemporaryDirectory() as tmp_dir: + project = BuckyProject( + name="test-project", + version="0.1.0", + base_dir=Path(tmp_dir), + modules={ + "daemon": RustModuleInfo(name="daemon"), + "frontend": WebModuleInfo(name="frontend", src_dir=Path("web/frontend")), + }, + ) + project.rust_target_dir = Path(tmp_dir) / "target" + + with patch.object(build_rust, "get_build_metadata", return_value={}), patch.object( + build_rust, "get_env_vars_by_target", return_value={} + ), patch.object(build_rust, "get_cross_compile_env_vars_by_target", return_value=None), patch.object( + build_rust.subprocess, "run" + ) as run_mock: + build_rust.build_rust_modules( + project, + "x86_64-unknown-linux-gnu", + selected_modules=["daemon"], + timing=True, + ) + + cargo_args = run_mock.call_args.args[0] + self.assertIn("--timings", cargo_args) + self.assertEqual(cargo_args.count("-p"), 1) + self.assertIn("daemon", cargo_args) + self.assertNotIn("frontend", cargo_args) + + +if __name__ == "__main__": + unittest.main() From 0e2c2826372cbfb938fd3602b1fe0ed520f0f4e9 Mon Sep 17 00:00:00 2001 From: weiqiushi Date: Sat, 6 Jun 2026 16:30:59 +0800 Subject: [PATCH 2/2] Add timing report copy dir --- QUICK_START.md | 3 +- USAGE_EXAMPLE.md | 7 +++- src/build.py | 44 +++++++++++++++++----- src/build_rust.py | 75 ++++++++++++++++++++++++++++++------- tests/test_build_options.py | 38 ++++++++++++++++++- 5 files changed, 139 insertions(+), 28 deletions(-) diff --git a/QUICK_START.md b/QUICK_START.md index 5219178..8f5ce96 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -47,7 +47,8 @@ buckyos-build --no-install # 不安装 buckyos-build --target=x86_64-unknown-linux-musl # 指定目标 buckyos-build --app demo_app # 只构建指定 app 使用的模块 buckyos-build --app app1 app2 # 同时构建多个 app 使用的模块 -buckyos-build --timing # 生成 Cargo timing 报告 +buckyos-build --timings # 生成 Cargo timings 报告 +buckyos-build --timings-dir ./timings # 生成并复制 Cargo timings 报告 ``` ### 2. `buckyos-install` - 安装工具 diff --git a/USAGE_EXAMPLE.md b/USAGE_EXAMPLE.md index 2e79ef5..a9fa44e 100644 --- a/USAGE_EXAMPLE.md +++ b/USAGE_EXAMPLE.md @@ -47,8 +47,11 @@ buckyos-build --app demo_app # 支持同时指定多个 app buckyos-build --app demo_app admin_app -# 生成 Cargo timing 报告 -buckyos-build --timing +# 生成 Cargo timings 报告 +buckyos-build --timings + +# 生成并复制 Cargo timings 报告到指定目录 +buckyos-build --timings-dir ./timings # 构建 amd64 版本 buckyos-build amd64 diff --git a/src/build.py b/src/build.py index 5886e9d..2b9fd31 100644 --- a/src/build.py +++ b/src/build.py @@ -1,5 +1,6 @@ import sys import platform +from pathlib import Path from typing import Optional from .build_web_apps import build_web_modules @@ -166,16 +167,21 @@ def build( rust_target: str, skip_web_module: bool, selected_modules: set[str] | None = None, - timing: bool = False, + timings: bool = False, + timings_dir: Path | None = None, ): if not skip_web_module: build_web_modules(project, None if selected_modules is None else list(selected_modules)) - build_rust_modules(project, rust_target, None if selected_modules is None else list(selected_modules), timing) + build_rust_modules( + project, + rust_target, + None if selected_modules is None else list(selected_modules), + timings, + timings_dir, + ) copy_build_results(project, skip_web_module, rust_target, None if selected_modules is None else list(selected_modules)) - -def build_main(): - from pathlib import Path - + +def build_main(): skip_web_module = False system = platform.system() # Linux / Windows / Darwin arch = platform.machine() # x86_64 / AMD64 / arm64 / arm @@ -184,7 +190,8 @@ def build_main(): select_mode = False selected_modules = None app_names: list[str] = [] - timing = False + timings = False + timings_dir: Path | None = None if system == "Linux" and (arch == "x86_64" or arch == "AMD64"): target = "x86_64-unknown-linux-musl" elif system == "Windows" and (arch == "x86_64" or arch == "AMD64"): @@ -202,8 +209,25 @@ def build_main(): skip_web_module = True i += 1 continue - if arg == "--timing": - timing = True + if arg == "--timings": + timings = True + i += 1 + continue + if arg == "--timings-dir": + if i + 1 >= len(args) or args[i + 1].startswith("-"): + print("Error: --timings-dir requires a directory") + sys.exit(1) + timings = True + timings_dir = Path(args[i + 1]) + i += 2 + continue + if arg.startswith("--timings-dir="): + value = arg.split("=", 1)[1] + if not value: + print("Error: --timings-dir requires a directory") + sys.exit(1) + timings = True + timings_dir = Path(value) i += 1 continue if arg == "--app": @@ -300,7 +324,7 @@ def build_main(): print(f"Selected modules from --app: {', '.join(sorted(selected_modules))}") print(f"Rust target is : {target}") - build(bucky_project, target, skip_web_module, selected_modules, timing) + build(bucky_project, target, skip_web_module, selected_modules, timings, timings_dir) if __name__ == "__main__": build_main() diff --git a/src/build_rust.py b/src/build_rust.py index c002344..eaa9316 100644 --- a/src/build_rust.py +++ b/src/build_rust.py @@ -6,8 +6,9 @@ import subprocess import platform import shutil -import re +import re from datetime import datetime +from pathlib import Path from typing import Optional,Dict from .project import BuckyProject, RustModuleInfo @@ -402,14 +403,55 @@ def get_cross_compile_env_vars_by_target(target: str) -> Optional[Dict[str, str] return env_vars +def _resolve_rust_target_dir(project: BuckyProject) -> Path: + target_dir = Path(project.rust_target_dir) + if target_dir.is_absolute(): + return target_dir + return project.resolve_from_base_dir(target_dir) + +def _get_timing_reports(target_dir: Path) -> list[Path]: + cargo_timings_dir = target_dir / "cargo-timings" + if not cargo_timings_dir.exists(): + return [] + return sorted( + cargo_timings_dir.glob("cargo-timing*.html"), + key=lambda path: path.stat().st_mtime_ns, + ) + +def _resolve_timings_output_dir(project: BuckyProject, timings_dir: str | Path) -> Path: + output_dir = Path(os.path.expanduser(os.path.expandvars(os.fspath(timings_dir)))) + if output_dir.is_absolute(): + return output_dir + return project.resolve_from_base_dir(output_dir) + +def _copy_timing_reports( + project: BuckyProject, + target_dir: Path, + timings_dir: str | Path, + previous_reports: list[Path], +) -> None: + previous_report_set = set(previous_reports) + reports = [path for path in _get_timing_reports(target_dir) if path not in previous_report_set] + if not reports: + print(f"Warning: no new Cargo timings report found under {target_dir / 'cargo-timings'}") + return + + output_dir = _resolve_timings_output_dir(project, timings_dir) + output_dir.mkdir(parents=True, exist_ok=True) + for report in reports: + target_file = output_dir / report.name + shutil.copy2(report, target_file) + print(f"Copied Cargo timings report: {report} => {target_file}") + def build_rust_modules( project: BuckyProject, rust_target: str, selected_modules: list[str] | None = None, - timing: bool = False, + timings: bool = False, + timings_dir: str | Path | None = None, ): - print(f"🚀 Building Rust code,target_dir is {project.rust_target_dir},target is {rust_target}") - env = os.environ.copy() + print(f"🚀 Building Rust code,target_dir is {project.rust_target_dir},target is {rust_target}") + env = os.environ.copy() build_env = get_build_metadata(str(project.base_dir)) for key, value in build_env.items(): env.setdefault(key, value) @@ -422,9 +464,13 @@ def build_rust_modules( env_vars = get_env_vars_by_target(rust_target) env.update(env_vars) + target_dir = _resolve_rust_target_dir(project) + timings = timings or timings_dir is not None + previous_timing_reports = _get_timing_reports(target_dir) if timings_dir is not None else [] + cross_compile_env_vars = get_cross_compile_env_vars_by_target(rust_target) - cargo_args = ["cargo", "build", "--release", "--target-dir", str(project.rust_target_dir)] - if timing: + cargo_args = ["cargo", "build", "--release", "--target-dir", str(target_dir)] + if timings: cargo_args.append("--timings") if selected_modules is not None: rust_modules = [ @@ -461,11 +507,14 @@ def build_rust_modules( cwd=project.base_dir, env=env) else: - print("*", " ".join(cargo_args)) - subprocess.run(cargo_args, - check=True, - cwd=project.base_dir, - env=env) - - print(f'✅ Build Rust Modules completed') + print("*", " ".join(cargo_args)) + subprocess.run(cargo_args, + check=True, + cwd=project.base_dir, + env=env) + + if timings_dir is not None: + _copy_timing_reports(project, target_dir, timings_dir, previous_timing_reports) + + print(f'✅ Build Rust Modules completed') diff --git a/tests/test_build_options.py b/tests/test_build_options.py index a37e48f..95d3453 100644 --- a/tests/test_build_options.py +++ b/tests/test_build_options.py @@ -53,7 +53,7 @@ def test_collect_app_modules_rejects_unknown_app(self): build._collect_app_modules(self.make_project(), ["missing-app"]) -class BuildRustTimingTests(unittest.TestCase): +class BuildRustTimingsTests(unittest.TestCase): def test_build_rust_modules_adds_cargo_timings_flag(self): with tempfile.TemporaryDirectory() as tmp_dir: project = BuckyProject( @@ -76,7 +76,7 @@ def test_build_rust_modules_adds_cargo_timings_flag(self): project, "x86_64-unknown-linux-gnu", selected_modules=["daemon"], - timing=True, + timings=True, ) cargo_args = run_mock.call_args.args[0] @@ -85,6 +85,40 @@ def test_build_rust_modules_adds_cargo_timings_flag(self): self.assertIn("daemon", cargo_args) self.assertNotIn("frontend", cargo_args) + def test_build_rust_modules_copies_new_timings_report(self): + with tempfile.TemporaryDirectory() as tmp_dir: + project = BuckyProject( + name="test-project", + version="0.1.0", + base_dir=Path(tmp_dir), + modules={ + "daemon": RustModuleInfo(name="daemon"), + }, + ) + project.rust_target_dir = Path(tmp_dir) / "target" + output_dir = Path(tmp_dir) / "timings-output" + + def make_timing_report(*args, **kwargs): + report_dir = project.rust_target_dir / "cargo-timings" + report_dir.mkdir(parents=True, exist_ok=True) + (report_dir / "cargo-timing-test.html").write_text("", encoding="utf-8") + + with patch.object(build_rust, "get_build_metadata", return_value={}), patch.object( + build_rust, "get_env_vars_by_target", return_value={} + ), patch.object(build_rust, "get_cross_compile_env_vars_by_target", return_value=None), patch.object( + build_rust.subprocess, "run", side_effect=make_timing_report + ) as run_mock: + build_rust.build_rust_modules( + project, + "x86_64-unknown-linux-gnu", + selected_modules=["daemon"], + timings_dir=output_dir, + ) + + cargo_args = run_mock.call_args.args[0] + self.assertIn("--timings", cargo_args) + self.assertTrue((output_dir / "cargo-timing-test.html").exists()) + if __name__ == "__main__": unittest.main()