fix: f-string backslash SyntaxError in spinner (Python < 3.12)

This commit is contained in:
2026-04-30 22:32:54 +02:00
parent e7427109a7
commit bb27c931a0
+538 -273
View File
@@ -1,14 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Build script canonico per il plugin ShellExecAsUser. Build script for nsShellExecAsUser plugin - All configurations
Supports multiple build configurations with flexible parameters
CLI standard: vedi `python build_plugin.py --help`
""" """
from __future__ import annotations
import argparse import argparse
import errno import errno
import io import multiprocessing
import os import os
import shutil import shutil
import stat import stat
@@ -16,38 +14,23 @@ import subprocess
import sys import sys
import threading import threading
import time import time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple
# Ensure UTF-8 output on Windows (avoids cp1252 UnicodeEncodeError) # Ensure stdout/stderr handle Unicode on Windows CI (cp1252 default breaks non-ASCII)
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": if sys.stdout.encoding and sys.stdout.encoding.lower() not in ('utf-8', 'utf_8'):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") import io
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
ROOT = Path(__file__).resolve().parent
SRC_DIR = ROOT / "src"
DIST_DIR = ROOT / "dist"
VERSION_FILE = ROOT / "VERSION"
# Mapping CLI config -> (MSBuild Configuration, MSBuild Platform, dist subdir)
CONFIGS = {
"x86-ansi": ("Release", "Win32", "x86-ansi"),
"x86-unicode": ("Release Unicode", "Win32", "x86-unicode"),
"x64-unicode": ("Release Unicode", "x64", "amd64-unicode"),
}
VCXPROJ = SRC_DIR / "ShellExecAsUser.vcxproj"
DLL_NAME = "ShellExecAsUser.dll"
_print_lock = threading.Lock()
# ============================================================================= # ---------------------------------------------------------------------------
# Utilities (copied verbatim from nsInnoUnp/build_plugin.py) # Filesystem helpers
# ============================================================================= # ---------------------------------------------------------------------------
def _on_rmtree_error(func, path, exc_info): def _on_rmtree_error(func, path, exc_info):
"""Error handler for shutil.rmtree to handle read-only files and temporary locks on Windows."""
if func in (os.unlink, os.rmdir) and exc_info[1].errno == errno.EACCES: if func in (os.unlink, os.rmdir) and exc_info[1].errno == errno.EACCES:
try: try:
os.chmod(path, stat.S_IWRITE) os.chmod(path, stat.S_IWRITE)
@@ -68,8 +51,7 @@ def _on_rmtree_error(func, path, exc_info):
raise exc_info[1] raise exc_info[1]
def robust_rmtree(path) -> None: def robust_rmtree(path):
"""Robustly remove a directory tree, handling Windows file locking issues."""
if not os.path.exists(path): if not os.path.exists(path):
return return
try: try:
@@ -86,42 +68,48 @@ def robust_rmtree(path) -> None:
pass pass
# ---------------------------------------------------------------------------
# Colors & Spinner
# ---------------------------------------------------------------------------
class Colors: class Colors:
CYAN = "\033[36m" CYAN = "\033[36m"
GREEN = "\033[32m" GREEN = "\033[32m"
YELLOW = "\033[33m" YELLOW = "\033[33m"
RED = "\033[31m" RED = "\033[31m"
GRAY = "\033[90m" GRAY = "\033[90m"
BLUE = "\033[34m" BLUE = "\033[34m"
MAGENTA = "\033[35m" RESET = "\033[0m"
RESET = "\033[0m" BOLD = "\033[1m"
BOLD = "\033[1m" BRIGHT_GREEN = "\033[92m"
BRIGHT_GREEN = "\033[92m" BRIGHT_CYAN = "\033[96m"
BRIGHT_CYAN = "\033[96m" BRIGHT_WHITE = "\033[97m"
BRIGHT_WHITE = "\033[97m"
BRIGHT_YELLOW = "\033[93m" BRIGHT_YELLOW = "\033[93m"
BRIGHT_MAGENTA = "\033[95m" BRIGHT_RED = "\033[91m"
BRIGHT_RED = "\033[91m"
class Spinner: class Spinner:
"""A colored terminal spinner with elapsed time display.""" def __init__(self, message: str = "Building...", delay: float = 0.1, total: int = 0):
def __init__(self, message: str = "Building...", delay: float = 0.1): self.spinner = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
self.spinner = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'] self.delay = delay
self.delay = delay self.message = message
self.message = message self.running = False
self.running = False self.thread = None
self.thread = None
self.start_time = time.time() self.start_time = time.time()
def update(self, current=None):
pass
def _spin(self): def _spin(self):
idx = 0 idx = 0
_block = '\u28ff'
while self.running: while self.running:
elapsed = time.time() - self.start_time elapsed = time.time() - self.start_time
time_blocks = f"{Colors.YELLOW}{'\u28ff' * int(elapsed // 2)}{Colors.RESET}" n_blocks = int(elapsed // 2)
spin_char = f"{Colors.YELLOW}{self.spinner[idx % len(self.spinner)]}{Colors.RESET}" time_blocks = f"{Colors.YELLOW}{_block * n_blocks}{Colors.RESET}"
msg = f"{Colors.BOLD}{Colors.CYAN}{self.message}{Colors.RESET}" spin_char = f"{Colors.YELLOW}{self.spinner[idx % len(self.spinner)]}{Colors.RESET}"
time_str = f"{Colors.GREEN}{int(elapsed)}s{Colors.RESET}" msg = f"{Colors.BOLD}{Colors.CYAN}{self.message}{Colors.RESET}"
time_str = f"{Colors.GREEN}{int(elapsed)}s{Colors.RESET}"
sys.stdout.write(f"\r{msg} {time_str} {time_blocks}{spin_char} ") sys.stdout.write(f"\r{msg} {time_str} {time_blocks}{spin_char} ")
sys.stdout.flush() sys.stdout.flush()
idx += 1 idx += 1
@@ -132,8 +120,7 @@ class Spinner:
def __enter__(self): def __enter__(self):
if sys.stdout.isatty(): if sys.stdout.isatty():
self.running = True self.running = True
self.start_time = time.time() self.thread = threading.Thread(target=self._spin, daemon=True)
self.thread = threading.Thread(target=self._spin, daemon=True)
self.thread.start() self.thread.start()
return self return self
@@ -144,271 +131,549 @@ class Spinner:
self.thread.join() self.thread.join()
def format_time(seconds: float) -> str: # ---------------------------------------------------------------------------
if seconds < 60: # Build configuration
return f"{seconds:.2f}s" # ---------------------------------------------------------------------------
m = int(seconds // 60)
s = seconds - m * 60 class BuildConfig:
if m < 60: def __init__(self, name: str, config: str, platform: str, dest_dir: str):
return f"{m}m {s:.1f}s" self.name = name
h = m // 60 self.config = config
m = m % 60 self.platform = platform
return f"{h}h {m}m {int(s)}s" self.dest_dir = dest_dir
# ============================================================================= CONFIGS = {
# Build logic 'x86-ansi': BuildConfig('x86-ansi', 'Release', 'Win32', 'x86-ansi'),
# ============================================================================= 'x86-unicode': BuildConfig('x86-unicode', 'Release Unicode', 'Win32', 'x86-unicode'),
'x64-unicode': BuildConfig('x64-unicode', 'Release Unicode', 'x64', 'amd64-unicode'),
}
def read_version() -> str: DLL_NAME = 'ShellExecAsUser.dll'
# utf-8-sig strips BOM (\ufeff) automatically _VSWHERE = Path(r'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe')
return VERSION_FILE.read_text(encoding="utf-8-sig").strip() _VS_RANGES = {'2026': '[18.0,19.0)', '2022': '[17.0,18.0)'}
_VS_TOOLSETS = {'2026': 'v145', '2022': 'v143'}
_VS_VERSION_RANGE = {'2026': '[18.0,19.0)', '2022': '[17.0,18.0)'} # ---------------------------------------------------------------------------
_VS_TOOLSET = {'2026': 'v145', '2022': 'v143'} # MSBuild discovery
_VSWHERE = ( # ---------------------------------------------------------------------------
Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"))
/ "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
)
def _find_msbuild_vswhere(vs_version: str) -> Optional[Tuple[Path, str, str]]:
def find_msbuild(vs_version: str = 'auto') -> tuple[Path, str, str] | None: if not _VSWHERE.exists():
"""Locate MSBuild via vswhere, returning (path, toolset, vs_year) or None.""" return None
if _VSWHERE.exists(): to_try = ['2026', '2022'] if vs_version == 'auto' else [vs_version]
versions_to_try = ['2022', '2026'] if vs_version == 'auto' else [vs_version] for ver in to_try:
for ver in versions_to_try: if ver not in _VS_RANGES:
if ver not in _VS_VERSION_RANGE: continue
continue try:
try: result = subprocess.run(
result = subprocess.run( [str(_VSWHERE), '-version', _VS_RANGES[ver], '-latest',
[str(_VSWHERE), '-version', _VS_VERSION_RANGE[ver], '-latest', '-requires', 'Microsoft.Component.MSBuild',
'-requires', 'Microsoft.Component.MSBuild', '-find', r'MSBuild\**\Bin\MSBuild.exe'],
'-find', r'MSBuild\**\Bin\MSBuild.exe'], capture_output=True, text=True, timeout=15,
capture_output=True, text=True, timeout=15, )
) if result.returncode == 0:
lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()] lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
if lines: if lines:
p = Path(lines[0]) p = Path(lines[0])
if p.exists(): if p.exists():
return p, _VS_TOOLSET[ver], ver return p, _VS_TOOLSETS[ver], ver
except Exception: except Exception:
pass pass
# Fallback to well-known paths
versions = ['2022', '2026'] if vs_version == 'auto' else [vs_version]
for ver in versions:
for ed in ('Community', 'Professional', 'Enterprise', 'BuildTools'):
p = Path(f'C:/Program Files/Microsoft Visual Studio/{ver}/{ed}/MSBuild/Current/Bin/MSBuild.exe')
if p.exists():
return p, _VS_TOOLSET.get(ver, 'v143'), ver
return None return None
def get_plugins_dir() -> Path | None: def find_msbuild(vs_version: str = 'auto') -> Optional[Tuple[Path, str, str]]:
"""Return workspace-level plugins/ dir (3 levels above src/modules/<plugin>/).""" result = _find_msbuild_vswhere(vs_version)
p = ROOT.parent.parent.parent / 'plugins' if result:
return result
# Fallback: well-known paths
to_try = ['2026', '2022'] if vs_version == 'auto' else [vs_version]
for ver in to_try:
for edition in ('Community', 'Professional', 'Enterprise', 'BuildTools'):
p = Path(rf'C:\Program Files\Microsoft Visual Studio\{ver}\{edition}\MSBuild\Current\Bin\MSBuild.exe')
if p.exists():
return p, _VS_TOOLSETS[ver], ver
return None
# ---------------------------------------------------------------------------
# Project paths & version
# ---------------------------------------------------------------------------
def get_project_paths() -> Tuple[Path, Path, Path]:
script_dir = Path(__file__).parent.absolute()
project_dir = script_dir / 'src'
project_file = project_dir / 'ShellExecAsUser.vcxproj'
dist_dir = script_dir / 'dist'
return project_dir, project_file, dist_dir
def get_plugins_dir() -> Optional[Path]:
"""Return workspace-level plugins/ dir (4 levels above src/modules/<plugin>/)."""
p = Path(__file__).parent.parent.parent.parent / 'plugins'
return p if p.is_dir() else None return p if p.is_dir() else None
def clean_build() -> None: def read_version() -> str:
"""Remove dist/ and all MSBuild output directories.""" vf = Path(__file__).parent / 'VERSION'
for d in [ try:
DIST_DIR, return vf.read_text(encoding='utf-8-sig').strip()
SRC_DIR / "Release", except Exception:
SRC_DIR / "Release Unicode", return '0.0.0'
SRC_DIR / "x64",
SRC_DIR / "Debug",
ROOT / "obj",
]:
if d.exists():
robust_rmtree(d)
def build_one(msbuild: Path, msbuild_config: str, platform: str, # ---------------------------------------------------------------------------
jobs: int, verbose: bool, rebuild: bool) -> tuple[bool, float]: # CPU info & build optimizations
"""Build one configuration, returning (success, elapsed_seconds).""" # ---------------------------------------------------------------------------
t0 = time.time()
cmd = [ def get_cpu_info() -> dict:
str(msbuild), str(VCXPROJ), try:
f"/p:Configuration={msbuild_config}", import psutil
f"/p:Platform={platform}", logical = psutil.cpu_count(logical=True)
f"/m:{jobs}", physical = psutil.cpu_count(logical=False)
"/nologo", return {'logical_cores': logical, 'physical_cores': physical,
"/v:" + ("normal" if verbose else "minimal"), 'has_hyperthreading': logical > physical, 'has_psutil': True}
except ImportError:
n = multiprocessing.cpu_count()
return {'logical_cores': n, 'physical_cores': n,
'has_hyperthreading': False, 'has_psutil': False}
def get_optimal_threads() -> int:
info = get_cpu_info()
return info['physical_cores'] if info['has_hyperthreading'] and info['physical_cores'] > 1 else info['logical_cores']
def get_build_optimizations() -> List[str]:
return [
'/p:BuildInParallel=true',
'/p:MultiProcessorCompilation=true',
'/p:PreferredToolArchitecture=x64',
'/p:UseSharedCompilation=true',
'/nodeReuse:true',
'/p:GenerateResourceUsePreserializedResources=true',
] ]
if rebuild:
cmd.append("/t:Rebuild")
result = subprocess.run(cmd, capture_output=not verbose)
return result.returncode == 0, time.time() - t0
def collect_dll(msbuild_config: str, platform: str, dist_subdir: str) -> Path | None: def get_memory_optimizations() -> List[str]:
"""Find built DLL and copy to dist/<dist_subdir>/. Returns dest path or None.""" try:
plat_dir = "" if platform == "Win32" else platform + "/" import psutil
candidates = list(SRC_DIR.glob(f"**/{plat_dir}{msbuild_config}/{DLL_NAME}")) gb = psutil.virtual_memory().total / (1024 ** 3)
candidates += list(ROOT.glob(f"**/{plat_dir}{msbuild_config}/{DLL_NAME}")) if gb >= 16:
if not candidates: return ['/p:DisableFastUpToDateCheck=false', '/p:BuildProjectReferences=true',
return None '/p:UseCommonOutputDirectory=false']
src = candidates[0] elif gb >= 8:
dst_dir = DIST_DIR / dist_subdir return ['/p:DisableFastUpToDateCheck=false']
dst_dir.mkdir(parents=True, exist_ok=True) return []
dst = dst_dir / DLL_NAME except ImportError:
shutil.copy2(src, dst) return ['/p:DisableFastUpToDateCheck=false']
return dst
def copy_to_plugins(dist_subdir: str, plugins_dir: Path) -> Path | None: def print_cpu_info(use_parallel: bool, use_optimizations: bool = True) -> None:
"""Copy DLL from dist/<dist_subdir>/ to plugins/<dist_subdir>/.""" if not use_parallel:
src = DIST_DIR / dist_subdir / DLL_NAME print(f"{Colors.CYAN}Build mode: {Colors.RESET} {Colors.BOLD}Single-threaded{Colors.RESET}")
if use_optimizations:
print(f"{Colors.CYAN}Optimizations: {Colors.RESET} {Colors.GREEN}ENABLED{Colors.RESET} {Colors.GRAY}(memory, caching){Colors.RESET}")
return
info = get_cpu_info()
optimal = get_optimal_threads()
print(f"{Colors.CYAN}Build mode: {Colors.RESET} {Colors.BRIGHT_WHITE}{'Parallel' if use_parallel else 'Sequential'}{Colors.RESET}")
print(f"{Colors.CYAN}Logical cores: {Colors.RESET} {Colors.BRIGHT_WHITE}{info['logical_cores']}{Colors.RESET}")
print(f"{Colors.CYAN}Physical cores: {Colors.RESET} {Colors.BRIGHT_WHITE}{info['physical_cores']}{Colors.RESET}")
if info['has_hyperthreading']:
print(f"{Colors.CYAN}Hyperthreading: {Colors.RESET} {Colors.BRIGHT_GREEN}ENABLED{Colors.RESET}")
print(f"{Colors.CYAN}Optimal threads: {Colors.RESET} {Colors.BRIGHT_WHITE}{optimal}{Colors.RESET} {Colors.GRAY}(using physical cores){Colors.RESET}")
else:
print(f"{Colors.CYAN}Hyperthreading: {Colors.RESET} {Colors.GRAY}NOT AVAILABLE{Colors.RESET}")
print(f"{Colors.CYAN}Optimal threads: {Colors.RESET} {Colors.BRIGHT_WHITE}{optimal}{Colors.RESET}")
print(f"{Colors.CYAN}MSBuild threads: {Colors.RESET} {Colors.BRIGHT_WHITE}{optimal}{Colors.RESET}")
if use_optimizations:
print(f"{Colors.CYAN}Optimizations: {Colors.RESET} {Colors.BRIGHT_GREEN}ENABLED{Colors.RESET} {Colors.GRAY}(parallel, memory, caching){Colors.RESET}")
try:
import psutil
gb = psutil.virtual_memory().total / (1024 ** 3)
print(f"{Colors.CYAN}Available memory: {Colors.RESET} {Colors.BRIGHT_WHITE}{gb:.1f} GB{Colors.RESET}")
except ImportError:
print(f"{Colors.CYAN}Available memory: {Colors.RESET} {Colors.GRAY}Unknown (install psutil){Colors.RESET}")
else:
print(f"{Colors.CYAN}Optimizations: {Colors.RESET} {Colors.BRIGHT_RED}DISABLED{Colors.RESET}")
if not info['has_psutil']:
print(f"{Colors.GRAY}Note: Install 'psutil' for detailed CPU/memory info (pip install psutil){Colors.RESET}")
print()
# ---------------------------------------------------------------------------
# Build helpers
# ---------------------------------------------------------------------------
def format_time(seconds: float) -> str:
if seconds < 60:
return f"{seconds:.2f}s"
elif seconds < 3600:
return f"{int(seconds // 60)}m {seconds % 60:.1f}s"
return f"{int(seconds // 3600)}h {int((seconds % 3600) // 60)}m {seconds % 60:.0f}s"
def build_configuration(
msbuild_path: Path,
project_file: Path,
config: BuildConfig,
platform_toolset: str,
*,
rebuild: bool = True,
verbosity: str = 'quiet',
parallel: bool = True,
optimizations: bool = True,
counter: str = "",
capture_output: bool = False,
) -> Tuple[bool, float, str]:
optimal = get_optimal_threads()
cmd = [
str(msbuild_path),
str(project_file),
f'/t:{"Rebuild" if rebuild else "Build"}',
f'/p:Configuration={config.config}',
f'/p:Platform={config.platform}',
f'/p:OutDir=Build\\{config.name}\\',
f'/p:IntDir=Build\\{config.name}\\obj\\',
'/p:WindowsTargetPlatformVersion=10.0',
f'/p:PlatformToolset={platform_toolset}',
f'/v:{verbosity}',
]
if parallel:
cmd += [f'/maxcpucount:{optimal}', '/p:UseMultiToolTask=true', f'/p:CL_MPCount={optimal}']
cmd += ['/p:Optimization=MaxSpeed', '/p:ExceptionHandling=Sync']
if optimizations:
cmd += get_build_optimizations() + get_memory_optimizations()
if not capture_output:
color = Colors.BRIGHT_YELLOW
print(f"\n{color}{'='*60}{Colors.RESET}")
label = f"[{counter}] " if counter else ""
print(f"{color}Building {Colors.BRIGHT_WHITE}{config.name:15s}{Colors.RESET} {color}{label}({'rebuild' if rebuild else 'incremental'}){Colors.RESET}")
print(f"{color}{'='*60}{Colors.RESET}")
start = time.time()
try:
if capture_output:
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
return result.returncode == 0, time.time() - start, result.stdout + result.stderr
else:
result = subprocess.run(cmd, check=False)
return result.returncode == 0, time.time() - start, ""
except Exception as e:
elapsed = time.time() - start
msg = f"ERROR: Build failed with exception: {e}"
if capture_output:
return False, elapsed, msg
print(msg)
return False, elapsed, ""
def copy_output(project_dir: Path, dist_dir: Path, config: BuildConfig) -> Tuple[bool, int, Optional[Path]]:
output_file = project_dir / 'Build' / config.name / DLL_NAME
if not output_file.exists():
print(f"ERROR: DLL not found: {output_file}")
return False, 0, None
dest = dist_dir / config.dest_dir
dest.mkdir(parents=True, exist_ok=True)
try:
dst = dest / DLL_NAME
shutil.copy2(output_file, dst)
return True, output_file.stat().st_size, dst
except Exception as e:
print(f"ERROR: Failed to copy {config.name}: {e}")
return False, 0, None
def copy_to_plugins(dist_dir: Path, config: BuildConfig, plugins_dir: Path) -> Optional[Path]:
"""Copy DLL from dist/<dest_dir>/ to plugins/<dest_dir>/."""
src = dist_dir / config.dest_dir / DLL_NAME
if not src.exists(): if not src.exists():
return None return None
dst_dir = plugins_dir / dist_subdir dst_dir = plugins_dir / config.dest_dir
dst_dir.mkdir(parents=True, exist_ok=True) dst_dir.mkdir(parents=True, exist_ok=True)
dst = dst_dir / DLL_NAME dst = dst_dir / DLL_NAME
shutil.copy2(src, dst) shutil.copy2(src, dst)
return dst return dst
def build_parallel(msbuild: Path, configs_to_build: list[str], def clean_build_artifacts(project_dir: Path, configs: List[BuildConfig]) -> None:
jobs: int, verbose: bool, rebuild: bool) -> list[tuple[str, bool, float]]: print(f"\n{Colors.CYAN}Cleaning build artifacts...{Colors.RESET}")
"""Build all configs in parallel using ThreadPoolExecutor.""" build_base = project_dir / 'Build'
results: list[tuple[str, bool, float]] = [] if not build_base.exists():
return
for cfg in configs:
d = build_base / cfg.name
if d.exists():
try:
robust_rmtree(d)
print(f" {Colors.GRAY}- Cleaned:{Colors.RESET} {cfg.name}")
except Exception as e:
print(f" {Colors.RED}- Failed to clean {cfg.name}: {e}{Colors.RESET}")
try:
if build_base.exists() and not any(build_base.iterdir()):
build_base.rmdir()
except Exception:
pass
print(f"{Colors.BRIGHT_GREEN}Build artifacts cleaned successfully.{Colors.RESET}")
def print_available_configurations(project_file: Path) -> None:
try:
tree = ET.parse(project_file)
root = tree.getroot()
ns = {'ms': 'http://schemas.microsoft.com/developer/msbuild/2003'}
configs = []
for ig in root.findall('.//ms:ItemGroup', ns):
for pc in ig.findall('ms:ProjectConfiguration', ns):
inc = pc.get('Include', '')
parts = inc.split('|')
if len(parts) == 2:
configs.append(tuple(parts))
print("Available configurations in project:")
print("-" * 60)
from collections import defaultdict
grouped: dict = defaultdict(list)
for cfg, plat in configs:
grouped[cfg].append(plat)
for cfg_name in sorted(grouped):
print(f" {cfg_name:25s} - {', '.join(sorted(grouped[cfg_name]))}")
print(f"\nTotal: {len(configs)} configuration(s)\n")
except Exception as e:
print(f"Could not parse project file: {e}")
_print_lock = threading.Lock()
def _build_configs_parallel(
msbuild_path: Path,
project_file: Path,
configs: List[BuildConfig],
platform_toolset: str,
*,
rebuild: bool,
verbosity: str,
parallel: bool,
optimizations: bool,
project_dir: Path,
dist_dir: Path,
) -> list:
n = len(configs)
print(f"\nParallel: launching {n} builds simultaneously...")
print("=" * 50)
total_start = time.time()
results_by_idx: dict = {}
idx_lock = threading.Lock()
def _build_one(idx: int, config: BuildConfig):
import contextlib
import io as _io
success, build_time, captured = build_configuration(
msbuild_path, project_file, config, platform_toolset,
rebuild=rebuild, verbosity=verbosity,
parallel=parallel, optimizations=optimizations,
capture_output=True,
)
copy_buf = _io.StringIO()
if success:
with contextlib.redirect_stdout(copy_buf):
copy_ok, file_size, dest_path = copy_output(project_dir, dist_dir, config)
else:
copy_ok, file_size, dest_path = False, 0, None
def _run(cfg_name: str) -> tuple[str, bool, float]:
msbuild_config, platform, _ = CONFIGS[cfg_name]
ok, elapsed = build_one(msbuild, msbuild_config, platform, jobs, verbose, rebuild)
with _print_lock: with _print_lock:
icon = f"{Colors.BRIGHT_GREEN}\u2713{Colors.RESET}" if ok else f"{Colors.RED}\u2717{Colors.RESET}" sys.stdout.write("\r" + " " * 80 + "\r")
print(f" {icon} {cfg_name:<18} {format_time(elapsed)}") sys.stdout.flush()
return cfg_name, ok, elapsed tag = "OK" if (success and copy_ok) else "FAILED"
size_str = f"{file_size:,} bytes" if file_size > 0 else "N/A"
copy_out = copy_buf.getvalue()
if copy_out.strip():
print(copy_out.rstrip())
if captured.strip():
lines = [ln for ln in captured.splitlines() if "MSBuild" not in ln and "Copyright" not in ln]
filtered = "\n".join(lines).strip()
if filtered:
print(filtered)
color = Colors.BRIGHT_GREEN if (success and copy_ok) else Colors.RED
print(f"{color}[{tag}]{Colors.RESET} {config.name} ({format_time(build_time)}) {size_str}")
if dest_path:
print(f" -> {dest_path}")
with ThreadPoolExecutor(max_workers=len(configs_to_build)) as executor: with idx_lock:
futures = {executor.submit(_run, cfg): cfg for cfg in configs_to_build} results_by_idx[idx] = (config, success and copy_ok, build_time, file_size, dest_path)
with Spinner(f"Building {len(configs_to_build)} config(s) in parallel..."):
for future in as_completed(futures):
results.append(future.result())
return results
with Spinner("Building configurations...", total=n) as s:
with ThreadPoolExecutor(max_workers=n) as executor:
futures = {executor.submit(_build_one, i, cfg): i for i, cfg in enumerate(configs)}
for fut in as_completed(futures):
s.update()
exc = fut.exception()
if exc:
idx = futures[fut]
with _print_lock:
print(f"ERROR: worker for config index {idx} raised: {exc}")
with idx_lock:
results_by_idx[idx] = (configs[idx], False, 0.0, 0, None)
wall = time.time() - total_start
print(f"\nAll {n} configs finished in {format_time(wall)} (wall clock)")
return [results_by_idx[i] for i in range(n)]
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description="Build ShellExecAsUser plugin") parser = argparse.ArgumentParser(description='Build nsShellExecAsUser plugin for NSIS')
parser.add_argument("--config", default="all", parser.add_argument('--configs', nargs='+',
choices=list(CONFIGS.keys()) + ["all"]) choices=list(CONFIGS.keys()) + ['all'], default=None)
parser.add_argument("--toolset", default="auto", choices=["2022", "2026", "auto"]) parser.add_argument('--config', # singular alias used by CI
parser.add_argument("--jobs", type=int, default=os.cpu_count() or 4) choices=list(CONFIGS.keys()) + ['all'], default=None)
parser.add_argument("--rebuild", action="store_true", default=True) parser.add_argument('--rebuild', action='store_true', default=True)
parser.add_argument("--no-rebuild", action="store_false", dest="rebuild") parser.add_argument('--no-rebuild', action='store_false', dest='rebuild')
parser.add_argument("--clean", action="store_true", default=True) parser.add_argument('--parallel', action='store_true', default=True)
parser.add_argument("--no-clean", action="store_false", dest="clean") parser.add_argument('--no-parallel', action='store_false', dest='parallel')
parser.add_argument("--parallel", action="store_true", default=True) parser.add_argument('--verbosity',
parser.add_argument("--no-parallel", action="store_false", dest="parallel") choices=['quiet', 'minimal', 'normal', 'detailed', 'diagnostic'],
parser.add_argument("--final", action="store_true", default=False, default='quiet')
help="Force rebuild+clean, disable incremental build") parser.add_argument('--clean', action='store_true', default=False)
parser.add_argument("--verbose", action="store_true") parser.add_argument('--list', action='store_true')
parser.add_argument("--version", action="store_true", parser.add_argument('--list-project', action='store_true')
help="Mostra versione e termina") parser.add_argument('--no-optimizations', action='store_true')
parser.add_argument('--vs-version', choices=['auto', '2026', '2022'], default='auto')
parser.add_argument('--final', action='store_true', default=False,
help='Force rebuild + clean (pre-release build)')
parser.add_argument('--install-dir', type=Path, default=None,
help='Copy built DLLs to this directory (in <config>/ subdirs)')
args = parser.parse_args() args = parser.parse_args()
if args.version: # Merge --config (singular) into --configs list
print(read_version()) if args.config and not args.configs:
return 0 args.configs = [args.config]
if args.final: if args.final:
args.rebuild = True args.rebuild = True
args.clean = True args.clean = True
if not VCXPROJ.exists(): project_dir, project_file, dist_dir = get_project_paths()
print(f"{Colors.RED}ERROR: {VCXPROJ} not found{Colors.RESET}", file=sys.stderr)
if args.list_project:
print_available_configurations(project_file)
return 0
if args.list:
for name, config in CONFIGS.items():
print(f" {name:15s} -> {config.config} / {config.platform}")
return 0
if not project_file.exists():
print(f"ERROR: project file not found: {project_file}", file=sys.stderr)
return 3
msbuild_result = find_msbuild(args.vs_version)
if not msbuild_result:
print("ERROR: MSBuild not found. Install Visual Studio 2022 or 2026.", file=sys.stderr)
return 2 return 2
msbuild_path, platform_toolset, vs_version_name = msbuild_result
ver = read_version() version = read_version()
print(f"{Colors.BOLD}{Colors.BRIGHT_CYAN}=== Building ShellExecAsUser v{ver} ==={Colors.RESET}")
result = find_msbuild(args.toolset) if not args.configs:
if result is None: configs_to_build = list(CONFIGS.values())
print(f"{Colors.RED}ERROR: MSBuild not found{Colors.RESET}", file=sys.stderr) elif 'all' in args.configs:
return 2 configs_to_build = list(CONFIGS.values())
msbuild, toolset, vs_year = result else:
print(f"{Colors.GRAY}[info]{Colors.RESET} MSBuild: {msbuild}") configs_to_build = [CONFIGS[n] for n in args.configs]
print(f"{Colors.GRAY}[info]{Colors.RESET} Toolset: {toolset} (VS {vs_year})")
print(f"{Colors.GRAY}[info]{Colors.RESET} CPUs: {args.jobs}")
print("=" * 50)
if args.clean: # Banner
clean_build() print(f"{Colors.BOLD}{Colors.BRIGHT_CYAN}{'='*60}")
print(f"Building nsShellExecAsUser plugin v{version} - {Colors.BRIGHT_WHITE}{len(configs_to_build)}{Colors.RESET} {Colors.BOLD}{Colors.BRIGHT_CYAN}configuration(s)")
print(f"{'='*60}{Colors.RESET}")
print(f"{Colors.CYAN}VS: {Colors.RESET} {Colors.BRIGHT_WHITE}{vs_version_name}{Colors.RESET} {Colors.GRAY}({platform_toolset}){Colors.RESET}")
print(f"{Colors.CYAN}MSBuild: {Colors.RESET} {Colors.BRIGHT_WHITE}{msbuild_path}{Colors.RESET}")
print(f"{Colors.CYAN}Project: {Colors.RESET} {Colors.BRIGHT_WHITE}{project_file}{Colors.RESET}")
print(f"{Colors.CYAN}Dist: {Colors.RESET} {Colors.BRIGHT_WHITE}{dist_dir}{Colors.RESET}")
print(f"{Colors.CYAN}Rebuild: {Colors.RESET} {Colors.BRIGHT_WHITE}{args.rebuild}{Colors.RESET}")
print(f"{Colors.CYAN}Verbosity: {Colors.RESET} {Colors.BRIGHT_WHITE}{args.verbosity}{Colors.RESET}")
print()
configs_to_build = list(CONFIGS.keys()) if args.config == "all" else [args.config] use_optimizations = not args.no_optimizations
wall_t0 = time.time() print_cpu_info(args.parallel, use_optimizations)
build_results = []
total_start = time.time()
if args.parallel and len(configs_to_build) > 1: if args.parallel and len(configs_to_build) > 1:
build_results = build_parallel(msbuild, configs_to_build, args.jobs, args.verbose, args.rebuild) build_results = _build_configs_parallel(
msbuild_path, project_file, configs_to_build, platform_toolset,
rebuild=args.rebuild, verbosity=args.verbosity,
parallel=True, optimizations=use_optimizations,
project_dir=project_dir, dist_dir=dist_dir,
)
else: else:
build_results = [] for i, config in enumerate(configs_to_build, 1):
for cfg_name in configs_to_build: success, b_time, _ = build_configuration(
msbuild_config, platform, _ = CONFIGS[cfg_name] msbuild_path, project_file, config, platform_toolset,
with Spinner(f"Building {cfg_name}..."): rebuild=args.rebuild, verbosity=args.verbosity,
ok, elapsed = build_one(msbuild, msbuild_config, platform, parallel=args.parallel, optimizations=use_optimizations,
args.jobs, args.verbose, args.rebuild) counter=f"{i}/{len(configs_to_build)}",
icon = f"{Colors.BRIGHT_GREEN}\u2713{Colors.RESET}" if ok else f"{Colors.RED}\u2717{Colors.RESET}" )
print(f" {icon} {cfg_name:<18} {format_time(elapsed)}") if success:
build_results.append((cfg_name, ok, elapsed)) ok, size, path = copy_output(project_dir, dist_dir, config)
build_results.append((config, ok, b_time, size, path))
wall_elapsed = time.time() - wall_t0
print(f"\nAll {len(configs_to_build)} config(s) finished in {format_time(wall_elapsed)} (wall clock)")
print("=" * 50)
plugins_dir = get_plugins_dir()
all_ok = True
plugins_copied: list[str] = []
for cfg_name, ok, _ in build_results:
msbuild_config, platform, dist_subdir = CONFIGS[cfg_name]
if ok:
dst = collect_dll(msbuild_config, platform, dist_subdir)
if dst:
print(f" {Colors.GRAY}-> dist/{dist_subdir}/{DLL_NAME}{Colors.RESET}")
if plugins_dir:
copy_to_plugins(dist_subdir, plugins_dir)
plugins_copied.append(dist_subdir)
else: else:
print(f" {Colors.YELLOW}! {cfg_name}: DLL not found after build{Colors.RESET}") build_results.append((config, False, b_time, 0, None))
all_ok = False
else:
all_ok = False
if plugins_copied: total_time = time.time() - total_start
print(f" {Colors.GRAY}-> plugins/{{{', '.join(plugins_copied)}}}/{Colors.RESET}") all_success = all(res[1] for res in build_results)
return 0 if all_ok else 1 print(f"\n{Colors.BOLD}{Colors.BRIGHT_GREEN if all_success else Colors.RED}{'='*50}")
print("ALL BUILDS SUCCESSFUL!" if all_success else "SOME BUILDS FAILED!")
print(f"{'='*50}{Colors.RESET}")
# Copy to workspace plugins/ dir if present
plugins_dir = get_plugins_dir()
plugins_copied = []
if plugins_dir and all_success:
for cfg, ok, _, _, _ in build_results:
if ok:
dst = copy_to_plugins(dist_dir, cfg, plugins_dir)
if dst:
plugins_copied.append(str(dst))
if plugins_copied:
print(f"\n{Colors.CYAN}Installed to plugins/:{Colors.RESET}")
for p in plugins_copied:
print(f" {Colors.GRAY}->{Colors.RESET} {p}")
if args.install_dir and all_success:
print(f"\n{Colors.CYAN}Installing to {args.install_dir}...{Colors.RESET}")
for cfg, ok, _, _, path in build_results:
if ok and path:
dest = args.install_dir / cfg.dest_dir
dest.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest / DLL_NAME)
print(f" {Colors.GRAY}- Installed:{Colors.RESET} {dest / DLL_NAME}")
print(f"\n{Colors.BOLD}{Colors.BRIGHT_CYAN}{'-'*50}")
print(f"Build Summary - VS {vs_version_name} ({platform_toolset}):")
for cfg, ok, b_time, size, _ in build_results:
color = Colors.GREEN if ok else Colors.RED
tag = "OK " if ok else "FAIL"
print(f" {color}{tag}{Colors.RESET} {cfg.name:15s} - {format_time(b_time):8s} - {size:11,d} bytes")
print(f"{Colors.BOLD}{Colors.BRIGHT_CYAN}{'-'*50}{Colors.RESET}")
print(f"Total time: {format_time(total_time)}\n")
if args.clean:
clean_build_artifacts(project_dir, configs_to_build)
return 0 if all_success else 1
if __name__ == "__main__": if __name__ == '__main__':
sys.exit(main())
targets = list(CONFIGS.keys()) if args.config == "all" else [args.config]
failed = []
for cfg_name in targets:
config, platform, subdir = CONFIGS[cfg_name]
if not build_one(msbuild, config, platform, args.jobs, args.verbose):
failed.append(cfg_name)
continue
dst = collect_dll(config, platform, subdir)
if dst is None:
print(f"ERROR: DLL not found for {cfg_name}", file=sys.stderr)
failed.append(cfg_name)
else:
print(f"[ok] {dst}")
if args.install_dir:
install_path = args.install_dir / subdir
install_path.mkdir(parents=True, exist_ok=True)
shutil.copy2(dst, install_path / DLL_NAME)
print(f"[install] {install_path / DLL_NAME}")
if failed:
print(f"FAILED: {failed}", file=sys.stderr)
return 1
print("[done] all targets built successfully")
return 0
if __name__ == "__main__":
sys.exit(main()) sys.exit(main())