multi-lang-build 0.2.5__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. multi_lang_build/__init__.py +4 -4
  2. multi_lang_build/cli.py +5 -2
  3. multi_lang_build/compiler/__init__.py +1 -1
  4. multi_lang_build/compiler/go_compiler.py +403 -0
  5. multi_lang_build/compiler/go_support/__init__.py +19 -0
  6. multi_lang_build/compiler/go_support/binary.py +119 -0
  7. multi_lang_build/compiler/go_support/builder.py +228 -0
  8. multi_lang_build/compiler/go_support/cleaner.py +40 -0
  9. multi_lang_build/compiler/go_support/env.py +41 -0
  10. multi_lang_build/compiler/go_support/mirror.py +111 -0
  11. multi_lang_build/compiler/go_support/module.py +80 -0
  12. multi_lang_build/compiler/go_support/tester.py +200 -0
  13. multi_lang_build/compiler/pnpm.py +61 -209
  14. multi_lang_build/compiler/pnpm_support/__init__.py +6 -0
  15. multi_lang_build/compiler/pnpm_support/executor.py +148 -0
  16. multi_lang_build/compiler/pnpm_support/project.py +53 -0
  17. multi_lang_build/compiler/python.py +65 -222
  18. multi_lang_build/compiler/python_support/__init__.py +13 -0
  19. multi_lang_build/compiler/python_support/cleaner.py +56 -0
  20. multi_lang_build/compiler/python_support/cli.py +46 -0
  21. multi_lang_build/compiler/python_support/detector.py +64 -0
  22. multi_lang_build/compiler/python_support/installer.py +162 -0
  23. multi_lang_build/compiler/python_support/venv.py +63 -0
  24. multi_lang_build/ide_register.py +97 -0
  25. multi_lang_build/mirror/config.py +19 -0
  26. multi_lang_build/register/support/__init__.py +13 -0
  27. multi_lang_build/register/support/claude.py +94 -0
  28. multi_lang_build/register/support/codebuddy.py +100 -0
  29. multi_lang_build/register/support/opencode.py +62 -0
  30. multi_lang_build/register/support/trae.py +72 -0
  31. {multi_lang_build-0.2.5.dist-info → multi_lang_build-0.3.0.dist-info}/METADATA +41 -5
  32. multi_lang_build-0.3.0.dist-info/RECORD +40 -0
  33. multi_lang_build/compiler/go.py +0 -468
  34. multi_lang_build/register.py +0 -412
  35. multi_lang_build-0.2.5.dist-info/RECORD +0 -18
  36. {multi_lang_build-0.2.5.dist-info → multi_lang_build-0.3.0.dist-info}/WHEEL +0 -0
  37. {multi_lang_build-0.2.5.dist-info → multi_lang_build-0.3.0.dist-info}/entry_points.txt +0 -0
  38. {multi_lang_build-0.2.5.dist-info → multi_lang_build-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,228 @@
1
+ """Go build utilities."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Final
7
+
8
+ from loguru import logger
9
+
10
+ from multi_lang_build.compiler.base import BuildResult
11
+
12
+
13
+ class GoBuilder:
14
+ """Build Go binaries with various options."""
15
+
16
+ DEFAULT_TARGETS: Final[list[str]] = ["linux/amd64", "linux/arm64", "darwin/amd64", "darwin/arm64"]
17
+
18
+ @staticmethod
19
+ def build(
20
+ go_executable: str,
21
+ source_dir: Path,
22
+ output_path: Path,
23
+ *,
24
+ ldflags: str | None = None,
25
+ tags: list[str] | None = None,
26
+ environment: dict[str, str],
27
+ stream_output: bool = True,
28
+ ) -> BuildResult:
29
+ """Build a Go binary.
30
+
31
+ Args:
32
+ go_executable: Path to Go executable
33
+ source_dir: Source directory
34
+ output_path: Output path for binary
35
+ ldflags: Linker flags
36
+ tags: Build tags
37
+ environment: Environment variables
38
+ stream_output: Whether to stream output
39
+
40
+ Returns:
41
+ BuildResult with success status
42
+ """
43
+ build_args = [go_executable, "build", "-o", str(output_path)]
44
+
45
+ if ldflags:
46
+ build_args.extend(["-ldflags", ldflags])
47
+
48
+ if tags:
49
+ build_args.extend(["-tags", ",".join(tags)])
50
+
51
+ return GoBuilder._run_build(
52
+ build_args, source_dir, environment, stream_output
53
+ )
54
+
55
+ @staticmethod
56
+ def build_all(
57
+ go_executable: str,
58
+ source_dir: Path,
59
+ output_dir: Path,
60
+ *,
61
+ environment: dict[str, str],
62
+ stream_output: bool = True,
63
+ ) -> BuildResult:
64
+ """Build all packages in the module.
65
+
66
+ Args:
67
+ go_executable: Path to Go executable
68
+ source_dir: Source directory
69
+ output_dir: Output directory for binaries
70
+ environment: Environment variables
71
+ stream_output: Whether to stream output
72
+
73
+ Returns:
74
+ BuildResult with success status
75
+ """
76
+ build_args = [go_executable, "build", "-o", str(output_dir), "./..."]
77
+
78
+ return GoBuilder._run_build(
79
+ build_args, source_dir, environment, stream_output
80
+ )
81
+
82
+ @staticmethod
83
+ def cross_compile(
84
+ go_executable: str,
85
+ source_dir: Path,
86
+ output_path: Path,
87
+ target: str,
88
+ *,
89
+ ldflags: str | None = None,
90
+ environment: dict[str, str],
91
+ stream_output: bool = True,
92
+ ) -> BuildResult:
93
+ """Cross-compile for a specific platform.
94
+
95
+ Args:
96
+ go_executable: Path to Go executable
97
+ source_dir: Source directory
98
+ output_path: Output path for binary
99
+ target: Target platform (e.g., "linux/amd64")
100
+ ldflags: Linker flags
101
+ environment: Environment variables
102
+ stream_output: Whether to stream output
103
+
104
+ Returns:
105
+ BuildResult with success status
106
+ """
107
+ build_args = [
108
+ go_executable, "build",
109
+ "-o", str(output_path),
110
+ "-ldflags", f"{ldflags or ''} -s -w",
111
+ "-GOOS", target.split("/")[0],
112
+ "-GOARCH", target.split("/")[1],
113
+ ]
114
+
115
+ return GoBuilder._run_build(
116
+ build_args, source_dir, environment, stream_output
117
+ )
118
+
119
+ @staticmethod
120
+ def _run_build(
121
+ command: list[str],
122
+ source_dir: Path,
123
+ environment: dict[str, str],
124
+ stream_output: bool,
125
+ ) -> BuildResult:
126
+ """Execute build command."""
127
+ import time
128
+
129
+ start_time = time.perf_counter()
130
+
131
+ try:
132
+ if stream_output:
133
+ return GoBuilder._stream_build(
134
+ command, source_dir, environment, start_time
135
+ )
136
+ else:
137
+ return GoBuilder._capture_build(
138
+ command, source_dir, environment, start_time
139
+ )
140
+ except Exception as e:
141
+ duration = time.perf_counter() - start_time
142
+ return BuildResult(
143
+ success=False,
144
+ return_code=-2,
145
+ stdout="",
146
+ stderr=f"Build error: {str(e)}",
147
+ output_path=None,
148
+ duration_seconds=duration,
149
+ )
150
+
151
+ @staticmethod
152
+ def _stream_build(
153
+ command: list[str],
154
+ source_dir: Path,
155
+ environment: dict[str, str],
156
+ start_time: float,
157
+ ) -> BuildResult:
158
+ """Execute build with streaming output."""
159
+ import os
160
+ import sys
161
+
162
+ stdout_buffer = []
163
+ stderr_buffer = []
164
+
165
+ process = subprocess.Popen(
166
+ command,
167
+ cwd=source_dir,
168
+ stdout=subprocess.PIPE,
169
+ stderr=subprocess.PIPE,
170
+ text=True,
171
+ env={**os.environ, **environment},
172
+ )
173
+
174
+ # Read stdout
175
+ if process.stdout:
176
+ for line in process.stdout:
177
+ line = line.rstrip('\n\r')
178
+ stdout_buffer.append(line)
179
+ print(line)
180
+ sys.stdout.flush()
181
+
182
+ # Read stderr
183
+ if process.stderr:
184
+ for line in process.stderr:
185
+ line = line.rstrip('\n\r')
186
+ stderr_buffer.append(line)
187
+ print(line, file=sys.stderr)
188
+ sys.stderr.flush()
189
+
190
+ return_code = process.wait()
191
+ duration = time.perf_counter() - start_time
192
+
193
+ return BuildResult(
194
+ success=return_code == 0,
195
+ return_code=return_code,
196
+ stdout='\n'.join(stdout_buffer),
197
+ stderr='\n'.join(stderr_buffer),
198
+ output_path=source_dir if return_code == 0 else None,
199
+ duration_seconds=duration,
200
+ )
201
+
202
+ @staticmethod
203
+ def _capture_build(
204
+ command: list[str],
205
+ source_dir: Path,
206
+ environment: dict[str, str],
207
+ start_time: float,
208
+ ) -> BuildResult:
209
+ """Execute build with captured output."""
210
+ result = subprocess.run(
211
+ command,
212
+ cwd=source_dir,
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=3600,
216
+ env={**os.environ, **environment},
217
+ )
218
+
219
+ duration = time.perf_counter() - start_time
220
+
221
+ return BuildResult(
222
+ success=result.returncode == 0,
223
+ return_code=result.returncode,
224
+ stdout=result.stdout,
225
+ stderr=result.stderr,
226
+ output_path=source_dir if result.returncode == 0 else None,
227
+ duration_seconds=duration,
228
+ )
@@ -0,0 +1,40 @@
1
+ """Go build artifact cleaning utilities."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+
7
+ class GoCleaner:
8
+ """Clean Go build artifacts."""
9
+
10
+ @staticmethod
11
+ def clean(directory: Path) -> bool:
12
+ """Clean Go build artifacts in the specified directory.
13
+
14
+ Args:
15
+ directory: Directory to clean
16
+
17
+ Returns:
18
+ True if successful, False otherwise.
19
+ """
20
+ try:
21
+ # Remove go.sum
22
+ go_sum = directory / "go.sum"
23
+ if go_sum.exists():
24
+ go_sum.unlink()
25
+
26
+ # Remove vendor directory
27
+ vendor_dir = directory / "vendor"
28
+ if vendor_dir.exists() and vendor_dir.is_dir():
29
+ shutil.rmtree(vendor_dir)
30
+
31
+ # Remove build artifacts
32
+ for pattern in ["*.exe", "*.exe~", "*.test", "*.test~"]:
33
+ for file in directory.glob(pattern):
34
+ if file.is_file():
35
+ file.unlink()
36
+
37
+ return True
38
+
39
+ except Exception:
40
+ return False
@@ -0,0 +1,41 @@
1
+ """Go environment preparation utilities."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from loguru import logger
7
+
8
+
9
+ class GoEnvironment:
10
+ """Prepare Go build environment with cache paths."""
11
+
12
+ @staticmethod
13
+ def prepare(environment: dict[str, str] | None = None) -> dict[str, str]:
14
+ """Prepare environment with GOMODCACHE and GOCACHE set.
15
+
16
+ Args:
17
+ environment: Base environment variables
18
+
19
+ Returns:
20
+ Environment with cache paths set
21
+ """
22
+ env = environment.copy() if environment else {}
23
+
24
+ # Set HOME if not present (needed for Path.home() and GOCACHE)
25
+ if "HOME" not in env:
26
+ home = os.environ.get("HOME") or "/root"
27
+ env["HOME"] = home
28
+
29
+ # Set GOMODCACHE if not present
30
+ if "GOMODCACHE" not in env and "GOPATH" not in env:
31
+ default_cache = Path(env["HOME"]) / "go" / "pkg" / "mod"
32
+ env["GOMODCACHE"] = str(default_cache)
33
+ logger.info(f"Go模块缓存: {default_cache}")
34
+
35
+ # Set GOCACHE if not present
36
+ if "GOCACHE" not in env:
37
+ cache_dir = Path(env["HOME"]) / ".cache" / "go-build"
38
+ env["GOCACHE"] = str(cache_dir)
39
+ logger.info(f"Go构建缓存: {cache_dir}")
40
+
41
+ return env
@@ -0,0 +1,111 @@
1
+ """Go mirror failover support."""
2
+
3
+ from pathlib import Path
4
+ from loguru import logger
5
+
6
+ from multi_lang_build.compiler.base import BuildResult
7
+ from multi_lang_build.mirror.config import (
8
+ MIRROR_CONFIGS,
9
+ get_go_mirror_with_fallback,
10
+ apply_mirror_environment,
11
+ )
12
+
13
+
14
+ class GoMirrorFailover:
15
+ """Handle Go mirror failover logic."""
16
+
17
+ def __init__(self, configured_mirror: str | None = None):
18
+ """Initialize mirror failover handler.
19
+
20
+ Args:
21
+ configured_mirror: User-configured mirror key (if any).
22
+ If None, will use failover mode.
23
+ """
24
+ self.configured_mirror = configured_mirror
25
+
26
+ def get_mirror_info(self, mirror_key: str) -> tuple[str, str]:
27
+ """Get mirror name and URL.
28
+
29
+ Args:
30
+ mirror_key: Mirror configuration key
31
+
32
+ Returns:
33
+ Tuple of (name, url)
34
+ """
35
+ config = MIRROR_CONFIGS.get(mirror_key, {})
36
+ return config.get("name", mirror_key), config.get("url", "")
37
+
38
+ def apply_mirror(self, mirror_key: str, environment: dict[str, str]) -> dict[str, str]:
39
+ """Apply mirror environment variables.
40
+
41
+ Args:
42
+ mirror_key: Mirror configuration key
43
+ environment: Base environment variables
44
+
45
+ Returns:
46
+ Updated environment with mirror variables
47
+ """
48
+ config_key = self.configured_mirror or mirror_key
49
+ return apply_mirror_environment(config_key, environment)
50
+
51
+ def try_build_with_fallback(
52
+ self,
53
+ command: list[str],
54
+ source_dir: Path,
55
+ output_dir: Path,
56
+ environment: dict[str, str],
57
+ stream_output: bool,
58
+ run_build_func,
59
+ ) -> BuildResult:
60
+ """Try build command with mirror failover.
61
+
62
+ Tries mirrors in order: goproxy.io → goproxy.cn → goproxy.vip.cn
63
+ Only switches to next mirror on failure.
64
+
65
+ Args:
66
+ command: Build command to execute
67
+ source_dir: Source directory for the build
68
+ output_dir: Output directory for build artifacts
69
+ environment: Environment variables
70
+ stream_output: Whether to stream output in real-time
71
+ run_build_func: Function to execute the build
72
+
73
+ Returns:
74
+ BuildResult with success status and output information.
75
+ """
76
+ # If user configured a specific mirror, use it without failover
77
+ if self.configured_mirror:
78
+ mirror_name, _ = self.get_mirror_info(self.configured_mirror)
79
+ logger.info(f"🔗 使用代理: {mirror_name}")
80
+ env = self.apply_mirror(self.configured_mirror, environment)
81
+ return run_build_func(command, source_dir, output_dir, env, stream_output)
82
+
83
+ # Use failover mode
84
+ mirror_keys = get_go_mirror_with_fallback(failover=True)
85
+ last_result: BuildResult | None = None
86
+
87
+ for i, mirror_key in enumerate(mirror_keys):
88
+ env = environment.copy()
89
+ mirror_name, mirror_url = self.get_mirror_info(mirror_key)
90
+ logger.info(f"🔗 使用代理: {mirror_name} ({mirror_url})")
91
+
92
+ env = self.apply_mirror(mirror_key, env)
93
+
94
+ result = run_build_func(command, source_dir, output_dir, env, stream_output)
95
+ last_result = result
96
+
97
+ if result["success"]:
98
+ return result
99
+
100
+ # Failed, try next mirror
101
+ if i < len(mirror_keys) - 1:
102
+ logger.warning(f"❌ {mirror_name} 失败,切换到备用镜像...")
103
+ else:
104
+ logger.error(f"❌ 所有镜像均失败")
105
+
106
+ assert last_result is not None
107
+ return last_result
108
+
109
+
110
+ # Also export fallback order constant for direct access
111
+ GO_MIRROR_FALLBACK_ORDER = ["go_qiniu", "go", "go_vip"]
@@ -0,0 +1,80 @@
1
+ """Go module building utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+ from loguru import logger
6
+
7
+ from multi_lang_build.compiler.base import BuildResult
8
+
9
+
10
+ class GoModuleBuilder:
11
+ """Build all packages in a Go module."""
12
+
13
+ @staticmethod
14
+ def build_all(
15
+ go_executable: str,
16
+ source_dir: Path,
17
+ output_dir: Path,
18
+ env: dict[str, str],
19
+ platform: str | None,
20
+ run_build,
21
+ stream_output: bool = True,
22
+ ) -> BuildResult:
23
+ """Build all packages in the module.
24
+
25
+ Args:
26
+ go_executable: Path to Go executable
27
+ source_dir: Source directory
28
+ output_dir: Output directory for binaries
29
+ env: Environment variables
30
+ platform: Target platform (e.g., "linux/amd64")
31
+ run_build: Callback to execute build
32
+ stream_output: Whether to stream output
33
+
34
+ Returns:
35
+ BuildResult with success status
36
+ """
37
+ build_args = [go_executable, "build", "-o", str(output_dir), "./..."]
38
+
39
+ env_copy = env.copy()
40
+ if platform:
41
+ env_copy["GOOS"], env_copy["GOARCH"] = platform.split("/")
42
+
43
+ logger.info("🔨 开始构建...")
44
+ logger.info(f"构建命令: {' '.join(build_args)}")
45
+ build_result = run_build(build_args, source_dir, output_dir, env_copy)
46
+
47
+ if build_result["success"]:
48
+ logger.info("✅ 构建成功")
49
+
50
+ return build_result
51
+
52
+ @staticmethod
53
+ def tidy(
54
+ go_executable: str,
55
+ source_dir: Path,
56
+ env: dict[str, str],
57
+ run_build,
58
+ stream_output: bool = True,
59
+ ) -> BuildResult:
60
+ """Run go mod tidy to clean up dependencies.
61
+
62
+ Args:
63
+ go_executable: Path to Go executable
64
+ source_dir: Source directory
65
+ env: Environment variables
66
+ run_build: Callback to execute build
67
+ stream_output: Whether to stream output
68
+
69
+ Returns:
70
+ BuildResult with success status
71
+ """
72
+ tidy_cmd = [go_executable, "mod", "tidy"]
73
+ logger.info("📦 执行 go mod tidy...")
74
+ logger.info(f"构建命令: {' '.join(tidy_cmd)}")
75
+ tidy_result = run_build(tidy_cmd, source_dir, source_dir, env)
76
+
77
+ if tidy_result["success"]:
78
+ logger.info("✅ 依赖整理完成")
79
+
80
+ return tidy_result
@@ -0,0 +1,200 @@
1
+ """Go test utilities."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from loguru import logger
11
+
12
+ from multi_lang_build.compiler.base import BuildResult
13
+
14
+
15
+ class GoTester:
16
+ """Run Go tests."""
17
+
18
+ @staticmethod
19
+ def run(
20
+ go_executable: str,
21
+ source_dir: Path,
22
+ env: dict[str, str],
23
+ verbose: bool = True,
24
+ race: bool = False,
25
+ stream_output: bool = True,
26
+ run_build: Callable | None = None,
27
+ ) -> BuildResult:
28
+ """Run Go tests.
29
+
30
+ Args:
31
+ go_executable: Path to Go executable
32
+ source_dir: Source directory
33
+ env: Environment variables
34
+ verbose: Enable verbose test output
35
+ race: Enable race detector
36
+ stream_output: Whether to stream output
37
+ run_build: Optional custom build runner
38
+
39
+ Returns:
40
+ BuildResult with success status
41
+ """
42
+ test_args = [go_executable, "test"]
43
+
44
+ if verbose:
45
+ test_args.append("-v")
46
+ if race:
47
+ test_args.append("-race")
48
+
49
+ test_args.append("./...")
50
+
51
+ logger.info("🧪 开始运行测试...")
52
+ logger.info(f"构建命令: {' '.join(test_args)}")
53
+
54
+ if run_build:
55
+ result = run_build(test_args, source_dir, source_dir, env)
56
+ else:
57
+ result = GoTester._run_test(test_args, source_dir, env, stream_output)
58
+
59
+ if result["success"]:
60
+ logger.info("✅ 测试完成")
61
+
62
+ return result
63
+
64
+ @staticmethod
65
+ def run_with_coverage(
66
+ go_executable: str,
67
+ source_dir: Path,
68
+ output_file: Path,
69
+ env: dict[str, str],
70
+ stream_output: bool = True,
71
+ ) -> BuildResult:
72
+ """Run tests with coverage.
73
+
74
+ Args:
75
+ go_executable: Path to Go executable
76
+ source_dir: Source directory
77
+ output_file: Output file for coverage report
78
+ env: Environment variables
79
+ stream_output: Whether to stream output
80
+
81
+ Returns:
82
+ BuildResult with success status
83
+ """
84
+ test_args = [
85
+ go_executable, "test",
86
+ "-v",
87
+ "-coverprofile", str(output_file),
88
+ "./...",
89
+ ]
90
+
91
+ return GoTester._run_test(test_args, source_dir, env, stream_output)
92
+
93
+ @staticmethod
94
+ def _run_test(
95
+ command: list[str],
96
+ source_dir: Path,
97
+ environment: dict[str, str],
98
+ stream_output: bool,
99
+ ) -> BuildResult:
100
+ """Execute test command."""
101
+ start_time = time.perf_counter()
102
+
103
+ try:
104
+ if stream_output:
105
+ return GoTester._stream_test(
106
+ command, source_dir, environment, start_time
107
+ )
108
+ else:
109
+ return GoTester._capture_test(
110
+ command, source_dir, environment, start_time
111
+ )
112
+ except Exception as e:
113
+ duration = time.perf_counter() - start_time
114
+ return BuildResult(
115
+ success=False,
116
+ return_code=-2,
117
+ stdout="",
118
+ stderr=f"Test error: {str(e)}",
119
+ output_path=None,
120
+ duration_seconds=duration,
121
+ )
122
+
123
+ @staticmethod
124
+ def _stream_test(
125
+ command: list[str],
126
+ source_dir: Path,
127
+ environment: dict[str, str],
128
+ start_time: float,
129
+ ) -> BuildResult:
130
+ """Execute tests with streaming output."""
131
+ import os
132
+ import sys
133
+
134
+ stdout_buffer = []
135
+ stderr_buffer = []
136
+
137
+ process = subprocess.Popen(
138
+ command,
139
+ cwd=source_dir,
140
+ stdout=subprocess.PIPE,
141
+ stderr=subprocess.PIPE,
142
+ text=True,
143
+ env={**os.environ, **environment},
144
+ )
145
+
146
+ # Read stdout
147
+ if process.stdout:
148
+ for line in process.stdout:
149
+ line = line.rstrip('\n\r')
150
+ stdout_buffer.append(line)
151
+ print(line)
152
+ sys.stdout.flush()
153
+
154
+ # Read stderr
155
+ if process.stderr:
156
+ for line in process.stderr:
157
+ line = line.rstrip('\n\r')
158
+ stderr_buffer.append(line)
159
+ print(line, file=sys.stderr)
160
+ sys.stderr.flush()
161
+
162
+ return_code = process.wait()
163
+ duration = time.perf_counter() - start_time
164
+
165
+ return BuildResult(
166
+ success=return_code == 0,
167
+ return_code=return_code,
168
+ stdout='\n'.join(stdout_buffer),
169
+ stderr='\n'.join(stderr_buffer),
170
+ output_path=source_dir,
171
+ duration_seconds=duration,
172
+ )
173
+
174
+ @staticmethod
175
+ def _capture_test(
176
+ command: list[str],
177
+ source_dir: Path,
178
+ environment: dict[str, str],
179
+ start_time: float,
180
+ ) -> BuildResult:
181
+ """Execute tests with captured output."""
182
+ result = subprocess.run(
183
+ command,
184
+ cwd=source_dir,
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=3600,
188
+ env={**os.environ, **environment},
189
+ )
190
+
191
+ duration = time.perf_counter() - start_time
192
+
193
+ return BuildResult(
194
+ success=result.returncode == 0,
195
+ return_code=result.returncode,
196
+ stdout=result.stdout,
197
+ stderr=result.stderr,
198
+ output_path=source_dir,
199
+ duration_seconds=duration,
200
+ )