Files
nixevol 98f03b3e0e refactor: 统一运行/编译脚本为 scripts/ 下纯 Python(自动 venv,傻瓜式)
新增 _env.py 共享工具与 dev/run/tauri_dev/build_server/build_tauri/build_docker/clean 脚本,
删除旧 build.{sh,ps1,bat}、根 run.bat/debug.bat/start.sh/dev.py、supervisord.conf、packaging/Dockerfile,
Docker 改以根 Dockerfile 为准并重写 compose(/data 卷)。
2026-06-26 11:03:19 +08:00

343 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CapacityReport 脚本共享工具。
所有 scripts/ 下的运行/编译脚本都只依赖 Python 标准库,可直接用任意系统 Python 运行
(python scripts/xxx.py)。本模块负责:
- 自动创建并维护项目 .venv(使用标准库 venv,不使用 uv),按 requirements.txt 变化自动重装依赖。
- 检测 Node.js / Rust(Cargo) / Docker 环境,缺失时给出安装引导。
- 统一的日志、命令执行、平台判断等助手。
"""
from __future__ import annotations
import hashlib
import os
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
IS_WINDOWS = os.name == "nt"
VENV_DIR = ROOT / ".venv"
VENV_BIN = VENV_DIR / ("Scripts" if IS_WINDOWS else "bin")
VENV_PYTHON = VENV_BIN / ("python.exe" if IS_WINDOWS else "python")
REQUIREMENTS = ROOT / "requirements.txt"
REQ_HASH_FILE = VENV_DIR / ".requirements.hash"
FRONTEND_DIR = ROOT / "frontend"
SRC_TAURI_DIR = ROOT / "src-tauri"
PACKAGING_DIR = ROOT / "packaging"
DIST_DIR = ROOT / "dist"
DESKTOP_API_BASE = "http://127.0.0.1:9081"
SERVER_NAME = "capareport-server"
# --------------------------------------------------------------------------- 日志
def _supports_color() -> bool:
return sys.stdout.isatty() and not IS_WINDOWS
_C = {
"reset": "\033[0m",
"green": "\033[32m",
"cyan": "\033[36m",
"yellow": "\033[33m",
"red": "\033[31m",
}
def setup_console() -> None:
"""让 Windows 控制台也能正常输出 UTF-8,避免中文乱码报错。"""
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def step(message: str) -> None:
color = _C["green"] if _supports_color() else ""
reset = _C["reset"] if _supports_color() else ""
print(f"\n{color}==> {message}{reset}", flush=True)
def info(message: str) -> None:
print(f" {message}", flush=True)
def warn(message: str) -> None:
color = _C["yellow"] if _supports_color() else ""
reset = _C["reset"] if _supports_color() else ""
print(f"{color}[warn] {message}{reset}", flush=True)
def die(message: str, hint: str | None = None) -> "NoReturn": # type: ignore[name-defined]
color = _C["red"] if _supports_color() else ""
reset = _C["reset"] if _supports_color() else ""
print(f"{color}[error] {message}{reset}", file=sys.stderr, flush=True)
if hint:
print(f" {hint}", file=sys.stderr, flush=True)
raise SystemExit(1)
# --------------------------------------------------------------------------- 命令执行
def run(args: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> None:
"""执行命令并实时输出;失败抛 SystemExit。"""
printable = " ".join(str(a) for a in args)
info(printable)
completed = subprocess.run(
[str(a) for a in args],
cwd=str(cwd) if cwd else None,
env=env,
shell=False,
)
if completed.returncode != 0:
die(f"命令执行失败(退出码 {completed.returncode}):{printable}")
def which(name: str) -> str | None:
return shutil.which(name)
def host_platform() -> str:
if IS_WINDOWS:
return "windows"
if sys.platform == "darwin":
return "macos"
return "linux"
def server_exe_name(platform: str | None = None) -> str:
platform = platform or host_platform()
return f"{SERVER_NAME}.exe" if platform == "windows" else SERVER_NAME
# --------------------------------------------------------------------------- Python venv
def _base_python() -> str:
"""返回用于创建 venv 的基础解释器,优先当前运行脚本的解释器。"""
if sys.executable:
return sys.executable
for name in ("python3", "python"):
found = shutil.which(name)
if found:
return found
die(
"未找到可用的 Python 解释器。",
"请安装 Python 3.10+ 并加入 PATH:https://www.python.org/downloads/",
)
def _requirements_hash() -> str:
try:
return hashlib.sha256(REQUIREMENTS.read_bytes()).hexdigest()
except OSError:
return ""
def ensure_venv(extra_packages: list[str] | None = None) -> Path:
"""确保 .venv 存在且依赖最新,返回 venv 内的 python 路径。"""
extra_packages = extra_packages or []
created = False
if not VENV_PYTHON.exists():
base = _base_python()
step(f"创建虚拟环境 .venv({base})")
run([base, "-m", "venv", str(VENV_DIR)])
created = True
current_hash = _requirements_hash()
stored_hash = REQ_HASH_FILE.read_text(encoding="utf-8").strip() if REQ_HASH_FILE.exists() else ""
if created or current_hash != stored_hash:
step("安装/更新 Python 依赖(requirements.txt)")
run([str(VENV_PYTHON), "-m", "pip", "install", "--upgrade", "pip"])
run([str(VENV_PYTHON), "-m", "pip", "install", "-r", str(REQUIREMENTS)], cwd=ROOT)
try:
REQ_HASH_FILE.write_text(current_hash, encoding="utf-8")
except OSError:
pass
for package in extra_packages:
_ensure_python_package(package)
return VENV_PYTHON
def _ensure_python_package(package: str) -> None:
"""确保某个额外包(如 pyinstaller)已安装在 venv 中。"""
name = package.split("==")[0].split(">=")[0].strip()
probe = subprocess.run(
[str(VENV_PYTHON), "-m", "pip", "show", name],
capture_output=True,
)
if probe.returncode == 0:
return
step(f"安装构建依赖 {package}")
run([str(VENV_PYTHON), "-m", "pip", "install", package], cwd=ROOT)
# --------------------------------------------------------------------------- Node.js
def ensure_node() -> str:
"""确保 Node.js / npm 可用,返回 npm 可执行路径。"""
npm = shutil.which("npm")
node = shutil.which("node")
if not npm or not node:
die(
"未检测到 Node.js / npm(构建前端需要)。",
"请安装 Node.js 18+:https://nodejs.org/ ,安装后重新打开终端再运行。",
)
return npm
def ensure_frontend_deps() -> None:
npm = ensure_node()
if (FRONTEND_DIR / "node_modules").exists():
return
step("安装前端依赖(npm install)")
run([npm, "install"], cwd=FRONTEND_DIR)
def build_frontend(api_base: str | None = None) -> None:
npm = ensure_node()
ensure_frontend_deps()
step("构建前端(npm run build)")
env = os.environ.copy()
if api_base:
env["VITE_API_BASE"] = api_base
else:
env.pop("VITE_API_BASE", None)
completed = subprocess.run([npm, "run", "build"], cwd=str(FRONTEND_DIR), env=env)
if completed.returncode != 0:
die("前端构建失败(npm run build)")
# --------------------------------------------------------------------------- Rust / Tauri
def ensure_rust() -> None:
"""确保 Rust 工具链(cargo + rustc)可用(编译 Tauri 桌面端需要)。"""
if shutil.which("cargo") and shutil.which("rustc"):
return
die(
"未检测到 Rust 工具链(cargo / rustc),编译 Tauri 桌面端需要。",
"请安装 Rust:https://www.rust-lang.org/tools/install ,安装后重新打开终端再运行。",
)
def rust_host_triple() -> str:
output = subprocess.run(["rustc", "-vV"], capture_output=True, text=True)
for line in output.stdout.splitlines():
if line.startswith("host:"):
return line.split(":", 1)[1].strip()
die("无法识别 Rust host triple(rustc -vV)")
def ensure_tauri_cli() -> None:
probe = subprocess.run(["cargo", "tauri", "--version"], capture_output=True)
if probe.returncode == 0:
return
step("安装 Tauri CLI(cargo install tauri-cli)")
run(["cargo", "install", "tauri-cli", "--locked"])
# --------------------------------------------------------------------------- PyInstaller
PYINSTALLER_SPEC = PACKAGING_DIR / "capareport-server.spec"
def build_server_binary(python: Path, onefile: bool, work_dir: Path, dist_dir: Path) -> Path:
"""用 PyInstaller 打包后端为可执行文件。
onefile=True 返回单文件可执行文件路径;onefile=False 返回 onedir 目录路径。
"""
remove_path(work_dir)
remove_path(dist_dir)
work_dir.mkdir(parents=True, exist_ok=True)
dist_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
if onefile:
env["CAPAREPORT_ONEFILE"] = "1"
else:
env.pop("CAPAREPORT_ONEFILE", None)
step(f"PyInstaller 打包后端({'onefile' if onefile else 'onedir'})")
run(
[
str(python), "-m", "PyInstaller", "--clean", "--noconfirm",
"--workpath", str(work_dir), "--distpath", str(dist_dir), str(PYINSTALLER_SPEC),
],
cwd=ROOT,
env=env,
)
if onefile:
binary = dist_dir / server_exe_name()
if not binary.exists():
die(f"未找到 PyInstaller 单文件产物:{binary}")
return binary
out_dir = dist_dir / SERVER_NAME
if not out_dir.exists():
die(f"未找到 PyInstaller onedir 产物:{out_dir}")
return out_dir
def build_tauri_sidecar(python: Path, work_dir: Path, dist_dir: Path) -> Path:
"""构建桌面端 sidecar(onefile)并按 Rust host triple 命名复制到 src-tauri/binaries。"""
binary = build_server_binary(python, onefile=True, work_dir=work_dir, dist_dir=dist_dir)
triple = rust_host_triple()
sidecar_dir = SRC_TAURI_DIR / "binaries"
sidecar_dir.mkdir(parents=True, exist_ok=True)
suffix = ".exe" if host_platform() == "windows" else ""
target = sidecar_dir / f"{SERVER_NAME}-{triple}{suffix}"
shutil.copy2(binary, target)
info(f"sidecar -> {target}")
return target
# --------------------------------------------------------------------------- Docker
def ensure_docker() -> None:
if not shutil.which("docker"):
die(
"未检测到 Docker,构建镜像需要。",
"请安装 Docker Desktop / Docker Engine:https://docs.docker.com/get-docker/",
)
# --------------------------------------------------------------------------- 清理助手
def remove_path(path: Path) -> bool:
"""安全删除文件或目录,仅允许删除工作区内路径。"""
try:
resolved = path.resolve()
except OSError:
return False
if ROOT not in resolved.parents and resolved != ROOT:
warn(f"跳过工作区外路径:{resolved}")
return False
if not resolved.exists():
return False
if resolved.is_dir():
shutil.rmtree(resolved, ignore_errors=True)
else:
try:
resolved.unlink()
except OSError:
return False
return True