diff --git a/QUICK_START.md b/QUICK_START.md index 4db7044..8f5ce96 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -45,6 +45,10 @@ 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 --timings # 生成 Cargo timings 报告 +buckyos-build --timings-dir ./timings # 生成并复制 Cargo timings 报告 ``` ### 2. `buckyos-install` - 安装工具 diff --git a/USAGE_EXAMPLE.md b/USAGE_EXAMPLE.md index 1124956..a9fa44e 100644 --- a/USAGE_EXAMPLE.md +++ b/USAGE_EXAMPLE.md @@ -41,6 +41,18 @@ 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 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 d915113..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 @@ -100,9 +101,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,15 +131,57 @@ 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, + 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)) + 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 @@ -140,8 +189,11 @@ 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] = [] + 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"): target = "x86_64-pc-windows-msvc" # elif system == "Linux" and (arch == "x86_64" or arch == "AMD64"): @@ -157,6 +209,47 @@ def build_main(): skip_web_module = True i += 1 continue + 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": + 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 +295,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, timings, timings_dir) if __name__ == "__main__": build_main() diff --git a/src/build_rust.py b/src/build_rust.py index 4083cd4..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,9 +403,55 @@ 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): - print(f"🚀 Building Rust code,target_dir is {project.rust_target_dir},target is {rust_target}") - env = os.environ.copy() +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, + 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() build_env = get_build_metadata(str(project.base_dir)) for key, value in build_env.items(): env.setdefault(key, value) @@ -417,11 +464,17 @@ 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 + 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(target_dir)] + if timings: + 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) ] @@ -454,11 +507,14 @@ def build_rust_modules(project: BuckyProject, rust_target: str, selected_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 new file mode 100644 index 0000000..95d3453 --- /dev/null +++ b/tests/test_build_options.py @@ -0,0 +1,124 @@ +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 BuildRustTimingsTests(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"], + timings=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) + + 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()