multi-lang-build 0.2.5__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.
@@ -0,0 +1,481 @@
1
+ """PNPM compiler with mirror acceleration support."""
2
+
3
+ from pathlib import Path
4
+ from typing import Final
5
+ import shutil
6
+ import json
7
+ import os
8
+ import time
9
+
10
+ from multi_lang_build.compiler.base import CompilerBase, BuildResult, CompilerInfo
11
+ from multi_lang_build.mirror.config import get_mirror_config, apply_mirror_environment
12
+
13
+
14
+ class PnpmCompiler(CompilerBase):
15
+ """Compiler for pnpm-based frontend projects."""
16
+
17
+ NAME: Final[str] = "pnpm"
18
+ DEFAULT_MIRROR: Final[str] = "https://registry.npmmirror.com"
19
+
20
+ def __init__(self, pnpm_path: str | None = None) -> None:
21
+ """Initialize the PNPM compiler.
22
+
23
+ Args:
24
+ pnpm_path: Optional path to pnpm executable. If None, uses system PATH.
25
+ """
26
+ self._pnpm_path = pnpm_path
27
+ self._version_cache: str | None = None
28
+
29
+ @property
30
+ def name(self) -> str:
31
+ """Get the compiler name."""
32
+ return self.NAME
33
+
34
+ @property
35
+ def version(self) -> str:
36
+ """Get the pnpm version."""
37
+ if self._version_cache:
38
+ return self._version_cache
39
+
40
+ pnpm_executable = self._get_executable_path()
41
+ import subprocess
42
+
43
+ try:
44
+ result = subprocess.run(
45
+ [pnpm_executable, "--version"],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=10,
49
+ )
50
+ self._version_cache = result.stdout.strip()
51
+ except Exception:
52
+ self._version_cache = "unknown"
53
+
54
+ return self._version_cache
55
+
56
+ @property
57
+ def supported_mirrors(self) -> list[str]:
58
+ """Get list of supported mirror configurations."""
59
+ return ["npm", "pnpm", "yarn"]
60
+
61
+ def get_info(self) -> CompilerInfo:
62
+ """Get compiler information."""
63
+ return {
64
+ "name": self.name,
65
+ "version": self.version,
66
+ "supported_mirrors": self.supported_mirrors,
67
+ "default_mirror": self.DEFAULT_MIRROR,
68
+ "executable": self._get_executable_path(),
69
+ }
70
+
71
+ def find_project_root(self, start_path: Path) -> Path | None:
72
+ """Find the frontend project root directory containing package.json.
73
+
74
+ Searches from start_path up to its ancestors and also checks subdirectories.
75
+ Returns the first directory containing package.json, or None if not found.
76
+
77
+ Args:
78
+ start_path: Starting path to search from
79
+
80
+ Returns:
81
+ Path to project root or None if not found
82
+ """
83
+ start_path = start_path.resolve()
84
+
85
+ # Check if start_path itself is the root
86
+ if (start_path / "package.json").exists():
87
+ return start_path
88
+
89
+ # Search upwards in the directory tree
90
+ current = start_path.parent
91
+ original_cwd = start_path
92
+ while current != current.parent:
93
+ if (current / "package.json").exists():
94
+ return current
95
+ current = current.parent
96
+
97
+ # Search subdirectories
98
+ for subdir in start_path.iterdir():
99
+ if subdir.is_dir() and not subdir.name.startswith("."):
100
+ subdir_root = self.find_project_root(subdir)
101
+ if subdir_root:
102
+ return subdir_root
103
+
104
+ return None
105
+
106
+ def _execute_in_directory(
107
+ self,
108
+ command: list[str],
109
+ working_dir: Path,
110
+ environment: dict[str, str],
111
+ stream_output: bool = True,
112
+ ) -> BuildResult:
113
+ """Execute a command in the specified directory, returning to original cwd afterwards.
114
+
115
+ Args:
116
+ command: Command to execute
117
+ working_dir: Working directory for the command
118
+ environment: Environment variables
119
+ stream_output: Whether to stream output in real-time (default: True)
120
+
121
+ Returns:
122
+ BuildResult containing success status and output information
123
+ """
124
+ import subprocess
125
+ import sys
126
+
127
+ original_cwd = Path.cwd()
128
+ full_command = command.copy()
129
+
130
+ start_time: float = 0.0
131
+
132
+ try:
133
+ os.chdir(working_dir)
134
+
135
+ start_time = time.perf_counter()
136
+
137
+ if stream_output:
138
+ stdout_buffer = []
139
+ stderr_buffer = []
140
+
141
+ process = subprocess.Popen(
142
+ full_command,
143
+ stdout=subprocess.PIPE,
144
+ stderr=subprocess.PIPE,
145
+ text=True,
146
+ env={**os.environ, **environment},
147
+ )
148
+
149
+ stdout_buffer = []
150
+ stderr_buffer = []
151
+
152
+ # Read stdout in real-time
153
+ if process.stdout:
154
+ for line in process.stdout:
155
+ line = line.rstrip('\n\r')
156
+ stdout_buffer.append(line)
157
+ print(line)
158
+ sys.stdout.flush()
159
+
160
+ # Read stderr in real-time
161
+ if process.stderr:
162
+ for line in process.stderr:
163
+ line = line.rstrip('\n\r')
164
+ stderr_buffer.append(line)
165
+ print(line, file=sys.stderr)
166
+ sys.stderr.flush()
167
+
168
+ return_code = process.wait()
169
+ duration = time.perf_counter() - start_time
170
+
171
+ return BuildResult(
172
+ success=return_code == 0,
173
+ return_code=return_code,
174
+ stdout='\n'.join(stdout_buffer),
175
+ stderr='\n'.join(stderr_buffer),
176
+ output_path=working_dir,
177
+ duration_seconds=duration,
178
+ )
179
+ else:
180
+ result = subprocess.run(
181
+ full_command,
182
+ capture_output=True,
183
+ text=True,
184
+ timeout=3600,
185
+ env={**os.environ, **environment},
186
+ )
187
+
188
+ duration = time.perf_counter() - start_time
189
+
190
+ return BuildResult(
191
+ success=result.returncode == 0,
192
+ return_code=result.returncode,
193
+ stdout=result.stdout,
194
+ stderr=result.stderr,
195
+ output_path=working_dir,
196
+ duration_seconds=duration,
197
+ )
198
+
199
+ except subprocess.TimeoutExpired:
200
+ duration = time.perf_counter() - start_time
201
+ return BuildResult(
202
+ success=False,
203
+ return_code=-1,
204
+ stdout="",
205
+ stderr="Build timed out after 1 hour",
206
+ output_path=None,
207
+ duration_seconds=duration,
208
+ )
209
+ except Exception as e:
210
+ duration = time.perf_counter() - start_time
211
+ return BuildResult(
212
+ success=False,
213
+ return_code=-2,
214
+ stdout="",
215
+ stderr=f"Build error: {str(e)}",
216
+ output_path=None,
217
+ duration_seconds=duration,
218
+ )
219
+ finally:
220
+ os.chdir(original_cwd)
221
+
222
+ def build(
223
+ self,
224
+ source_dir: Path,
225
+ output_dir: Path,
226
+ *,
227
+ environment: dict[str, str] | None = None,
228
+ mirror_enabled: bool = True,
229
+ extra_args: list[str] | None = None,
230
+ stream_output: bool = True,
231
+ ) -> BuildResult:
232
+ """Execute the pnpm build process with auto-detection of project root.
233
+
234
+ Args:
235
+ source_dir: Source code directory or subdirectory
236
+ output_dir: Build output directory
237
+ environment: Additional environment variables
238
+ mirror_enabled: Whether to use mirror acceleration
239
+ extra_args: Additional arguments to pass to pnpm build
240
+ stream_output: Whether to stream output in real-time (default: True)
241
+
242
+ Returns:
243
+ BuildResult containing success status and output information.
244
+ """
245
+ pnpm_executable = self._get_executable_path()
246
+
247
+ # Auto-detect project root
248
+ project_root = self.find_project_root(source_dir)
249
+
250
+ if project_root is None:
251
+ return BuildResult(
252
+ success=False,
253
+ return_code=-1,
254
+ stdout="",
255
+ stderr=f"package.json not found in {source_dir} or its parent/sub directories",
256
+ output_path=None,
257
+ duration_seconds=0.0,
258
+ )
259
+
260
+ # Validate directories
261
+ output_dir = self._validate_directory(output_dir, create_if_not_exists=True)
262
+
263
+ # Prepare environment
264
+ env = environment.copy() if environment else {}
265
+
266
+ if mirror_enabled:
267
+ env = apply_mirror_environment("pnpm", env)
268
+ env = apply_mirror_environment("npm", env)
269
+
270
+ # Install dependencies in project root
271
+ install_result = self._execute_in_directory(
272
+ [pnpm_executable, "install"],
273
+ project_root,
274
+ env,
275
+ stream_output=stream_output,
276
+ )
277
+
278
+ if not install_result["success"]:
279
+ return install_result
280
+
281
+ # Run build
282
+ build_args = [pnpm_executable, "build"]
283
+ if extra_args:
284
+ build_args.extend(extra_args)
285
+
286
+ return self._execute_in_directory(
287
+ build_args,
288
+ project_root,
289
+ env,
290
+ stream_output=stream_output,
291
+ )
292
+
293
+ def install_dependencies(
294
+ self,
295
+ source_dir: Path,
296
+ *,
297
+ environment: dict[str, str] | None = None,
298
+ mirror_enabled: bool = True,
299
+ production: bool = False,
300
+ ) -> BuildResult:
301
+ """Install dependencies using pnpm with auto-detection of project root.
302
+
303
+ Args:
304
+ source_dir: Source code directory or subdirectory
305
+ environment: Additional environment variables
306
+ mirror_enabled: Whether to use mirror acceleration
307
+ production: Install only production dependencies
308
+
309
+ Returns:
310
+ BuildResult containing success status and output information.
311
+ """
312
+ pnpm_executable = self._get_executable_path()
313
+
314
+ project_root = self.find_project_root(source_dir)
315
+
316
+ if project_root is None:
317
+ return BuildResult(
318
+ success=False,
319
+ return_code=-1,
320
+ stdout="",
321
+ stderr=f"package.json not found in {source_dir} or its parent/sub directories",
322
+ output_path=None,
323
+ duration_seconds=0.0,
324
+ )
325
+
326
+ env = environment.copy() if environment else {}
327
+
328
+ if mirror_enabled:
329
+ env = apply_mirror_environment("pnpm", env)
330
+
331
+ command = [pnpm_executable, "install"]
332
+ if production:
333
+ command.append("--prod")
334
+
335
+ return self._execute_in_directory(
336
+ command,
337
+ project_root,
338
+ env,
339
+ stream_output=True,
340
+ )
341
+
342
+ def run_script(
343
+ self,
344
+ source_dir: Path,
345
+ script_name: str,
346
+ *,
347
+ environment: dict[str, str] | None = None,
348
+ mirror_enabled: bool = True,
349
+ ) -> BuildResult:
350
+ """Run a specific npm script with auto-detection of project root.
351
+
352
+ Args:
353
+ source_dir: Source code directory or subdirectory
354
+ script_name: Name of the script to run
355
+ environment: Additional environment variables
356
+ mirror_enabled: Whether to use mirror acceleration
357
+
358
+ Returns:
359
+ BuildResult containing success status and output information.
360
+ """
361
+ pnpm_executable = self._get_executable_path()
362
+
363
+ project_root = self.find_project_root(source_dir)
364
+
365
+ if project_root is None:
366
+ return BuildResult(
367
+ success=False,
368
+ return_code=-1,
369
+ stdout="",
370
+ stderr=f"package.json not found in {source_dir} or its parent/sub directories",
371
+ output_path=None,
372
+ duration_seconds=0.0,
373
+ )
374
+
375
+ env = environment.copy() if environment else {}
376
+
377
+ if mirror_enabled:
378
+ env = apply_mirror_environment("pnpm", env)
379
+
380
+ return self._execute_in_directory(
381
+ [pnpm_executable, "run", script_name],
382
+ project_root,
383
+ env,
384
+ )
385
+
386
+ def clean(self, directory: Path) -> bool:
387
+ """Clean pnpm artifacts in the specified directory.
388
+
389
+ Args:
390
+ directory: Directory to clean
391
+
392
+ Returns:
393
+ True if successful, False otherwise.
394
+ """
395
+ import shutil
396
+
397
+ try:
398
+ directory = self._validate_directory(directory, create_if_not_exists=False)
399
+
400
+ # Remove node_modules
401
+ node_modules = directory / "node_modules"
402
+ if node_modules.exists():
403
+ shutil.rmtree(node_modules)
404
+
405
+ # Remove pnpm-lock.yaml
406
+ lock_file = directory / "pnpm-lock.yaml"
407
+ if lock_file.exists():
408
+ lock_file.unlink()
409
+
410
+ # Remove .pnpm-store if exists
411
+ pnpm_store = directory / ".pnpm-store"
412
+ if pnpm_store.exists():
413
+ shutil.rmtree(pnpm_store)
414
+
415
+ return True
416
+
417
+ except Exception:
418
+ return False
419
+
420
+ def _get_executable_path(self) -> str:
421
+ """Get the pnpm executable path."""
422
+ if self._pnpm_path:
423
+ return self._pnpm_path
424
+
425
+ pnpm_path = shutil.which("pnpm")
426
+ if pnpm_path is None:
427
+ raise RuntimeError("pnpm not found in PATH. Please install pnpm or provide pnpm_path.")
428
+
429
+ return pnpm_path
430
+
431
+ @staticmethod
432
+ def create(source_dir: Path, *, mirror_enabled: bool = True) -> "PnpmCompiler":
433
+ """Factory method to create a PnpmCompiler instance.
434
+
435
+ Args:
436
+ source_dir: Source directory for the project
437
+ mirror_enabled: Whether to enable mirror acceleration by default
438
+
439
+ Returns:
440
+ Configured PnpmCompiler instance
441
+ """
442
+ compiler = PnpmCompiler()
443
+ return compiler
444
+
445
+
446
+ def main() -> None:
447
+ """PNPM compiler CLI entry point."""
448
+ import argparse
449
+ import sys
450
+
451
+ parser = argparse.ArgumentParser(description="PNPM Build Compiler")
452
+ parser.add_argument("source_dir", type=Path, help="Source directory")
453
+ parser.add_argument("-o", "--output", type=Path, required=True, help="Output directory")
454
+ parser.add_argument("--mirror", action="store_true", default=True, help="Enable mirror acceleration")
455
+ parser.add_argument("--no-mirror", dest="mirror", action="store_false", help="Disable mirror acceleration")
456
+ parser.add_argument("--script", type=str, help="Run specific npm script instead of build")
457
+ parser.add_argument("--install", action="store_true", help="Install dependencies only")
458
+
459
+ args = parser.parse_args()
460
+
461
+ compiler = PnpmCompiler()
462
+
463
+ if args.script:
464
+ result = compiler.run_script(
465
+ args.source_dir,
466
+ args.script,
467
+ mirror_enabled=args.mirror,
468
+ )
469
+ elif args.install:
470
+ result = compiler.install_dependencies(
471
+ args.source_dir,
472
+ mirror_enabled=args.mirror,
473
+ )
474
+ else:
475
+ result = compiler.build(
476
+ args.source_dir,
477
+ args.output,
478
+ mirror_enabled=args.mirror,
479
+ )
480
+
481
+ sys.exit(0 if result["success"] else 1)