From 87e39ce6b9feda95687d5a573ea91d88eb1cdd46 Mon Sep 17 00:00:00 2001 From: greenthree <1395214327@qq.com> Date: Sun, 2 Aug 2026 14:48:06 +0800 Subject: [PATCH] Align system Python installation --- .github/workflows/ci.yml | 40 ++++++++++++ CHANGELOG.md | 2 + README.md | 26 ++++++-- SKILL.md | 2 + bin/python.js | 9 ++- compat/probhub-skill/README.md | 38 +++++++---- probhub/build_lock.py | 6 +- probhub/install_deps.py | 90 +++++++++++++++++++------- references/installation.md | 55 ++++++++++++++++ scripts/check_clean_install.py | 113 +++++++++++++++++++++++++++++---- scripts/check_release.py | 1 + tests/test_batch_build.py | 14 +++- tests/test_npm_packages.py | 113 ++++++++++++++++++++++++++++++++- 13 files changed, 448 insertions(+), 61 deletions(-) create mode 100644 references/installation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84d7720..f6eb1e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,46 @@ jobs: - name: Run clean npm and Python delivery flow run: python scripts/check_clean_install.py --json + ubuntu-distro-python-install: + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install Ubuntu pip package + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + - name: Install with externally managed distro Python + shell: bash + run: | + set -euo pipefail + compgen -G '/usr/lib/python3*/EXTERNALLY-MANAGED' >/dev/null + project="$RUNNER_TEMP/probhub-distro-project" + home="$RUNNER_TEMP/probhub-distro-home" + user_base="$RUNNER_TEMP/probhub-distro-user-base" + mkdir -p "$project" "$home" "$user_base" + cd "$project" + env \ + HOME="$home" \ + PYTHON=/usr/bin/python3 \ + PYTHONUSERBASE="$user_base" \ + PROBHUB_ALLOW_SYSTEM_PYTHON=1 \ + node "$GITHUB_WORKSPACE/bin/init.js" --local + env \ + HOME="$home" \ + PYTHON=/usr/bin/python3 \ + PYTHONUSERBASE="$user_base" \ + node "$GITHUB_WORKSPACE/bin/probhub.js" --version + env PYTHONUSERBASE="$user_base" /usr/bin/python3 -c \ + 'import flask, pypdf, yaml; print(flask.__name__, pypdf.__version__)' + test -f "$project/.agents/skills/probhub/.probhub-version.json" + test -n "$(find "$user_base" -type f -print -quit)" + python-dependency-audit: strategy: fail-fast: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a9e2c..9da49d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ## [Unreleased] +- 主包 README、兼容包 README、Agent Skill 与 Release 安装说明统一为 Node.js 18+ / Python 3.10+ 的系统 Python 显式授权流程;非虚拟环境依赖只写入用户目录并兼容 Ubuntu PEP 668,pip 子进程清除 Python 环境污染;临时 `npx`、Doctor 修复和 WebUI 检查不再遗漏 `PROBHUB_ALLOW_SYSTEM_PYTHON=1`,安装器报错不再引导用户创建虚拟环境。 +- 空工作区锁文件只在取得 OS 文件锁后初始化,消除 Windows 并发 generation 首次启动时的写入、刷新与关闭竞态。 - Build Manifest 升至 schema v4、试卷 generation 升至 schema v3,统一记录 ProbHub/Core、Typst、pypdf、模板与固定字体的 `builder_fingerprint`;`status` 提供字段级 stale 原因,旧 schema 和不可探测工具链不再误报 `current`,build/seal/generation 在发布前以 `builder_changed` 阻断身份漂移。Noto Sans CJK SC 与许可证改为随 npm 包发布,Typst 正式编译只使用校验后的包内字体。 - Python 运行时升级并锁定 Flask 3.1.3 与 pypdf 6.14.2;Windows/Ubuntu CI 使用固定 pip-audit 审计完整依赖闭包,临时例外必须绑定包名、原因、到期日和追踪链接。PDF 页数读取、文本边界扫描与切页迁入受 timeout、内存、输出和进程数限制的独立 worker,损坏或异常 PDF 不再无界占用构建进程或 WebUI 请求线程。 - 非交互 stdout/stderr 及 Checker feedback 改为原子共享单一输出预算,完成、超时、取消和资源超限均在进程树终止后执行确定性公平前缀截断;无法测量或截断时 fail closed,沙箱缓存 Schema 升至 6。stress schema 2 反例以 `E + min(E, 8 MiB)` 限制单次持久化,消除 `generator.out` 输入副本,记录逐文件预算与截断证据,并拒绝重放不完整的 Generator OLE 输入。 diff --git a/README.md b/README.md index 88fb19c..039b3ea 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ProbHub 会在这条流程中提供: ### 1. 安装 Skill -安装前请确认已安装 Node.js 18 或更高版本(包含 npm),以及 Python 3.10 或更高版本。安装 Skill 时会把 Flask、PyYAML 和 pypdf 等依赖安装到当前 `python` 指向的环境;下面的命令显式允许这次安装。 +安装前请确认已安装 Node.js 18 或更高版本(包含 npm),以及 Python 3.10 或更高版本。Ubuntu 的系统 Python 还需安装 `python3-pip`。安装 Skill 时会把 Flask、PyYAML 和 pypdf 等固定版本依赖安装到当前 Python 的用户依赖目录;下面的命令显式允许这次安装。 Windows PowerShell: @@ -53,6 +53,8 @@ probhub doctor `probhub doctor` 会列出 Python、Node.js、npm、`g++`、Typst、字体和 Python 依赖的实际状态。先修复其中的错误,再继续创建题目。 +`PROBHUB_ALLOW_SYSTEM_PYTHON=1` 只表示你同意本次安装写入当前 Python 的用户依赖目录;不会覆盖 Ubuntu 由系统包管理器维护的 Python 包。PowerShell 中的设置只在当前终端会话生效,它也不会关闭 ProbHub 的资源限制。 + `probhub-skill` 还会把 Agent Skill 安装到: ```text @@ -60,7 +62,23 @@ probhub doctor ~/.agents/skills/probhub ``` -只想临时安装 Skill 时可以运行 `npx probhub-skill`;只想安装到当前项目时使用 `npx probhub-skill --local`。 +
+临时运行或只安装到当前项目 + +不全局安装 npm 包时,同样需要显式允许写入当前 Python: + +```powershell +$env:PROBHUB_ALLOW_SYSTEM_PYTHON = "1" +npx probhub-skill +``` + +```bash +PROBHUB_ALLOW_SYSTEM_PYTHON=1 npx probhub-skill +``` + +只安装到当前项目的 Agent Skill 目录时,在命令末尾增加 `--local`。 + +
### 2. 调用 Agent @@ -117,7 +135,7 @@ Ubuntu 可以安装编译器: ```bash sudo apt update -sudo apt install -y g++ +sudo apt install -y g++ python3-pip ``` Typst 请使用上方链接中的 0.14.2 固定版本。Noto Sans CJK SC 已随 npm 主包发布,正式编译会校验字体字节并忽略系统字体,无需单独下载安装。 @@ -267,7 +285,7 @@ npx probhub --version - `g++` 或 Typst 不在 `PATH`; - Typst 不是 0.14.2; - npm 包内的固定字体缺失或校验失败,可重新安装 `probhub`; -- Python 依赖没有安装,可重新运行 `probhub-skill`。 +- Python 依赖没有安装,可按“快速开始”使用带 `PROBHUB_ALLOW_SYSTEM_PYTHON=1` 的同一条 `probhub-skill` 命令重新安装。 ### WebUI 打不开 diff --git a/SKILL.md b/SKILL.md index 32771d4..4dd3f2e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -43,6 +43,8 @@ description: 当用户需要创作或维护算法竞赛题目、选择快速/普 # 3. CLI 操作规则 +安装、升级、排查 `doctor` / WebUI 启动问题,或准备 GitHub Release 安装说明时,先读取 `references/installation.md`。不得推荐虚拟环境、遗漏 Node.js/Python 版本要求,或在系统 Python 路径中省略显式允许开关。 + ## 3.1 入口和工作区定位 在工作区根目录或其任意子目录中运行: diff --git a/bin/python.js b/bin/python.js index 31e9c8c..404f9c2 100644 --- a/bin/python.js +++ b/bin/python.js @@ -1,14 +1,19 @@ const { spawnSync } = require('child_process'); const MODULE_BOOTSTRAP = [ - 'import runpy, sys', + 'import runpy, site, sys', 'root = sys.argv.pop(1)', 'module = sys.argv.pop(1)', "stdout = getattr(sys, 'stdout', None)", "stderr = getattr(sys, 'stderr', None)", "getattr(stdout, 'reconfigure', lambda **kwargs: None)(encoding='utf-8', errors='backslashreplace')", "getattr(stderr, 'reconfigure', lambda **kwargs: None)(encoding='utf-8', errors='backslashreplace')", - "sys.path[:] = [root] + [entry for entry in sys.path if entry not in ('', root)]", + "inside_venv = bool(getattr(sys, 'real_prefix', None) or sys.prefix != getattr(sys, 'base_prefix', sys.prefix))", + 'user_sites = [] if inside_venv else site.getusersitepackages()', + 'user_sites = [user_sites] if isinstance(user_sites, str) else list(user_sites)', + "base_paths = [entry for entry in sys.path if entry not in ('', root) and entry not in user_sites]", + "package_index = next((index for index, entry in enumerate(base_paths) if entry.replace('\\\\', '/').lower().endswith(('/site-packages', '/dist-packages'))), len(base_paths))", + "sys.path[:] = [root] + base_paths[:package_index] + [entry for entry in user_sites if entry and entry != root] + base_paths[package_index:]", "runpy.run_module(module, run_name='__main__', alter_sys=True)", ].join('; '); diff --git a/compat/probhub-skill/README.md b/compat/probhub-skill/README.md index 331a0c0..f0c0b1d 100644 --- a/compat/probhub-skill/README.md +++ b/compat/probhub-skill/README.md @@ -1,29 +1,45 @@ # probhub-skill -`probhub-skill` 是 ProbHub 的轻量 npm 入口包: +`probhub-skill` 是 ProbHub 的轻量 npm 入口包。完整实现由同版本的 [`probhub`](https://www.npmjs.com/package/probhub) 主包提供;本包只保留命令转发,不复制 Python Core、WebUI、Skill 或 references。 + +安装前需要 Node.js 18 或更高版本(包含 npm),以及 Python 3.10 或更高版本;Ubuntu 的系统 Python 还需安装 `python3-pip`。 + +Windows PowerShell: + +```powershell +npm install -g probhub +$env:PROBHUB_ALLOW_SYSTEM_PYTHON = "1" +probhub-skill +probhub doctor +``` + +Ubuntu/Linux: ```bash -python3 -m venv ~/.probhub/venv -source ~/.probhub/venv/bin/activate -npx probhub-skill -npx probhub-skill --local +npm install -g probhub +PROBHUB_ALLOW_SYSTEM_PYTHON=1 probhub-skill +probhub doctor ``` -Windows PowerShell 使用 `py -3 -m venv "$HOME\.probhub\venv"`,然后运行 `& "$HOME\.probhub\venv\Scripts\Activate.ps1"`。后续也可把 `PYTHON` 指向该虚拟环境解释器;只有明确接受修改当前系统 Python 时才设置 `PROBHUB_ALLOW_SYSTEM_PYTHON=1`。 +`PROBHUB_ALLOW_SYSTEM_PYTHON=1` 明确授权安装器把固定版本的 Python 依赖安装到当前 Python 的用户依赖目录,不会覆盖 Ubuntu 由系统包管理器维护的 Python 包。PowerShell 中的设置只在当前终端会话生效。需要指定另一套 Python 3.10+ 时,先设置 `PYTHON` 指向该解释器。 -完整实现由同版本的 [`probhub`](https://www.npmjs.com/package/probhub) 主包提供。本包只保留 `probhub-skill` 与 `probhub` 两个命令转发入口,不复制 Python Core、WebUI、Skill 或 references。 +临时运行时使用: -需要持久使用 CLI 时,安装完整主包: +```powershell +$env:PROBHUB_ALLOW_SYSTEM_PYTHON = "1" +npx probhub-skill +``` ```bash -npm install -g probhub -probhub-skill -probhub --version +PROBHUB_ALLOW_SYSTEM_PYTHON=1 npx probhub-skill ``` +只安装到当前项目的 Agent Skill 目录时增加 `--local`。安装后在包含 `.probhub/workspace.yaml` 的目录运行 `probhub --json ui --check` 检查 WebUI,运行 `probhub ui` 启动它。 + ## 维护规则 - 本包版本必须与 `probhub` 主包版本完全一致。 - 必须先发布 `probhub`,确认 npm registry 可安装后,再发布本包。 +- 两个包的同版本均可从 npm 安装后,才能创建对应 GitHub Release。 - 本包的 `dependencies.probhub` 必须锁定精确版本,不能使用 `^` 或 `~`。 - 功能代码只在 `probhub` 主包中维护,本包不得复制实现。 diff --git a/probhub/build_lock.py b/probhub/build_lock.py index 96c4733..c753b0c 100644 --- a/probhub/build_lock.py +++ b/probhub/build_lock.py @@ -106,9 +106,6 @@ def workspace_file_lock( path.parent.mkdir(parents=True, exist_ok=True) stream = _open_lock_stream(path, no_follow=no_follow) try: - if path.stat().st_size == 0: - stream.write(b"\0") - stream.flush() deadline = time.monotonic() + max(0, float(wait_timeout)) while True: try: @@ -127,6 +124,9 @@ def workspace_file_lock( else f"failed to acquire ProbHub workspace lock {path}: {exc}" ) raise ProbHubError(message, code=code) from exc + if path.stat().st_size == 0: + stream.write(b"\0") + stream.flush() try: yield path finally: diff --git a/probhub/install_deps.py b/probhub/install_deps.py index 66ff508..a168cf7 100644 --- a/probhub/install_deps.py +++ b/probhub/install_deps.py @@ -1,6 +1,7 @@ """Bounded Python dependency installation used by the npm Skill entry.""" import os +import shutil import sys import tempfile from pathlib import Path @@ -8,6 +9,14 @@ from .process_control import run_managed_to_files +WINDOWS_NODE_CHILD_LAUNCHER = ( + "const {spawnSync}=require('child_process');" + "const child=spawnSync(process.argv[1],process.argv.slice(2),{stdio:'inherit'});" + "if(child.error){console.error(child.error.message);process.exit(1);}" + "process.exit(child.status===null?1:child.status);" +) + + for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure: @@ -24,13 +33,37 @@ def _inside_virtual_environment(): ) +def _pip_install_command(requirements, *, user_install): + python = os.environ.get("PYTHON") or sys.executable + command = [ + python, + "-m", + "pip", + "install", + "--disable-pip-version-check", + ] + if user_install: + command.append("--user") + command.extend(["-r", str(requirements)]) + if os.name != "nt": + return command + node = shutil.which("node") + if not node: + raise OSError("Node.js >= 18 is required to contain the Windows Python installer") + # Windows Store Python uses an App Execution Alias that cannot itself be + # created suspended. Start a bounded Node supervisor first; its Python and + # pip descendants inherit the already-assigned Job Object. + return [node, "-e", WINDOWS_NODE_CHILD_LAUNCHER, *command] + + def main(): - if not _inside_virtual_environment() and os.environ.get("PROBHUB_ALLOW_SYSTEM_PYTHON") != "1": + user_install = not _inside_virtual_environment() + if user_install and os.environ.get("PROBHUB_ALLOW_SYSTEM_PYTHON") != "1": print( - "ProbHub Skill dependency installation requires an activated Python " - "virtual environment. Activate one or set PYTHON to its interpreter. " - "Set PROBHUB_ALLOW_SYSTEM_PYTHON=1 only when modifying this Python " - "installation is intentional.", + "ProbHub refused to modify the selected Python installation without " + "explicit consent. Set PROBHUB_ALLOW_SYSTEM_PYTHON=1 when installing " + "the pinned dependencies into this Python is intentional, or set " + "PYTHON to another prepared Python 3.10+ interpreter.", file=sys.stderr, ) return 1 @@ -44,27 +77,30 @@ def main(): stdout_path = Path(temp) / "stdout" stderr_path = Path(temp) / "stderr" env = os.environ.copy() + for variable in ("PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP"): + env.pop(variable, None) env["PYTHONIOENCODING"] = "utf-8" env["PYTHONUTF8"] = "1" - result = run_managed_to_files( - [ - sys.executable, - "-m", - "pip", - "install", - "--disable-pip-version-check", - "-r", - str(requirements), - ], - stdout_path=stdout_path, - stderr_path=stderr_path, - timeout=600, - memory_limit_mb=2048, - output_limit_bytes=16 * 1024 * 1024, - process_limit=64, - cwd=package_root, - env=env, - ) + if user_install: + # Distro Python remains externally managed; pinned packages are + # installed only into the selected interpreter's user site. + env["PIP_BREAK_SYSTEM_PACKAGES"] = "1" + try: + command = _pip_install_command(requirements, user_install=user_install) + result = run_managed_to_files( + command, + stdout_path=stdout_path, + stderr_path=stderr_path, + timeout=600, + memory_limit_mb=2048, + output_limit_bytes=16 * 1024 * 1024, + process_limit=64, + cwd=package_root, + env=env, + ) + except OSError as exc: + print(f"ProbHub dependency installation failed: {exc}", file=sys.stderr) + return 1 stdout = stdout_path.read_text(encoding="utf-8", errors="backslashreplace") if stdout_path.is_file() else "" stderr = stderr_path.read_text(encoding="utf-8", errors="backslashreplace") if stderr_path.is_file() else "" if stdout: @@ -72,6 +108,12 @@ def main(): if stderr: print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) if result["reason"] != "completed" or result["returncode"] != 0: + if user_install and "No module named pip" in stderr: + print( + "The selected Python does not provide pip. On Ubuntu, install " + "it with: sudo apt install python3-pip", + file=sys.stderr, + ) print( f"ProbHub dependency installation failed: " f"{result.get('message') or result['reason']}", diff --git a/references/installation.md b/references/installation.md new file mode 100644 index 0000000..906724b --- /dev/null +++ b/references/installation.md @@ -0,0 +1,55 @@ +# 安装与发布说明 + +安装、升级、修复依赖或准备 GitHub Release 时使用本说明。面向用户的主 README、兼容包 README 和 Release 必须保留相同的支持版本与系统 Python 授权语义。 + +## 1. 支持环境 + +- Node.js 18 或更高版本,包含 npm。 +- Python 3.10 或更高版本;Ubuntu 的系统 Python 需要 `python3-pip`。 +- 完整出题还需要支持 C++17 的 `g++` 与 Typst 0.14.2;固定中文字体随主包提供。 + +不要向用户推荐创建虚拟环境。`probhub-skill` 会选择 `PYTHON` 指定的解释器;未设置时选择 PATH 中的 Python 3.10+。当该解释器不是虚拟环境时,安装 Python 依赖必须显式设置 `PROBHUB_ALLOW_SYSTEM_PYTHON=1`,依赖只写入该解释器的用户依赖目录。Ubuntu 仍由系统包管理器维护全局 Python 包;缺少 pip 时先运行 `sudo apt install python3-pip`。 + +该变量仅表示用户同意本次安装向所选 Python 的用户依赖目录写入固定版本依赖。它不关闭沙箱限制,不改变构建身份,也不应被描述为永久系统设置。 + +## 2. 持久安装 + +Windows PowerShell: + +```powershell +npm install -g probhub +$env:PROBHUB_ALLOW_SYSTEM_PYTHON = "1" +probhub-skill +probhub doctor +``` + +Ubuntu/Linux: + +```bash +npm install -g probhub +PROBHUB_ALLOW_SYSTEM_PYTHON=1 probhub-skill +probhub doctor +``` + +`doctor` 必须能够报告 Python、Node.js、npm、`g++`、Typst、固定字体和 Python 依赖。进入 Schema v1 工作区后,再运行 `probhub --json ui --check` 检查已安装 WebUI。 + +## 3. 临时安装 + +Windows PowerShell: + +```powershell +$env:PROBHUB_ALLOW_SYSTEM_PYTHON = "1" +npx probhub-skill +``` + +Ubuntu/Linux: + +```bash +PROBHUB_ALLOW_SYSTEM_PYTHON=1 npx probhub-skill +``` + +只写入当前项目的 `.claude/skills/probhub` 与 `.agents/skills/probhub` 时增加 `--local`。不要给 `npx probhub-skill` 写成缺少允许开关的裸命令。 + +## 4. Release 安装段落 + +GitHub Release 使用第 2 节的双平台命令,不另创安装路径。先发布 `probhub` 主包并确认目标版本可安装,再发布精确依赖同版本主包的 `probhub-skill`;两个包均可解析到该版本后再创建 GitHub Release。 diff --git a/scripts/check_clean_install.py b/scripts/check_clean_install.py index 0c8e571..a59e14a 100644 --- a/scripts/check_clean_install.py +++ b/scripts/check_clean_install.py @@ -19,6 +19,14 @@ ) +WINDOWS_NODE_CHILD_LAUNCHER = ( + "const {spawnSync}=require('child_process');" + "const child=spawnSync(process.argv[1],process.argv.slice(2),{stdio:'inherit'});" + "if(child.error){console.error(child.error.message);process.exit(1);}" + "process.exit(child.status===null?1:child.status);" +) + + class CleanInstallError(RuntimeError): pass @@ -72,6 +80,11 @@ def _venv_python(venv): return Path(venv) / ("Scripts/python.exe" if os.name == "nt" else "bin/python") +def _base_python(): + candidate = Path(getattr(sys, "_base_executable", "") or sys.executable) + return candidate if candidate.is_file() else Path(sys.executable) + + def _sha256(path): digest = hashlib.sha256() with Path(path).open("rb") as stream: @@ -116,9 +129,20 @@ def run_clean_install(): venv = root / "python venv" home = root / "isolated home" compat_home = root / "compat isolated home" + documented_project = root / "documented PowerShell project" + documented_workspace = documented_project / "赛事 工作区" + documented_user_base = root / "documented Python user base" project = root / "含空格的项目" workspace = project / "赛事 工作区" - for directory in (dist, prefix, home, compat_home, project): + for directory in ( + dist, + prefix, + home, + compat_home, + documented_project, + documented_user_base, + project, + ): directory.mkdir(parents=True, exist_ok=True) inventories = validate_pack_inventories(dry_run=False, destination=dist) @@ -140,7 +164,78 @@ def run_clean_install(): if not (installed_main / "bin/python.js").is_file(): raise CleanInstallError("installed probhub package was not the locally packed release candidate") - _run([sys.executable, "-m", "venv", venv], cwd=root, timeout=300) + base_env = os.environ.copy() + base_env.pop("PYTHONPATH", None) + if not base_env.get("TYPST_PACKAGE_CACHE_PATH"): + if os.name == "nt" and base_env.get("LOCALAPPDATA"): + package_cache = Path(base_env["LOCALAPPDATA"]) / "typst/packages" + else: + cache_home = Path(base_env.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) + package_cache = cache_home / "typst/packages" + base_env["TYPST_PACKAGE_CACHE_PATH"] = str(package_cache) + + probhub = _bin_path(prefix, "probhub") + probhub_skill = _bin_path(prefix, "probhub-skill") + base_python = _base_python() + documented_env = base_env.copy() + documented_env.update({ + "PYTHON": str(base_python), + "PYTHONUSERBASE": str(documented_user_base), + "PROBHUB_ALLOW_SYSTEM_PYTHON": "1", + }) + if os.name == "nt": + powershell = shutil.which("pwsh") or shutil.which("powershell") + if not powershell: + raise CleanInstallError("PowerShell is required for the documented Windows install smoke") + quoted_entry = str(probhub_skill).replace("'", "''") + _run( + [ + powershell, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + f"$env:PROBHUB_ALLOW_SYSTEM_PYTHON = '1'; & '{quoted_entry}' --local", + ], + cwd=documented_project, + env=documented_env, + ) + else: + _run([probhub_skill, "--local"], cwd=documented_project, env=documented_env) + for agent_dir in ( + documented_project / ".claude/skills/probhub", + documented_project / ".agents/skills/probhub", + ): + marker = agent_dir / ".probhub-version.json" + if not marker.is_file() or json.loads(marker.read_text(encoding="utf-8"))["version"] != metadata["version"]: + raise CleanInstallError(f"documented Skill installation is incomplete: {agent_dir}") + _run_json([probhub, "--json", "doctor"], cwd=documented_project, env=documented_env) + _run_json( + [ + probhub, + "--json", + "init", + documented_workspace, + "--title", + "Documented Install", + "--subtitle", + "正式赛", + "--author", + "ProbHub CI", + ], + cwd=documented_project, + env=documented_env, + ) + _run_json( + [probhub, "--workspace", documented_workspace, "--json", "ui", "--check"], + cwd=documented_project, + env=documented_env, + ) + + venv_command = [base_python, "-m", "venv", venv] + if os.name == "nt": + venv_command = [node, "-e", WINDOWS_NODE_CHILD_LAUNCHER, *venv_command] + _run(venv_command, cwd=root, timeout=300) python = _venv_python(venv) missing_before = _run( [ @@ -155,15 +250,7 @@ def run_clean_install(): if not all(json.loads(missing_before).values()): raise CleanInstallError("fresh venv unexpectedly contains ProbHub runtime dependencies") - env = os.environ.copy() - env.pop("PYTHONPATH", None) - if not env.get("TYPST_PACKAGE_CACHE_PATH"): - if os.name == "nt" and env.get("LOCALAPPDATA"): - package_cache = Path(env["LOCALAPPDATA"]) / "typst/packages" - else: - cache_home = Path(env.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) - package_cache = cache_home / "typst/packages" - env["TYPST_PACKAGE_CACHE_PATH"] = str(package_cache) + env = base_env.copy() env["PYTHON"] = str(python) env["HOME"] = str(home) env["USERPROFILE"] = str(home) @@ -176,9 +263,6 @@ def run_clean_install(): for shadow_name in ("probhub.py", "runpy.py", "sitecustomize.py"): (project / shadow_name).write_text(shadow_payload, encoding="utf-8") env["PROBHUB_SHADOW_SENTINEL"] = str(shadow_sentinel) - probhub = _bin_path(prefix, "probhub") - _bin_path(prefix, "probhub-skill") - version = _run([probhub, "--version"], cwd=project, env=env).strip() if version != metadata["version"]: raise CleanInstallError(f"installed CLI version mismatch: {version}") @@ -298,6 +382,7 @@ def run_clean_install(): "ok": True, "version": metadata["version"], "packages": inventories, + "documented_install": ["skill-install", "doctor", "ui-check"], "workflow": ["doctor", "init", "ui-check", "new", "gen", "judge", "seal", "build", "status", "verify-package"], "rebuild_equivalent": True, } diff --git a/scripts/check_release.py b/scripts/check_release.py index 4bebebc..2b091df 100644 --- a/scripts/check_release.py +++ b/scripts/check_release.py @@ -199,6 +199,7 @@ def validate_pack_inventories(*, dry_run=True, destination=None): "scripts/webui/vendor/THIRD_PARTY_NOTICES.txt", "references/cli.md", "references/lib.typ", "references/main.typ", "references/problems.typ", "references/usts.png", "references/testlib.h", + "references/installation.md", "references/verification-modes.md", } missing = sorted(required_main - main_paths) diff --git a/tests/test_batch_build.py b/tests/test_batch_build.py index 9870f27..c38ead7 100644 --- a/tests/test_batch_build.py +++ b/tests/test_batch_build.py @@ -6,7 +6,7 @@ from types import SimpleNamespace from unittest.mock import patch -from probhub.build_lock import workspace_build_lock +from probhub.build_lock import workspace_build_lock, workspace_file_lock from probhub.builder_fingerprint import ( GENERATION_SCHEMA_VERSION, compute_typst_template_hash, @@ -1140,6 +1140,18 @@ def test_workspace_build_lock_rejects_a_second_writer_and_releases(self): with workspace_build_lock(root): pass + def test_empty_lock_file_is_initialized_only_after_acquisition(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + lock = root / ".probhub/test.lock" + + def assert_empty_before_acquire(_stream): + self.assertEqual(lock.stat().st_size, 0) + + with patch("probhub.build_lock._acquire", side_effect=assert_empty_before_acquire): + with workspace_file_lock(root, ".probhub/test.lock"): + self.assertEqual(lock.stat().st_size, 1) + def test_windows_locked_artifact_is_reported_before_publish(self): with tempfile.TemporaryDirectory() as temp: root, workspace = self.create_workspace(Path(temp)) diff --git a/tests/test_npm_packages.py b/tests/test_npm_packages.py index fbde652..4d1a2c3 100644 --- a/tests/test_npm_packages.py +++ b/tests/test_npm_packages.py @@ -56,6 +56,35 @@ def test_both_packages_expose_cli_and_skill_installer(self): self.assertIn("require('probhub/bin/init.js')", (ROOT / "compat/probhub-skill/bin/init.js").read_text(encoding="utf-8")) self.assertIn("require('probhub/bin/probhub.js')", (ROOT / "compat/probhub-skill/bin/probhub.js").read_text(encoding="utf-8")) + def test_installation_guides_share_the_supported_system_python_flow(self): + documents = { + "main README": (ROOT / "README.md").read_text(encoding="utf-8"), + "compatibility README": (ROOT / "compat/probhub-skill/README.md").read_text(encoding="utf-8"), + "Agent installation reference": (ROOT / "references/installation.md").read_text(encoding="utf-8"), + } + windows = ( + "npm install -g probhub\n" + "$env:PROBHUB_ALLOW_SYSTEM_PYTHON = \"1\"\n" + "probhub-skill\n" + "probhub doctor" + ) + linux = ( + "npm install -g probhub\n" + "PROBHUB_ALLOW_SYSTEM_PYTHON=1 probhub-skill\n" + "probhub doctor" + ) + for label, content in documents.items(): + with self.subTest(document=label): + self.assertIn("Node.js 18", content) + self.assertIn("Python 3.10", content) + self.assertIn("python3-pip", content) + self.assertIn(windows, content) + self.assertIn(linux, content) + self.assertIn("probhub --json ui --check", content) + self.assertNotIn("python3 -m venv", content) + skill = (ROOT / "SKILL.md").read_text(encoding="utf-8") + self.assertIn("references/installation.md", skill) + def test_release_metadata_gate_passes_for_the_source_tree(self): result = subprocess.run( [sys.executable, str(ROOT / "scripts/check_release.py"), "--json"], @@ -227,7 +256,7 @@ def test_node_entrypoints_cannot_be_shadowed_by_workspace_module(self): ) self.assertEqual(marker["version"], "0.6.2") - def test_dependency_installer_requires_an_explicit_virtual_environment(self): + def test_dependency_installer_requires_explicit_system_python_consent(self): from probhub import install_deps error = io.StringIO() @@ -239,7 +268,87 @@ def test_dependency_installer_requires_an_explicit_virtual_environment(self): os.environ.pop("PROBHUB_ALLOW_SYSTEM_PYTHON", None) code = install_deps.main() self.assertEqual(code, 1) - self.assertIn("virtual environment", error.getvalue()) + self.assertIn("PROBHUB_ALLOW_SYSTEM_PYTHON=1", error.getvalue()) + + def test_dependency_installer_accepts_explicit_system_python_consent(self): + from probhub import install_deps + + completed = {"reason": "completed", "returncode": 0, "message": None} + with ( + patch("probhub.install_deps._inside_virtual_environment", return_value=False), + patch.dict( + os.environ, + { + "PROBHUB_ALLOW_SYSTEM_PYTHON": "1", + "PYTHONHOME": "polluted-home", + "PYTHONPATH": "polluted-path", + "PYTHONSTARTUP": "polluted-startup", + }, + clear=False, + ), + patch("probhub.install_deps.run_managed_to_files", return_value=completed) as run, + ): + code = install_deps.main() + self.assertEqual(code, 0) + command = run.call_args.args[0] + self.assertIn(sys.executable, command) + self.assertIn("pip", command) + self.assertIn("install", command) + self.assertIn("--user", command) + self.assertIn(str(ROOT / "requirements.txt"), command) + child_env = run.call_args.kwargs["env"] + self.assertEqual(child_env["PIP_BREAK_SYSTEM_PACKAGES"], "1") + self.assertNotIn("PYTHONHOME", child_env) + self.assertNotIn("PYTHONPATH", child_env) + self.assertNotIn("PYTHONSTARTUP", child_env) + + def test_dependency_installer_keeps_virtual_environment_local(self): + from probhub import install_deps + + completed = {"reason": "completed", "returncode": 0, "message": None} + with ( + patch("probhub.install_deps._inside_virtual_environment", return_value=True), + patch("probhub.install_deps.run_managed_to_files", return_value=completed) as run, + ): + code = install_deps.main() + self.assertEqual(code, 0) + self.assertNotIn("--user", run.call_args.args[0]) + self.assertNotIn("PIP_BREAK_SYSTEM_PACKAGES", run.call_args.kwargs["env"]) + + def test_dependency_installer_explains_missing_ubuntu_pip(self): + from probhub import install_deps + + def missing_pip(_command, **kwargs): + kwargs["stderr_path"].write_text( + "/usr/bin/python3: No module named pip\n", + encoding="utf-8", + ) + return {"reason": "completed", "returncode": 1, "message": None} + + error = io.StringIO() + with ( + patch("probhub.install_deps._inside_virtual_environment", return_value=False), + patch.dict(os.environ, {"PROBHUB_ALLOW_SYSTEM_PYTHON": "1"}, clear=False), + patch("probhub.install_deps.run_managed_to_files", side_effect=missing_pip), + redirect_stderr(error), + ): + code = install_deps.main() + self.assertEqual(code, 1) + self.assertIn("sudo apt install python3-pip", error.getvalue()) + + def test_windows_dependency_installer_uses_a_bounded_node_supervisor(self): + from probhub import install_deps + + requirements = ROOT / "requirements.txt" + with ( + patch("probhub.install_deps.os.name", "nt"), + patch("probhub.install_deps.shutil.which", return_value="C:/node/node.exe"), + patch.dict(os.environ, {"PYTHON": "C:/python/python.exe"}, clear=False), + ): + command = install_deps._pip_install_command(requirements, user_install=False) + self.assertEqual(command[:2], ["C:/node/node.exe", "-e"]) + self.assertEqual(command[3:7], ["C:/python/python.exe", "-m", "pip", "install"]) + self.assertEqual(command[-1], str(requirements)) @unittest.skipUnless(shutil.which("node"), "node is required") def test_dependency_install_failure_preserves_both_existing_skills(self):