diff --git a/.changes/animation/perf-12258-pydeps-hook-ready-stamp-a3f19c42.md b/.changes/animation/perf-12258-pydeps-hook-ready-stamp-a3f19c42.md new file mode 100644 index 00000000000..7f2d492db12 --- /dev/null +++ b/.changes/animation/perf-12258-pydeps-hook-ready-stamp-a3f19c42.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The `SessionStart` hook `install-python-deps.sh` starts no process while its ready stamp holds: after a successful install with an interpreter found on PATH, `pydeps.py` writes `/python/.hook-ready`, and the hook skips the interpreter probe, `cygpath` and the load probe until the plugin root, a candidate interpreter's resolution, that interpreter's file, `requirements.txt`, `pydeps.py` or `pydeps.json` changes, or the installed set is gone. `pydeps.py run` drops the stamp when the set is missing or a script exits non-zero, and `check` when a row fails, so the next session start probes and rebuilds ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)). diff --git a/.changes/explainer-video/perf-12258-pydeps-hook-ready-stamp-c94e2a61.md b/.changes/explainer-video/perf-12258-pydeps-hook-ready-stamp-c94e2a61.md new file mode 100644 index 00000000000..68e356cb309 --- /dev/null +++ b/.changes/explainer-video/perf-12258-pydeps-hook-ready-stamp-c94e2a61.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The `SessionStart` hook `install-python-deps.sh` starts no process while its ready stamp holds: after a successful install with an interpreter found on PATH, `pydeps.py` writes `/python/.hook-ready`, and the hook skips the interpreter probe, `cygpath` and the ManimCE load probe until the plugin root, a candidate interpreter's resolution, that interpreter's file, `requirements.txt`, `pydeps.py` or `pydeps.json` changes, or the installed set is gone. `pydeps.py run` drops the stamp when the set is missing or a script exits non-zero, and `check` when a row fails, so the next session start probes and rebuilds ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)). diff --git a/.changes/speech/perf-12258-pydeps-hook-ready-stamp-5be07d18.md b/.changes/speech/perf-12258-pydeps-hook-ready-stamp-5be07d18.md new file mode 100644 index 00000000000..8ad8e15ce2d --- /dev/null +++ b/.changes/speech/perf-12258-pydeps-hook-ready-stamp-5be07d18.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Changed + +- The `SessionStart` hook `install-python-deps.sh` starts no process while its ready stamp holds: after a successful install with an interpreter found on PATH, `pydeps.py` writes `/python/.hook-ready`, and the hook skips the interpreter probe, `cygpath` and the numpy and onnxruntime load probe until the plugin root, a candidate interpreter's resolution, that interpreter's file, `requirements.txt`, `pydeps.py` or `pydeps.json` changes, or the installed set is gone. `pydeps.py run` drops the stamp when the set is missing or a script exits non-zero, and `check` when a row fails, so the next session start probes and rebuilds ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)). diff --git a/.github/workflows/pr-test-platforms.yml b/.github/workflows/pr-test-platforms.yml index 37cd79ba54e..851a7bf4334 100644 --- a/.github/workflows/pr-test-platforms.yml +++ b/.github/workflows/pr-test-platforms.yml @@ -413,6 +413,19 @@ jobs: - name: Test the developer-experience pointer-line helper on Windows if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/developer-experience/skills/setup/scripts/pointer-line.test.sh') run: bash plugins/developer-experience/skills/setup/scripts/pointer-line.test.sh + # The install-python-deps SessionStart hooks under real Git Bash: a ready stamp + # pydeps.py writes with native Windows paths must let the hook start no Python. + # Only the real Git Bash cases run here; the rest stub interpreters and run on + # Linux. explainer-video's suite skips unless Python 3.12 or 3.13 is on PATH. + - name: Test the animation install-python-deps hook on Windows + if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/animation/hooks/install-python-deps.test.sh') + run: bash plugins/animation/hooks/install-python-deps.test.sh + - name: Test the speech install-python-deps hook on Windows + if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/speech/hooks/install-python-deps.test.sh') + run: bash plugins/speech/hooks/install-python-deps.test.sh + - name: Test the explainer-video install-python-deps hook on Windows + if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/explainer-video/hooks/install-python-deps.test.sh') + run: bash plugins/explainer-video/hooks/install-python-deps.test.sh - name: Log the API budget at the end if: always() diff --git a/docs/contracts/on-demand-dependencies/CHANGELOG.md b/docs/contracts/on-demand-dependencies/CHANGELOG.md index 0ab6ead5bf9..e39a0cab8f2 100644 --- a/docs/contracts/on-demand-dependencies/CHANGELOG.md +++ b/docs/contracts/on-demand-dependencies/CHANGELOG.md @@ -4,6 +4,19 @@ Notable changes to the on-demand dependency contract. The contract is versioned stamp in `README.md` (SemVer). A `[SPEC]` rule that tightens is a major bump; a new rule, exception or adopter is a minor bump; wording is a patch. +## [2.4.0] - 2026-11-02 + +- **Rule P2**: the install hook skips the install and the load probe, starting no process, while + `pydeps.py`'s ready stamp holds. A successful install through an interpreter found on PATH writes + the stamp; another plugin root, a changed candidate resolution, a newer interpreter file, lock, + `pydeps.py` or `pydeps.json`, or a missing installed set invalidates it. Paths are compared as + files, not strings ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)). +- **Rule P3**: the load probe runs on the hook's full path, in the repair install and in `check`. +- **Rule P4**: a stamped set that breaks surfaces at skill time; the launcher and `check` drop the + stamp so the next session start rebuilds, and a failed launcher run with a stamp present prints + the repair line. The two cases that outlive the stamp's checks, a candidate symlink repointed to + an older interpreter file and a version-manager shim whose selected version changed, are stated. + ## [2.3.0] - 2026-10-04 - **Rule P4**: in an interactive session the agent offers to run the repair line and runs it on the diff --git a/docs/contracts/on-demand-dependencies/README.md b/docs/contracts/on-demand-dependencies/README.md index 126903202f7..8f4c2c2e4da 100644 --- a/docs/contracts/on-demand-dependencies/README.md +++ b/docs/contracts/on-demand-dependencies/README.md @@ -1,7 +1,7 @@ # On-demand dependencies: install pinned packages, never vendor them -Version: 2.3.0 -Last updated: 2026-10-04 +Version: 2.4.0 +Last updated: 2026-11-02 A marketplace-wide rule for **third-party packages a plugin needs at run time**: commit the pinned manifest and lockfile, install from them on demand into the plugin's data directory, and fail @@ -121,6 +121,18 @@ and does nothing when the set already loads (Rule P3). pip refuses the install w requirement, transitive ones included, is unpinned or unhashed, or a wheel's hash differs from the lock ([pip secure installs](https://pip.pypa.io/en/stable/topics/secure-installs/)). +**Ready stamp:** while the reference's ready stamp holds, the hook skips the install and the load +probe and starts no process, since starting Python and importing the set take most of the hook's +run time on Windows. After a successful install with an interpreter found through a PATH +candidate, `pydeps.py` writes `/python/.hook-ready` atomically, recording the +plugin root, the installed directory and what each of the hook's candidate names resolves to. An +interpreter only the py launcher listed writes no stamp. The hook checks the stamp with shell +builtins alone, and any of these sends it down the full path: another plugin root, a candidate +name resolving to another file, a resolved interpreter file, `requirements.txt`, `pydeps.py` or +`pydeps.json` newer than the stamp, or the installed directory missing. The root and the +candidates are compared as files (`-ef`), not as path strings, so a symlink, another mount, an 8.3 +short name, another letter case or a dropped `.exe` still matches the same file. + **Nothing else fetches.** No skill body, script or hook other than the install hook runs `uv run`, `uvx`, `pip install` or `--with-requirements`. A skill runs its scripts against the installed set through a launcher that puts the install directory on `PYTHONPATH` and never @@ -148,7 +160,9 @@ or script runs it on its own. distribution and import names differ, `opencv-python-headless` and `cv2`) and imports them with the install as the only third-party path (`PYTHONPATH` set to it, `-S` so no ambient `site-packages`), so an ambient copy never passes for the locked set. The launcher runs scripts - the same way. A directory that is present but no longer loads is rebuilt. + the same way. A directory that is present but no longer loads is rebuilt. The probe runs on the + hook's full path, in the install the repair line runs, and in `check`; while the ready stamp + (Rule P2) holds, the hook trusts the stamp instead. - **Floor:** the component states its Python floor and the hook looks for an interpreter at or above it, so a host with an older Python gets Rule P4's notice, not a failed install. @@ -165,6 +179,17 @@ packages, installs on its own, or reports a partial result as complete. In an in the agent offers to run the repair line and runs it on the user's yes, per [When a check fails](../prerequisites/README.md#when-a-check-fails-offer-the-fix-run-it-on-a-yes). +A stamped set that breaks surfaces when a skill runs, not at session start. The launcher drops the +ready stamp and prints the repair line both for a missing set and when a script exits non-zero +while the stamp is present, since a set that stops importing fails the script; the script's own +exit code stands. `check` drops the stamp when a row fails. Either way the next session start +probes and rebuilds. Two cases outlive the stamp's checks, since the hook cannot learn which +interpreter a name runs without starting it: a candidate symlink repointed to an interpreter file +older than the stamp, and a version-manager shim (pyenv, asdf, mise) whose selected version +changed, which leaves the shim's path and file time as they were. In both, the hook keeps trusting +the stamp until the first skill run, which finds no set for the new interpreter, prints the repair +line and drops the stamp. + ## Exceptions | Component | Rule | Exception and reason | diff --git a/lib/install-python-deps.sh b/lib/install-python-deps.sh index 7a197aa84c0..b2bc8e42c55 100755 --- a/lib/install-python-deps.sh +++ b/lib/install-python-deps.sh @@ -2,7 +2,7 @@ # SessionStart hook: install the plugin's hash-locked Python packages (requirements.txt) into # ${CLAUDE_PLUGIN_DATA} on demand, so its skills run against them and nothing fetches a package # while a skill runs (docs/contracts/on-demand-dependencies, Python). A no-op once the set -# loads. A failed install is a user notice with the repair line, never a silent skip; the model +# loads, and one that starts no process while pydeps.py's ready stamp holds. A failed install is a user notice with the repair line, never a silent skip; the model # hears nothing here, since pydeps.py run prints the same repair line when a skill runs. # Always exits 0: a failed install must not block the session. The plugin's wording (its name, # the Pythons it supports, the skills that need the packages) is in install-python-deps.conf. @@ -50,9 +50,72 @@ launcher_listed() { # such an alias too, and it runs, so it is only asked to list. candidates=(python3.13 python3.12 python3 python) case "${OSTYPE:-}" in -msys* | cygwin*) candidates+=(py) ;; -*) ;; + msys* | cygwin*) candidates+=(py) ;; + *) ;; esac + +# as_posix -> REPLY: a drive-letter path (C:\x or C:/x) in this shell's POSIX form under Git Bash or Cygwin, +# by builtins alone: cygpath would cost a process start. The drive mounts at /c/ or /cygdrive/c/, whichever exists: +# OSTYPE does not tell them apart, since Git Bash can report cygwin. Fails when neither exists. Any other path is +# returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + local drive="${BASH_REMATCH[1],,}" rest="${BASH_REMATCH[2]}" + if [[ -d "/$drive" ]]; then + REPLY="/$drive$rest" + elif [[ -d "/cygdrive/$drive" ]]; then + REPLY="/cygdrive/$drive$rest" + else + return 1 + fi + fi + ;; + *) ;; + esac +} + +# stamp_holds -> 0 when pydeps.py's ready stamp (written after a successful install with an interpreter found on +# PATH) still describes this session: the same plugin root, every candidate name resolving to the same file, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# Files are compared by identity (-ef), not spelling: one file can be named through a symlink, another mount, an 8.3 +# short name, another letter case, or without its .exe. Builtins only (hash fills BASH_CMDS the way command -v looks +# up a name, without a subshell), so a session start whose set is ready starts no process. Anything else falls +# through to the full path, which probes and rebuilds. +stamp_holds() { + ((BASH_VERSINFO[0] >= 4)) || return 1 + local stamp lines name got i=3 f + as_posix "$data" || return 1 + stamp="$REPLY/python/.hook-ready" + [[ -f "$stamp" ]] || return 1 + mapfile -t lines <"$stamp" || return 1 + lines=("${lines[@]%$'\r'}") + ((${#lines[@]} == 3 + ${#candidates[@]})) && [[ "${lines[0]}" == 'pydeps-hook-ready 1' ]] || return 1 + as_posix "${lines[1]}" || return 1 + [[ "$REPLY/scripts/pydeps.py" -ef "$ROOT/scripts/pydeps.py" ]] || return 1 + as_posix "${lines[2]}" || return 1 + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + as_posix "${lines[i]#*=}" || return 1 + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + if [[ -n "$REPLY" || -n "$got" ]]; then + [[ -n "$REPLY" && -n "$got" && "$REPLY" -ef "$got" ]] || return 1 + [[ "$got" -nt "$stamp" ]] && return 1 + fi + i=$((i + 1)) + done + for f in requirements.txt scripts/pydeps.py scripts/pydeps.json; do + [[ "$ROOT/$f" -nt "$stamp" ]] && return 1 + done + return 0 +} +stamp_holds && exit 0 + py="" for candidate in "${candidates[@]}"; do resolved="$(command -v "$candidate" 2>/dev/null)" || continue @@ -76,19 +139,19 @@ fi repair_line() { local parts=("$(command -v "$py")" "$ROOT/scripts/pydeps.py" install --data-dir "$data") line="" part case "${OSTYPE:-}" in - msys* | cygwin*) - for part in "${parts[@]}"; do - [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" - line+=" '${part//\'/\'\'}'" - done - printf '&%s' "$line" - ;; - *) - for part in "${parts[@]}"; do - line+=" '${part//\'/\'\\\'\'}'" - done - printf '%s' "${line# }" - ;; + msys* | cygwin*) + for part in "${parts[@]}"; do + [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" + line+=" '${part//\'/\'\'}'" + done + printf '&%s' "$line" + ;; + *) + for part in "${parts[@]}"; do + line+=" '${part//\'/\'\\\'\'}'" + done + printf '%s' "${line# }" + ;; esac } @@ -98,15 +161,15 @@ repair_line() { # return the unconverted path. to_native() { case "${OSTYPE:-}" in - msys* | cygwin*) - [[ "$1" == /* ]] || { - printf '%s' "$1" - return 0 - } - local converted - converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" - ;; - *) printf '%s' "$1" ;; + msys* | cygwin*) + [[ "$1" == /* ]] || { + printf '%s' "$1" + return 0 + } + local converted + converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" + ;; + *) printf '%s' "$1" ;; esac } diff --git a/lib/pydeps.py b/lib/pydeps.py index 45b5631b388..ed43e18604b 100755 --- a/lib/pydeps.py +++ b/lib/pydeps.py @@ -6,7 +6,10 @@ usage: pydeps.py install [--data-dir DIR] install requirements.txt unless already ready; the SessionStart hook runs this, and so does the repair line pydeps.py run [--data-dir DIR] -- SCRIPT ... run SCRIPT against the installed packages; never installs - pydeps.py check [--data-dir DIR] read-only PASS/FAIL rows: Python, the packages, any tools + pydeps.py check [--data-dir DIR] PASS/FAIL rows: Python, the packages, any tools; installs nothing + +A successful install with an interpreter found on PATH writes /python/.hook-ready, which lets the hook skip +starting Python while nothing it records has changed; run and check drop it when the set fails them. exit codes: install 0 ready or installed, 1 broken (reason and repair line on stderr); run 2 packages not installed (repair line on stderr), else the script's own code; @@ -53,6 +56,9 @@ def _version(text): CHECK_LABEL = _CONFIG.get('check', {}).get('label', 'Python packages') CHECK_TOOLS = dict(_CONFIG.get('check', {}).get('tools', {})) # tool -> what to do when it is missing HANDED_OVER = re.sub(r'[^A-Z0-9]', '_', PLUGIN.upper()) + '_PYDEPS_INTERPRETER' +# The hook's own candidate list, in its order; the ready stamp records what each resolves to. +HOOK_CANDIDATES = ('python3.13', 'python3.12', 'python3', 'python') +STAMP_TAG = 'pydeps-hook-ready 1' class Broken(Exception): @@ -194,19 +200,65 @@ def install(data, requirements=REQUIREMENTS, probe=PROBE): shutil.rmtree(partial, ignore_errors=True) +def _which(name): + """shutil.which, which on Windows searches the working directory first unless NoDefaultCurrentDirectoryInExePath + is set, so a python3.bat in the project would run as the interpreter.""" + if sys.platform == 'win32': + os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + return shutil.which(name) + + +def ready_stamp(data): + return Path(data) / 'python' / '.hook-ready' + + +def write_ready_stamp(data, target): + """Record, one value per line, what the hook checks before it skips starting Python: this plugin root, the + installed set, and what each of the hook's candidate names resolves to on PATH. A changed lock shows as a newer + requirements.txt or another root; a different interpreter as another resolution or a newer resolved file. + Written atomically; a failed write leaves the hook on its full path.""" + stamp = ready_stamp(data) + names = (*HOOK_CANDIDATES, *(('py',) if sys.platform == 'win32' else ())) + lines = [STAMP_TAG, str(Path(__file__).resolve().parent.parent), str(target), + *(f'{name}={_which(name) or ""}' for name in names)] + partial = stamp.with_name(f'{stamp.name}.{os.getpid()}') + try: + partial.write_text('\n'.join(lines) + '\n', encoding='utf-8', newline='\n') + os.replace(partial, stamp) + except OSError: + partial.unlink(missing_ok=True) + + +def drop_ready_stamp(data): + """The set failed a run or check: the next session start probes it again, and rebuilds it if it does not load.""" + try: + ready_stamp(data).unlink(missing_ok=True) + except OSError: + pass + + def run(data, script_args, requirements=REQUIREMENTS): """Run a script with the installed packages on PYTHONPATH. Never installs: that is the hook's job.""" target = env_dir(data, requirements) if not target.is_dir(): + drop_ready_stamp(data) sys.stderr.write(f'{PLUGIN}: the Python packages are not installed for {sys.implementation.cache_tag}. ' 'Start a new Claude Code session, whose SessionStart hook installs them, or run: ' f'{repair_line(data)}\n') return 2 - return subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + if code and ready_stamp(data).exists(): + # The hook trusted the stamp and skipped the load probe, so the set may no longer import. + drop_ready_stamp(data) + sys.stderr.write(f'{PLUGIN}: the run failed; if the Python packages no longer load, the next Claude Code ' + 'session start re-probes them and rebuilds them, or run now: ' + f'{repair_line(data)}\n') + return code def check(data, requirements=REQUIREMENTS, probe=PROBE): - """Read-only: one PASS/FAIL row per prerequisite. Returns the exit code, 1 when any row fails.""" + """One PASS/FAIL row per prerequisite; installs nothing, and drops the ready stamp when a row fails. Returns the + exit code, 1 when any row fails.""" target = env_dir(data, requirements) rows = [('PASS', f'Python {sys.version_info[0]}.{sys.version_info[1]}', sys.executable)] if target.is_dir() and loads(target, probe): @@ -219,7 +271,10 @@ def check(data, requirements=REQUIREMENTS, probe=PROBE): rows.append(('PASS', tool, found) if found else ('FAIL', tool, f'not on PATH; {missing}')) for row in rows: print(' '.join(row)) - return 1 if any(r[0] == 'FAIL' for r in rows) else 0 + if any(r[0] == 'FAIL' for r in rows): + drop_ready_stamp(data) + return 1 + return 0 def _runs_supported(path): @@ -253,13 +308,9 @@ def _launcher_listed(): def interpreter(): """The first supported interpreter: CANDIDATES on PATH in order, then on Windows each one the py launcher lists. - The hook and every run hand over to it, so both resolve the same directory. On Windows, - shutil.which searches the working directory first unless NoDefaultCurrentDirectoryInExePath is set, so a - python3.bat in the project would run as the interpreter.""" - if sys.platform == 'win32': - os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + The hook and every run hand over to it, so both resolve the same directory.""" for name in CANDIDATES: - found = shutil.which(name) + found = _which(name) if not found or ('windowsapps' in found.lower() and Path(found).stat().st_size == 0): continue if _runs_supported(found): @@ -269,7 +320,8 @@ def interpreter(): def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) - if not os.environ.get(HANDED_OVER): + chosen = os.environ.get(HANDED_OVER) + if not chosen: chosen = interpreter() if chosen and Path(chosen).resolve() != Path(sys.executable).resolve(): return subprocess.run([chosen, str(Path(__file__).resolve()), *argv], @@ -302,10 +354,16 @@ def main(argv=None): return check(data, a.requirements, a.probe) try: print(install(data, a.requirements, a.probe)) - return 0 except Broken as e: sys.stderr.write(f'{PLUGIN}: {e}\nRepair with: {repair_line(data)}\n') return 1 + # Only the plugin's own lock, and only an interpreter found through a name the hook checks: one the py launcher + # listed, or one named outside HOOK_CANDIDATES (python3.14 for a plugin allowing 3.14), could change without + # the hook seeing it, so no stamp, and the hook keeps its full path. + if (a.requirements.resolve() == REQUIREMENTS and tuple(a.probe) == PROBE + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): + write_ready_stamp(data, env_dir(data, a.requirements)) + return 0 if __name__ == '__main__': diff --git a/plugins/animation/hooks/install-python-deps.sh b/plugins/animation/hooks/install-python-deps.sh index 1e535755307..1c95d43101d 100755 --- a/plugins/animation/hooks/install-python-deps.sh +++ b/plugins/animation/hooks/install-python-deps.sh @@ -4,7 +4,7 @@ # SessionStart hook: install the plugin's hash-locked Python packages (requirements.txt) into # ${CLAUDE_PLUGIN_DATA} on demand, so its skills run against them and nothing fetches a package # while a skill runs (docs/contracts/on-demand-dependencies, Python). A no-op once the set -# loads. A failed install is a user notice with the repair line, never a silent skip; the model +# loads, and one that starts no process while pydeps.py's ready stamp holds. A failed install is a user notice with the repair line, never a silent skip; the model # hears nothing here, since pydeps.py run prints the same repair line when a skill runs. # Always exits 0: a failed install must not block the session. The plugin's wording (its name, # the Pythons it supports, the skills that need the packages) is in install-python-deps.conf. @@ -52,9 +52,72 @@ launcher_listed() { # such an alias too, and it runs, so it is only asked to list. candidates=(python3.13 python3.12 python3 python) case "${OSTYPE:-}" in -msys* | cygwin*) candidates+=(py) ;; -*) ;; + msys* | cygwin*) candidates+=(py) ;; + *) ;; esac + +# as_posix -> REPLY: a drive-letter path (C:\x or C:/x) in this shell's POSIX form under Git Bash or Cygwin, +# by builtins alone: cygpath would cost a process start. The drive mounts at /c/ or /cygdrive/c/, whichever exists: +# OSTYPE does not tell them apart, since Git Bash can report cygwin. Fails when neither exists. Any other path is +# returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + local drive="${BASH_REMATCH[1],,}" rest="${BASH_REMATCH[2]}" + if [[ -d "/$drive" ]]; then + REPLY="/$drive$rest" + elif [[ -d "/cygdrive/$drive" ]]; then + REPLY="/cygdrive/$drive$rest" + else + return 1 + fi + fi + ;; + *) ;; + esac +} + +# stamp_holds -> 0 when pydeps.py's ready stamp (written after a successful install with an interpreter found on +# PATH) still describes this session: the same plugin root, every candidate name resolving to the same file, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# Files are compared by identity (-ef), not spelling: one file can be named through a symlink, another mount, an 8.3 +# short name, another letter case, or without its .exe. Builtins only (hash fills BASH_CMDS the way command -v looks +# up a name, without a subshell), so a session start whose set is ready starts no process. Anything else falls +# through to the full path, which probes and rebuilds. +stamp_holds() { + ((BASH_VERSINFO[0] >= 4)) || return 1 + local stamp lines name got i=3 f + as_posix "$data" || return 1 + stamp="$REPLY/python/.hook-ready" + [[ -f "$stamp" ]] || return 1 + mapfile -t lines <"$stamp" || return 1 + lines=("${lines[@]%$'\r'}") + ((${#lines[@]} == 3 + ${#candidates[@]})) && [[ "${lines[0]}" == 'pydeps-hook-ready 1' ]] || return 1 + as_posix "${lines[1]}" || return 1 + [[ "$REPLY/scripts/pydeps.py" -ef "$ROOT/scripts/pydeps.py" ]] || return 1 + as_posix "${lines[2]}" || return 1 + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + as_posix "${lines[i]#*=}" || return 1 + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + if [[ -n "$REPLY" || -n "$got" ]]; then + [[ -n "$REPLY" && -n "$got" && "$REPLY" -ef "$got" ]] || return 1 + [[ "$got" -nt "$stamp" ]] && return 1 + fi + i=$((i + 1)) + done + for f in requirements.txt scripts/pydeps.py scripts/pydeps.json; do + [[ "$ROOT/$f" -nt "$stamp" ]] && return 1 + done + return 0 +} +stamp_holds && exit 0 + py="" for candidate in "${candidates[@]}"; do resolved="$(command -v "$candidate" 2>/dev/null)" || continue @@ -78,19 +141,19 @@ fi repair_line() { local parts=("$(command -v "$py")" "$ROOT/scripts/pydeps.py" install --data-dir "$data") line="" part case "${OSTYPE:-}" in - msys* | cygwin*) - for part in "${parts[@]}"; do - [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" - line+=" '${part//\'/\'\'}'" - done - printf '&%s' "$line" - ;; - *) - for part in "${parts[@]}"; do - line+=" '${part//\'/\'\\\'\'}'" - done - printf '%s' "${line# }" - ;; + msys* | cygwin*) + for part in "${parts[@]}"; do + [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" + line+=" '${part//\'/\'\'}'" + done + printf '&%s' "$line" + ;; + *) + for part in "${parts[@]}"; do + line+=" '${part//\'/\'\\\'\'}'" + done + printf '%s' "${line# }" + ;; esac } @@ -100,15 +163,15 @@ repair_line() { # return the unconverted path. to_native() { case "${OSTYPE:-}" in - msys* | cygwin*) - [[ "$1" == /* ]] || { - printf '%s' "$1" - return 0 - } - local converted - converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" - ;; - *) printf '%s' "$1" ;; + msys* | cygwin*) + [[ "$1" == /* ]] || { + printf '%s' "$1" + return 0 + } + local converted + converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" + ;; + *) printf '%s' "$1" ;; esac } diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index 5c41299b835..faeae5ec212 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -71,6 +71,57 @@ run_hook() { installs() { find "$1/python" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d '[:space:]'; } +# Real Git Bash or Cygwin on Windows (the Windows CI lane): the ready stamp the real install writes, in native +# Windows form (drive-letter paths, 8.3 or long names, .EXE suffixes as shutil.which gives them), holds and the hook +# starts no Python. The fixture stays under /tmp, which Git Bash maps through an 8.3 short name, so the hook's ROOT +# and the stamp's root spell one directory two ways. A sitecustomize on PYTHONPATH logs every Python start that +# imports site, the hook's own interpreter probe included. Every later case stubs interpreters with shell scripts, +# simulates Windows paths or needs Linux namespaces, so on Windows the suite ends here. +if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]] && command -v cygpath >/dev/null 2>&1; then + root="$(new_plugin realwin "$digest")" + data="$WORK/data-realwin" + stamp="$data/python/.hook-ready" + mkdir -p "$WORK/site" + printf 'with open(%s, "a") as f:\n f.write("start\\n")\n' "'$(native "$WORK/py-starts.log")'" >"$WORK/site/sitecustomize.py" + # win_run -> runs the hook with the logging sitecustomize; sets OUT, RC and STARTS. + win_run() { + rm -f "$WORK/py-starts.log" + run_hook "$root" "$1" CLAUDE_PLUGIN_DATA="$(native "$data")" PYTHONPATH="$(native "$WORK/site")" + STARTS=0 + [[ -f "$WORK/py-starts.log" ]] && STARTS="$(wc -l <"$WORK/py-starts.log" | tr -d '[:space:]')" + } + win_run "$WHEELS" + stamp_root="" + if [[ -f "$stamp" ]]; then + mapfile -t stamp_lines <"$stamp" + stamp_root="${stamp_lines[1]-}" + fi + drive_letter="^[A-Za-z]:\\\\" + if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -gt 0 && "$stamp_root" =~ $drive_letter ]]; then + ok "under Git Bash the first session installs, starting Python, and writes the ready stamp in Windows form" + else + fail "Git Bash install: rc=$RC starts=$STARTS stamp root=[$stamp_root] output=[$OUT]" + fi + win_run "$EMPTY" + if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -eq 0 ]]; then + ok "under Git Bash a ready stamp in Windows form starts no Python" + else + fail "Git Bash ready stamp: rc=$RC starts=$STARTS stamp=[$(tr '\n' '|' <"$stamp")] output=[$OUT]" + fi + rm -f "$stamp" + win_run "$EMPTY" + if [[ "$RC" -eq 0 && "$STARTS" -gt 0 ]]; then + ok "under Git Bash without the stamp the hook starts Python, so the start log sees one" + else + fail "Git Bash without stamp: rc=$RC starts=$STARTS output=[$OUT]" + fi + echo "SKIP: the remaining cases stub interpreters with shell scripts, simulate Windows paths or need Linux namespaces" + echo "---" + echo "passed: $PASS, failed: $FAIL" + exit $((FAIL > 0)) +fi +echo "SKIP: the real Git Bash ready-stamp case needs Git Bash or Cygwin with cygpath" + # First session: installs, says nothing. root="$(new_plugin first "$digest")" data="$WORK/data-first" @@ -186,6 +237,175 @@ else fail "linux paths: rc=$RC argv=[$ARGV] output=[$OUT]" fi +# The ready stamp: pydeps.py install, started through a candidate found on PATH, writes /python/.hook-ready, +# and while it holds a session start starts no Python. Every candidate name resolves to a wrapper that logs each start +# and execs the real interpreter. Each case restores the stamp that install wrote, shows that baseline starts nothing, +# changes one input and shows the full path runs. +logged="$WORK/logged" +other="$WORK/other" +mkdir -p "$logged" "$other" +real_py="$(command -v "$py")" +for name in python3.13 python3.12 python3 python; do + printf '#!/bin/sh\necho "%s $*" >>"%s/starts.log"\nexec "%s" "$@"\n' "$name" "$WORK" "$real_py" >"$logged/$name" +done +cp "$logged/python3.13" "$other/python3.13" +chmod +x "$logged/"* "$other/"* +root="$(new_plugin stamped "$digest")" +data="$WORK/data-stamped" +stamp="$data/python/.hook-ready" +# stamp_run [PATH prefix] -> runs the hook with the wrappers first on PATH; sets OUT, RC +# and STARTS, the number of Python starts. +stamp_run() { + rm -f "$WORK/starts.log" + run_hook "$1" "$2" CLAUDE_PLUGIN_DATA="$(native "$data")" PATH="${3:-}$logged:$PATH" + STARTS=0 + [[ -f "$WORK/starts.log" ]] && STARTS="$(wc -l <"$WORK/starts.log" | tr -d '[:space:]')" +} +stamp_run "$root" "$WHEELS" +if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" ]]; then + ok "an install through a candidate on PATH writes the ready stamp" +else + fail "ready stamp write: rc=$RC stamp=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" +fi +cp "$stamp" "$WORK/good-stamp" 2>/dev/null +# restore_stamp -> the stamp the install wrote, newer than every input it checks. +restore_stamp() { + cp "$WORK/good-stamp" "$stamp" + touch -t 202001010000 "$root/requirements.txt" "$root/scripts/pydeps.py" "$root/scripts/pydeps.json" "$logged/"* "$other/"* + touch -t 202101010000 "$stamp" +} +# invalidated -> the baseline starts no Python; after the mutation the hook starts one. +invalidated() { + local what="$1" base + shift + restore_stamp + stamp_run "$root" "$WHEELS" + base="$STARTS/$RC/[$OUT]" + "$@" + if [[ "$base" == "0/0/[]" && "$STARTS" -gt 0 && "$RC" -eq 0 ]]; then + ok "while the ready stamp holds no Python starts; $what runs the full path" + else + fail "$what: baseline starts/rc/output=$base, after: starts=$STARTS rc=$RC output=[$OUT]" + fi +} +moved_root() { + mv "$root" "$root-moved" + stamp_run "$root-moved" "$WHEELS" + mv "$root-moved" "$root" +} +other_candidate() { stamp_run "$root" "$WHEELS" "$other:"; } +newer() { + touch "$1" + stamp_run "$root" "$WHEELS" +} +target_missing() { + find "$data/python" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + stamp_run "$root" "$WHEELS" +} +invalidated "another plugin root" moved_root +invalidated "a candidate resolving elsewhere" other_candidate +invalidated "a resolved candidate newer than the stamp" newer "$logged/python3" +invalidated "a newer requirements.txt" newer "$root/requirements.txt" +invalidated "a newer pydeps.py" newer "$root/scripts/pydeps.py" +invalidated "a newer pydeps.json" newer "$root/scripts/pydeps.json" +invalidated "a missing installed set" target_missing + +# One directory spelled two ways (a symlink here; an 8.3 short name or another mount on Windows): pydeps.py records +# the resolved root, the hook's ROOT keeps the spelling it was started through, and the stamp still holds. +ln -s "$root" "$WORK/stamped-link" +restore_stamp +stamp_run "$WORK/stamped-link" "$EMPTY" +if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -eq 0 ]]; then + ok "a plugin root reached through a symlink still matches the stamp, and no Python starts" +else + fail "symlinked plugin root: starts=$STARTS rc=$RC output=[$OUT]" +fi + +# Under Git Bash, Windows Python writes the stamp with backslashes, while the hook sees POSIX paths. A stamp in that +# shape holds under OSTYPE=msys and, paired, not elsewhere. Mounts, 8.3 names and .EXE suffixes need a Windows file +# system: the real Git Bash case covers them. No case here covers a letter-case difference. +# windows_form -> the path with backslashes. +windows_form() { printf '%s' "${1//\//\\}"; } +target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" +py_at="$(PATH="$logged:$PATH" command -v py 2>/dev/null || true)" +{ + printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(windows_form "$root")" "$(windows_form "$target")" + for name in python3.13 python3.12 python3 python; do + printf '%s=%s\n' "$name" "$(windows_form "$logged/$name")" + done + printf 'py=%s\n' "$(windows_form "$py_at")" +} >"$WORK/good-stamp" +starts_under="" +for ostype in msys linux-gnu; do + restore_stamp + touch "$stamp" # newer than a real py this PATH may hold + rm -f "$WORK/starts.log" + run_hook "$root" "$WHEELS" OSTYPE="$ostype" CLAUDE_PLUGIN_DATA="$data" PATH="$logged:$PATH" + STARTS=0 + [[ -f "$WORK/starts.log" ]] && STARTS="$(wc -l <"$WORK/starts.log" | tr -d '[:space:]')" + starts_under+="$ostype=$STARTS " +done +if [[ "$starts_under" == "msys=0 linux-gnu="[1-9]* ]]; then + ok "a stamp in Windows path form holds under Git Bash and nowhere else" +else + fail "Windows-form stamp: starts under $starts_under" +fi + +# Git Bash on Windows can report OSTYPE=cygwin while it mounts drives at /c/, not /cygdrive/c/. Inside a user and +# mount namespace whose root holds a /c directory (this suite's WORK bound there), a stamp in Windows drive-letter +# form holds under OSTYPE=cygwin and under OSTYPE=msys, and starts no Python. Skipped where unprivileged user +# namespaces are not allowed. +drive_case() { + local nr="$WORK/newroot" d + mkdir -p "$nr" + mount -t tmpfs drives "$nr" || return 1 + for d in /*; do + [[ "$d" == /proc || "$d" == /sys || "$d" == /dev ]] && continue + if [[ -L "$d" ]]; then + cp -P "$d" "$nr$d" + elif [[ -d "$d" ]]; then + mkdir "$nr$d" && mount --rbind "$d" "$nr$d" + fi + done + mkdir "$nr/proc" "$nr/dev" "$nr/c" + mount --rbind /dev "$nr/dev" + mount --rbind "$WORK/drive" "$nr/c" + chroot "$nr" /bin/bash -c "$1" +} +mkdir -p "$WORK/drive/plugin" "$WORK/drive/data/python/0123456789ab-cpython-399" "$WORK/drive/bin" +cp -R "$root/hooks" "$root/scripts" "$root/requirements.txt" "$WORK/drive/plugin/" +for name in python3.13 python3.12 python3 python; do + printf '#!/bin/sh\necho "%s $*" >>/c/starts.log\nexec "%s" "$@"\n' "$name" "$real_py" >"$WORK/drive/bin/$name" +done +chmod +x "$WORK/drive/bin/"* +{ + printf 'pydeps-hook-ready 1\nC:\\plugin\nC:\\data\\python\\0123456789ab-cpython-399\n' + for name in python3.13 python3.12 python3 python; do + printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name" + done + printf 'py=\n' +} >"$WORK/drive/data/python/.hook-ready" +touch -t 202001010000 "$WORK/drive/plugin/requirements.txt" "$WORK/drive/plugin/scripts/"* "$WORK/drive/bin/"* +drive_starts="" +for ostype in cygwin msys; do + rm -f "$WORK/drive/starts.log" + # shellcheck disable=SC2016 # expands inside the namespace + if ! unshare -rm bash -c "$(declare -f drive_case); WORK='$WORK'; drive_case 'OSTYPE=$ostype CLAUDE_PLUGIN_DATA=/c/data PATH=/c/bin:/usr/bin:/bin bash /c/plugin/hooks/install-python-deps.sh /dev/null 2>&1'" 2>/dev/null; then + drive_starts="unavailable" + break + fi + n=0 + [[ -f "$WORK/drive/starts.log" ]] && n="$(wc -l <"$WORK/drive/starts.log" | tr -d '[:space:]')" + drive_starts+="$ostype=$n " +done +if [[ "$drive_starts" == unavailable ]]; then + echo "SKIP: no unprivileged user and mount namespace here -- the /c/ drive-mount case is skipped" +elif [[ "$drive_starts" == "cygwin=0 msys=0 " ]]; then + ok "a drive-letter stamp holds where drives mount at /c/, whether the shell reports cygwin or msys" +else + fail "drive-letter stamp under a /c/ mount: starts $drive_starts" +fi + echo "---" echo "passed: $PASS, failed: $FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/plugins/animation/scripts/pydeps.py b/plugins/animation/scripts/pydeps.py index 0ae952f519e..9358fc1ab57 100755 --- a/plugins/animation/scripts/pydeps.py +++ b/plugins/animation/scripts/pydeps.py @@ -8,7 +8,10 @@ usage: pydeps.py install [--data-dir DIR] install requirements.txt unless already ready; the SessionStart hook runs this, and so does the repair line pydeps.py run [--data-dir DIR] -- SCRIPT ... run SCRIPT against the installed packages; never installs - pydeps.py check [--data-dir DIR] read-only PASS/FAIL rows: Python, the packages, any tools + pydeps.py check [--data-dir DIR] PASS/FAIL rows: Python, the packages, any tools; installs nothing + +A successful install with an interpreter found on PATH writes /python/.hook-ready, which lets the hook skip +starting Python while nothing it records has changed; run and check drop it when the set fails them. exit codes: install 0 ready or installed, 1 broken (reason and repair line on stderr); run 2 packages not installed (repair line on stderr), else the script's own code; @@ -55,6 +58,9 @@ def _version(text): CHECK_LABEL = _CONFIG.get('check', {}).get('label', 'Python packages') CHECK_TOOLS = dict(_CONFIG.get('check', {}).get('tools', {})) # tool -> what to do when it is missing HANDED_OVER = re.sub(r'[^A-Z0-9]', '_', PLUGIN.upper()) + '_PYDEPS_INTERPRETER' +# The hook's own candidate list, in its order; the ready stamp records what each resolves to. +HOOK_CANDIDATES = ('python3.13', 'python3.12', 'python3', 'python') +STAMP_TAG = 'pydeps-hook-ready 1' class Broken(Exception): @@ -196,19 +202,65 @@ def install(data, requirements=REQUIREMENTS, probe=PROBE): shutil.rmtree(partial, ignore_errors=True) +def _which(name): + """shutil.which, which on Windows searches the working directory first unless NoDefaultCurrentDirectoryInExePath + is set, so a python3.bat in the project would run as the interpreter.""" + if sys.platform == 'win32': + os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + return shutil.which(name) + + +def ready_stamp(data): + return Path(data) / 'python' / '.hook-ready' + + +def write_ready_stamp(data, target): + """Record, one value per line, what the hook checks before it skips starting Python: this plugin root, the + installed set, and what each of the hook's candidate names resolves to on PATH. A changed lock shows as a newer + requirements.txt or another root; a different interpreter as another resolution or a newer resolved file. + Written atomically; a failed write leaves the hook on its full path.""" + stamp = ready_stamp(data) + names = (*HOOK_CANDIDATES, *(('py',) if sys.platform == 'win32' else ())) + lines = [STAMP_TAG, str(Path(__file__).resolve().parent.parent), str(target), + *(f'{name}={_which(name) or ""}' for name in names)] + partial = stamp.with_name(f'{stamp.name}.{os.getpid()}') + try: + partial.write_text('\n'.join(lines) + '\n', encoding='utf-8', newline='\n') + os.replace(partial, stamp) + except OSError: + partial.unlink(missing_ok=True) + + +def drop_ready_stamp(data): + """The set failed a run or check: the next session start probes it again, and rebuilds it if it does not load.""" + try: + ready_stamp(data).unlink(missing_ok=True) + except OSError: + pass + + def run(data, script_args, requirements=REQUIREMENTS): """Run a script with the installed packages on PYTHONPATH. Never installs: that is the hook's job.""" target = env_dir(data, requirements) if not target.is_dir(): + drop_ready_stamp(data) sys.stderr.write(f'{PLUGIN}: the Python packages are not installed for {sys.implementation.cache_tag}. ' 'Start a new Claude Code session, whose SessionStart hook installs them, or run: ' f'{repair_line(data)}\n') return 2 - return subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + if code and ready_stamp(data).exists(): + # The hook trusted the stamp and skipped the load probe, so the set may no longer import. + drop_ready_stamp(data) + sys.stderr.write(f'{PLUGIN}: the run failed; if the Python packages no longer load, the next Claude Code ' + 'session start re-probes them and rebuilds them, or run now: ' + f'{repair_line(data)}\n') + return code def check(data, requirements=REQUIREMENTS, probe=PROBE): - """Read-only: one PASS/FAIL row per prerequisite. Returns the exit code, 1 when any row fails.""" + """One PASS/FAIL row per prerequisite; installs nothing, and drops the ready stamp when a row fails. Returns the + exit code, 1 when any row fails.""" target = env_dir(data, requirements) rows = [('PASS', f'Python {sys.version_info[0]}.{sys.version_info[1]}', sys.executable)] if target.is_dir() and loads(target, probe): @@ -221,7 +273,10 @@ def check(data, requirements=REQUIREMENTS, probe=PROBE): rows.append(('PASS', tool, found) if found else ('FAIL', tool, f'not on PATH; {missing}')) for row in rows: print(' '.join(row)) - return 1 if any(r[0] == 'FAIL' for r in rows) else 0 + if any(r[0] == 'FAIL' for r in rows): + drop_ready_stamp(data) + return 1 + return 0 def _runs_supported(path): @@ -255,13 +310,9 @@ def _launcher_listed(): def interpreter(): """The first supported interpreter: CANDIDATES on PATH in order, then on Windows each one the py launcher lists. - The hook and every run hand over to it, so both resolve the same directory. On Windows, - shutil.which searches the working directory first unless NoDefaultCurrentDirectoryInExePath is set, so a - python3.bat in the project would run as the interpreter.""" - if sys.platform == 'win32': - os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + The hook and every run hand over to it, so both resolve the same directory.""" for name in CANDIDATES: - found = shutil.which(name) + found = _which(name) if not found or ('windowsapps' in found.lower() and Path(found).stat().st_size == 0): continue if _runs_supported(found): @@ -271,7 +322,8 @@ def interpreter(): def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) - if not os.environ.get(HANDED_OVER): + chosen = os.environ.get(HANDED_OVER) + if not chosen: chosen = interpreter() if chosen and Path(chosen).resolve() != Path(sys.executable).resolve(): return subprocess.run([chosen, str(Path(__file__).resolve()), *argv], @@ -304,10 +356,16 @@ def main(argv=None): return check(data, a.requirements, a.probe) try: print(install(data, a.requirements, a.probe)) - return 0 except Broken as e: sys.stderr.write(f'{PLUGIN}: {e}\nRepair with: {repair_line(data)}\n') return 1 + # Only the plugin's own lock, and only an interpreter found through a name the hook checks: one the py launcher + # listed, or one named outside HOOK_CANDIDATES (python3.14 for a plugin allowing 3.14), could change without + # the hook seeing it, so no stamp, and the hook keeps its full path. + if (a.requirements.resolve() == REQUIREMENTS and tuple(a.probe) == PROBE + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): + write_ready_stamp(data, env_dir(data, a.requirements)) + return 0 if __name__ == '__main__': diff --git a/plugins/animation/scripts/test_pydeps.py b/plugins/animation/scripts/test_pydeps.py index df684d71b2a..9552501eaa6 100644 --- a/plugins/animation/scripts/test_pydeps.py +++ b/plugins/animation/scripts/test_pydeps.py @@ -6,7 +6,10 @@ prints its sha256, for the hook suite (../hooks/install-python-deps.test.sh). """ import base64 +import contextlib import hashlib +import io +import json import os import re import shutil @@ -285,6 +288,117 @@ def test_no_skill_or_script_fetches_a_package(self): self.assertEqual(found, []) +@unittest.skipUnless(os.name == 'posix' and sys.version_info >= (3, 12) and have_pip(), + 'needs a POSIX Python 3.12 or later with pip: the PATH candidate is a symlink') +class ReadyStamp(unittest.TestCase): + """The ready stamp lets the SessionStart hook skip starting Python. Only an install through an interpreter the + hook can see on PATH writes it; a run or check that finds the set broken drops it.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.root = self.tmp / 'plugin' + (self.root / 'scripts').mkdir(parents=True) + for name in ('pydeps.py', 'pydeps.json'): + shutil.copy(HERE / name, self.root / 'scripts' / name) + wheels = self.tmp / 'wheels' + wheels.mkdir() + _, digest = make_wheel(wheels) + self.lock = self.root / 'requirements.txt' + self.lock.write_text(lock_text(digest), encoding='utf-8') + self.pip = {'PIP_NO_INDEX': '1', 'PIP_FIND_LINKS': str(wheels)} + self.bin = self.tmp / 'bin' + self.bin.mkdir() + self.data = self.tmp / 'data' + self.stamp = self.data / 'python' / '.hook-ready' + self.pydeps = import_pydeps() + + def install_as(self, chosen): + """pydeps.py install in the plugin copy, as the child handed over to `chosen`, with only self.bin on PATH.""" + r = subprocess.run([sys.executable, str(self.root / 'scripts' / 'pydeps.py'), 'install', '--data-dir', str(self.data)], capture_output=True, text=True, cwd=self.root / 'scripts', + env={**os.environ, **self.pip, 'PATH': str(self.bin), 'ANIMATION_PYDEPS_INTERPRETER': str(chosen)}) + self.assertEqual((r.returncode, r.stdout.strip()), (0, 'installed'), r.stderr) + + def install_here(self): + with mock.patch.dict(os.environ, self.pip): + self.pydeps.install(self.data, self.lock) + + def stamped(self): + self.stamp.parent.mkdir(parents=True, exist_ok=True) + self.stamp.write_text('pydeps-hook-ready 1\n', encoding='utf-8') + + def test_an_install_through_a_candidate_on_path_writes_the_stamp_the_hook_reads(self): + python3 = self.bin / 'python3' + python3.symlink_to(sys.executable) + self.install_as(python3) + (target,) = [p for p in (self.data / 'python').iterdir() if p.is_dir()] + self.assertEqual(self.stamp.read_text(encoding='utf-8').splitlines(), [ + 'pydeps-hook-ready 1', str(self.root.resolve()), str(target), + 'python3.13=', 'python3.12=', f'python3={python3}', 'python=']) + + def test_an_interpreter_only_the_launcher_listed_writes_no_stamp(self): + self.install_as(sys.executable) # not on PATH, as a python.exe that only py -0p lists + self.assertEqual(len([p for p in (self.data / 'python').iterdir() if p.is_dir()]), 1) + self.assertFalse(self.stamp.exists()) + + def test_an_interpreter_named_outside_the_hooks_candidates_writes_no_stamp(self): + # A plugin allowing 3.14 chooses python3.14, which the hook never checks, so a change to it could not + # invalidate a stamp. + config_path = self.root / 'scripts' / 'pydeps.json' + config = json.loads(config_path.read_text(encoding='utf-8')) + config.pop('python_min', None) + config['pythons'] = sorted({f'{sys.version_info[0]}.{sys.version_info[1]}', '3.14'}) + config_path.write_text(json.dumps(config), encoding='utf-8') + python314 = self.bin / 'python3.14' + python314.symlink_to(sys.executable) + self.install_as(python314) + self.assertEqual(len([p for p in (self.data / 'python').iterdir() if p.is_dir()]), 1) + self.assertFalse(self.stamp.exists()) + + def test_run_drops_the_stamp_when_the_set_is_missing_or_the_script_fails(self): + script = self.tmp / 'exit.py' + script.write_text('import sys\nsys.exit(int(sys.argv[1]))\n', encoding='utf-8') + self.stamped() + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 2) + self.assertFalse(self.stamp.exists()) + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 2) # no stamp to drop + self.install_here() + self.stamped() + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 0) + self.assertTrue(self.stamp.exists()) + self.assertEqual(self.pydeps.run(self.data, [str(script), '3'], self.lock), 3) + self.assertFalse(self.stamp.exists()) + + def test_a_failed_run_with_a_stamp_prints_the_repair_line(self): + # A stamped set that stops importing fails the skill's script; the hook skipped the probe, so the run says + # how to repair it. Without a stamp a failing script is the script's own business. + script = self.tmp / 'fail.py' + script.write_text('import sys\nsys.exit(1)\n', encoding='utf-8') + self.install_here() + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.pydeps.run(self.data, [str(script)], self.lock), 1) + self.assertEqual(err.getvalue(), '') + self.stamped() + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.pydeps.run(self.data, [str(script)], self.lock), 1) + self.assertIn(self.pydeps.repair_line(self.data), err.getvalue()) + self.assertFalse(self.stamp.exists()) + + def test_check_drops_the_stamp_when_a_row_fails(self): + self.stamped() + with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(self.pydeps, 'CHECK_TOOLS', {}): + self.assertEqual(self.pydeps.check(self.data, self.lock), 1) + self.assertFalse(self.stamp.exists()) + self.install_here() + self.stamped() + self.assertEqual(self.pydeps.check(self.data, self.lock), 0) + self.assertTrue(self.stamp.exists()) + + if __name__ == '__main__': if len(sys.argv) == 3 and sys.argv[1] == '--make-wheel': print(make_wheel(sys.argv[2])[1]) diff --git a/plugins/explainer-video/hooks/install-python-deps.sh b/plugins/explainer-video/hooks/install-python-deps.sh index 1e535755307..1c95d43101d 100755 --- a/plugins/explainer-video/hooks/install-python-deps.sh +++ b/plugins/explainer-video/hooks/install-python-deps.sh @@ -4,7 +4,7 @@ # SessionStart hook: install the plugin's hash-locked Python packages (requirements.txt) into # ${CLAUDE_PLUGIN_DATA} on demand, so its skills run against them and nothing fetches a package # while a skill runs (docs/contracts/on-demand-dependencies, Python). A no-op once the set -# loads. A failed install is a user notice with the repair line, never a silent skip; the model +# loads, and one that starts no process while pydeps.py's ready stamp holds. A failed install is a user notice with the repair line, never a silent skip; the model # hears nothing here, since pydeps.py run prints the same repair line when a skill runs. # Always exits 0: a failed install must not block the session. The plugin's wording (its name, # the Pythons it supports, the skills that need the packages) is in install-python-deps.conf. @@ -52,9 +52,72 @@ launcher_listed() { # such an alias too, and it runs, so it is only asked to list. candidates=(python3.13 python3.12 python3 python) case "${OSTYPE:-}" in -msys* | cygwin*) candidates+=(py) ;; -*) ;; + msys* | cygwin*) candidates+=(py) ;; + *) ;; esac + +# as_posix -> REPLY: a drive-letter path (C:\x or C:/x) in this shell's POSIX form under Git Bash or Cygwin, +# by builtins alone: cygpath would cost a process start. The drive mounts at /c/ or /cygdrive/c/, whichever exists: +# OSTYPE does not tell them apart, since Git Bash can report cygwin. Fails when neither exists. Any other path is +# returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + local drive="${BASH_REMATCH[1],,}" rest="${BASH_REMATCH[2]}" + if [[ -d "/$drive" ]]; then + REPLY="/$drive$rest" + elif [[ -d "/cygdrive/$drive" ]]; then + REPLY="/cygdrive/$drive$rest" + else + return 1 + fi + fi + ;; + *) ;; + esac +} + +# stamp_holds -> 0 when pydeps.py's ready stamp (written after a successful install with an interpreter found on +# PATH) still describes this session: the same plugin root, every candidate name resolving to the same file, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# Files are compared by identity (-ef), not spelling: one file can be named through a symlink, another mount, an 8.3 +# short name, another letter case, or without its .exe. Builtins only (hash fills BASH_CMDS the way command -v looks +# up a name, without a subshell), so a session start whose set is ready starts no process. Anything else falls +# through to the full path, which probes and rebuilds. +stamp_holds() { + ((BASH_VERSINFO[0] >= 4)) || return 1 + local stamp lines name got i=3 f + as_posix "$data" || return 1 + stamp="$REPLY/python/.hook-ready" + [[ -f "$stamp" ]] || return 1 + mapfile -t lines <"$stamp" || return 1 + lines=("${lines[@]%$'\r'}") + ((${#lines[@]} == 3 + ${#candidates[@]})) && [[ "${lines[0]}" == 'pydeps-hook-ready 1' ]] || return 1 + as_posix "${lines[1]}" || return 1 + [[ "$REPLY/scripts/pydeps.py" -ef "$ROOT/scripts/pydeps.py" ]] || return 1 + as_posix "${lines[2]}" || return 1 + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + as_posix "${lines[i]#*=}" || return 1 + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + if [[ -n "$REPLY" || -n "$got" ]]; then + [[ -n "$REPLY" && -n "$got" && "$REPLY" -ef "$got" ]] || return 1 + [[ "$got" -nt "$stamp" ]] && return 1 + fi + i=$((i + 1)) + done + for f in requirements.txt scripts/pydeps.py scripts/pydeps.json; do + [[ "$ROOT/$f" -nt "$stamp" ]] && return 1 + done + return 0 +} +stamp_holds && exit 0 + py="" for candidate in "${candidates[@]}"; do resolved="$(command -v "$candidate" 2>/dev/null)" || continue @@ -78,19 +141,19 @@ fi repair_line() { local parts=("$(command -v "$py")" "$ROOT/scripts/pydeps.py" install --data-dir "$data") line="" part case "${OSTYPE:-}" in - msys* | cygwin*) - for part in "${parts[@]}"; do - [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" - line+=" '${part//\'/\'\'}'" - done - printf '&%s' "$line" - ;; - *) - for part in "${parts[@]}"; do - line+=" '${part//\'/\'\\\'\'}'" - done - printf '%s' "${line# }" - ;; + msys* | cygwin*) + for part in "${parts[@]}"; do + [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" + line+=" '${part//\'/\'\'}'" + done + printf '&%s' "$line" + ;; + *) + for part in "${parts[@]}"; do + line+=" '${part//\'/\'\\\'\'}'" + done + printf '%s' "${line# }" + ;; esac } @@ -100,15 +163,15 @@ repair_line() { # return the unconverted path. to_native() { case "${OSTYPE:-}" in - msys* | cygwin*) - [[ "$1" == /* ]] || { - printf '%s' "$1" - return 0 - } - local converted - converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" - ;; - *) printf '%s' "$1" ;; + msys* | cygwin*) + [[ "$1" == /* ]] || { + printf '%s' "$1" + return 0 + } + local converted + converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" + ;; + *) printf '%s' "$1" ;; esac } diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index 4e080692e54..f9f636a7360 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -72,6 +72,57 @@ run_hook() { installs() { find "$1/python" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d '[:space:]'; } +# Real Git Bash or Cygwin on Windows (the Windows CI lane): the ready stamp the real install writes, in native +# Windows form (drive-letter paths, 8.3 or long names, .EXE suffixes as shutil.which gives them), holds and the hook +# starts no Python. The fixture stays under /tmp, which Git Bash maps through an 8.3 short name, so the hook's ROOT +# and the stamp's root spell one directory two ways. A sitecustomize on PYTHONPATH logs every Python start that +# imports site, the hook's own interpreter probe included. Every later case stubs interpreters with shell scripts, +# simulates Windows paths or needs Linux namespaces, so on Windows the suite ends here. +if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]] && command -v cygpath >/dev/null 2>&1; then + root="$(new_plugin realwin "$digest")" + data="$WORK/data-realwin" + stamp="$data/python/.hook-ready" + mkdir -p "$WORK/site" + printf 'with open(%s, "a") as f:\n f.write("start\\n")\n' "'$(native "$WORK/py-starts.log")'" >"$WORK/site/sitecustomize.py" + # win_run -> runs the hook with the logging sitecustomize; sets OUT, RC and STARTS. + win_run() { + rm -f "$WORK/py-starts.log" + run_hook "$root" "$1" CLAUDE_PLUGIN_DATA="$(native "$data")" PYTHONPATH="$(native "$WORK/site")" + STARTS=0 + [[ -f "$WORK/py-starts.log" ]] && STARTS="$(wc -l <"$WORK/py-starts.log" | tr -d '[:space:]')" + } + win_run "$WHEELS" + stamp_root="" + if [[ -f "$stamp" ]]; then + mapfile -t stamp_lines <"$stamp" + stamp_root="${stamp_lines[1]-}" + fi + drive_letter="^[A-Za-z]:\\\\" + if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -gt 0 && "$stamp_root" =~ $drive_letter ]]; then + ok "under Git Bash the first session installs, starting Python, and writes the ready stamp in Windows form" + else + fail "Git Bash install: rc=$RC starts=$STARTS stamp root=[$stamp_root] output=[$OUT]" + fi + win_run "$EMPTY" + if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -eq 0 ]]; then + ok "under Git Bash a ready stamp in Windows form starts no Python" + else + fail "Git Bash ready stamp: rc=$RC starts=$STARTS stamp=[$(tr '\n' '|' <"$stamp")] output=[$OUT]" + fi + rm -f "$stamp" + win_run "$EMPTY" + if [[ "$RC" -eq 0 && "$STARTS" -gt 0 ]]; then + ok "under Git Bash without the stamp the hook starts Python, so the start log sees one" + else + fail "Git Bash without stamp: rc=$RC starts=$STARTS output=[$OUT]" + fi + echo "SKIP: the remaining cases stub interpreters with shell scripts, simulate Windows paths or need Linux namespaces" + echo "---" + echo "passed: $PASS, failed: $FAIL" + exit $((FAIL > 0)) +fi +echo "SKIP: the real Git Bash ready-stamp case needs Git Bash or Cygwin with cygpath" + # First session: installs, says nothing. root="$(new_plugin first "$digest")" data="$WORK/data-first" @@ -281,6 +332,175 @@ else fail "py outside Windows: rc=$rc output=[$out]" fi +# The ready stamp: pydeps.py install, started through a candidate found on PATH, writes /python/.hook-ready, +# and while it holds a session start starts no Python. Every candidate name resolves to a wrapper that logs each start +# and execs the real interpreter. Each case restores the stamp that install wrote, shows that baseline starts nothing, +# changes one input and shows the full path runs. +logged="$WORK/logged" +other="$WORK/other" +mkdir -p "$logged" "$other" +real_py="$(command -v "$py")" +for name in python3.13 python3.12 python3 python; do + printf '#!/bin/sh\necho "%s $*" >>"%s/starts.log"\nexec "%s" "$@"\n' "$name" "$WORK" "$real_py" >"$logged/$name" +done +cp "$logged/python3.13" "$other/python3.13" +chmod +x "$logged/"* "$other/"* +root="$(new_plugin stamped "$digest")" +data="$WORK/data-stamped" +stamp="$data/python/.hook-ready" +# stamp_run [PATH prefix] -> runs the hook with the wrappers first on PATH; sets OUT, RC +# and STARTS, the number of Python starts. +stamp_run() { + rm -f "$WORK/starts.log" + run_hook "$1" "$2" CLAUDE_PLUGIN_DATA="$(native "$data")" PATH="${3:-}$logged:$PATH" + STARTS=0 + [[ -f "$WORK/starts.log" ]] && STARTS="$(wc -l <"$WORK/starts.log" | tr -d '[:space:]')" +} +stamp_run "$root" "$WHEELS" +if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" ]]; then + ok "an install through a candidate on PATH writes the ready stamp" +else + fail "ready stamp write: rc=$RC stamp=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" +fi +cp "$stamp" "$WORK/good-stamp" 2>/dev/null +# restore_stamp -> the stamp the install wrote, newer than every input it checks. +restore_stamp() { + cp "$WORK/good-stamp" "$stamp" + touch -t 202001010000 "$root/requirements.txt" "$root/scripts/pydeps.py" "$root/scripts/pydeps.json" "$logged/"* "$other/"* + touch -t 202101010000 "$stamp" +} +# invalidated -> the baseline starts no Python; after the mutation the hook starts one. +invalidated() { + local what="$1" base + shift + restore_stamp + stamp_run "$root" "$WHEELS" + base="$STARTS/$RC/[$OUT]" + "$@" + if [[ "$base" == "0/0/[]" && "$STARTS" -gt 0 && "$RC" -eq 0 ]]; then + ok "while the ready stamp holds no Python starts; $what runs the full path" + else + fail "$what: baseline starts/rc/output=$base, after: starts=$STARTS rc=$RC output=[$OUT]" + fi +} +moved_root() { + mv "$root" "$root-moved" + stamp_run "$root-moved" "$WHEELS" + mv "$root-moved" "$root" +} +other_candidate() { stamp_run "$root" "$WHEELS" "$other:"; } +newer() { + touch "$1" + stamp_run "$root" "$WHEELS" +} +target_missing() { + find "$data/python" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + stamp_run "$root" "$WHEELS" +} +invalidated "another plugin root" moved_root +invalidated "a candidate resolving elsewhere" other_candidate +invalidated "a resolved candidate newer than the stamp" newer "$logged/python3" +invalidated "a newer requirements.txt" newer "$root/requirements.txt" +invalidated "a newer pydeps.py" newer "$root/scripts/pydeps.py" +invalidated "a newer pydeps.json" newer "$root/scripts/pydeps.json" +invalidated "a missing installed set" target_missing + +# One directory spelled two ways (a symlink here; an 8.3 short name or another mount on Windows): pydeps.py records +# the resolved root, the hook's ROOT keeps the spelling it was started through, and the stamp still holds. +ln -s "$root" "$WORK/stamped-link" +restore_stamp +stamp_run "$WORK/stamped-link" "$EMPTY" +if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -eq 0 ]]; then + ok "a plugin root reached through a symlink still matches the stamp, and no Python starts" +else + fail "symlinked plugin root: starts=$STARTS rc=$RC output=[$OUT]" +fi + +# Under Git Bash, Windows Python writes the stamp with backslashes, while the hook sees POSIX paths. A stamp in that +# shape holds under OSTYPE=msys and, paired, not elsewhere. Mounts, 8.3 names and .EXE suffixes need a Windows file +# system: the real Git Bash case covers them. No case here covers a letter-case difference. +# windows_form -> the path with backslashes. +windows_form() { printf '%s' "${1//\//\\}"; } +target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" +py_at="$(PATH="$logged:$PATH" command -v py 2>/dev/null || true)" +{ + printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(windows_form "$root")" "$(windows_form "$target")" + for name in python3.13 python3.12 python3 python; do + printf '%s=%s\n' "$name" "$(windows_form "$logged/$name")" + done + printf 'py=%s\n' "$(windows_form "$py_at")" +} >"$WORK/good-stamp" +starts_under="" +for ostype in msys linux-gnu; do + restore_stamp + touch "$stamp" # newer than a real py this PATH may hold + rm -f "$WORK/starts.log" + run_hook "$root" "$WHEELS" OSTYPE="$ostype" CLAUDE_PLUGIN_DATA="$data" PATH="$logged:$PATH" + STARTS=0 + [[ -f "$WORK/starts.log" ]] && STARTS="$(wc -l <"$WORK/starts.log" | tr -d '[:space:]')" + starts_under+="$ostype=$STARTS " +done +if [[ "$starts_under" == "msys=0 linux-gnu="[1-9]* ]]; then + ok "a stamp in Windows path form holds under Git Bash and nowhere else" +else + fail "Windows-form stamp: starts under $starts_under" +fi + +# Git Bash on Windows can report OSTYPE=cygwin while it mounts drives at /c/, not /cygdrive/c/. Inside a user and +# mount namespace whose root holds a /c directory (this suite's WORK bound there), a stamp in Windows drive-letter +# form holds under OSTYPE=cygwin and under OSTYPE=msys, and starts no Python. Skipped where unprivileged user +# namespaces are not allowed. +drive_case() { + local nr="$WORK/newroot" d + mkdir -p "$nr" + mount -t tmpfs drives "$nr" || return 1 + for d in /*; do + [[ "$d" == /proc || "$d" == /sys || "$d" == /dev ]] && continue + if [[ -L "$d" ]]; then + cp -P "$d" "$nr$d" + elif [[ -d "$d" ]]; then + mkdir "$nr$d" && mount --rbind "$d" "$nr$d" + fi + done + mkdir "$nr/proc" "$nr/dev" "$nr/c" + mount --rbind /dev "$nr/dev" + mount --rbind "$WORK/drive" "$nr/c" + chroot "$nr" /bin/bash -c "$1" +} +mkdir -p "$WORK/drive/plugin" "$WORK/drive/data/python/0123456789ab-cpython-399" "$WORK/drive/bin" +cp -R "$root/hooks" "$root/scripts" "$root/requirements.txt" "$WORK/drive/plugin/" +for name in python3.13 python3.12 python3 python; do + printf '#!/bin/sh\necho "%s $*" >>/c/starts.log\nexec "%s" "$@"\n' "$name" "$real_py" >"$WORK/drive/bin/$name" +done +chmod +x "$WORK/drive/bin/"* +{ + printf 'pydeps-hook-ready 1\nC:\\plugin\nC:\\data\\python\\0123456789ab-cpython-399\n' + for name in python3.13 python3.12 python3 python; do + printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name" + done + printf 'py=\n' +} >"$WORK/drive/data/python/.hook-ready" +touch -t 202001010000 "$WORK/drive/plugin/requirements.txt" "$WORK/drive/plugin/scripts/"* "$WORK/drive/bin/"* +drive_starts="" +for ostype in cygwin msys; do + rm -f "$WORK/drive/starts.log" + # shellcheck disable=SC2016 # expands inside the namespace + if ! unshare -rm bash -c "$(declare -f drive_case); WORK='$WORK'; drive_case 'OSTYPE=$ostype CLAUDE_PLUGIN_DATA=/c/data PATH=/c/bin:/usr/bin:/bin bash /c/plugin/hooks/install-python-deps.sh /dev/null 2>&1'" 2>/dev/null; then + drive_starts="unavailable" + break + fi + n=0 + [[ -f "$WORK/drive/starts.log" ]] && n="$(wc -l <"$WORK/drive/starts.log" | tr -d '[:space:]')" + drive_starts+="$ostype=$n " +done +if [[ "$drive_starts" == unavailable ]]; then + echo "SKIP: no unprivileged user and mount namespace here -- the /c/ drive-mount case is skipped" +elif [[ "$drive_starts" == "cygwin=0 msys=0 " ]]; then + ok "a drive-letter stamp holds where drives mount at /c/, whether the shell reports cygwin or msys" +else + fail "drive-letter stamp under a /c/ mount: starts $drive_starts" +fi + echo "---" echo "passed: $PASS, failed: $FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/plugins/explainer-video/scripts/pydeps.py b/plugins/explainer-video/scripts/pydeps.py index 0ae952f519e..9358fc1ab57 100755 --- a/plugins/explainer-video/scripts/pydeps.py +++ b/plugins/explainer-video/scripts/pydeps.py @@ -8,7 +8,10 @@ usage: pydeps.py install [--data-dir DIR] install requirements.txt unless already ready; the SessionStart hook runs this, and so does the repair line pydeps.py run [--data-dir DIR] -- SCRIPT ... run SCRIPT against the installed packages; never installs - pydeps.py check [--data-dir DIR] read-only PASS/FAIL rows: Python, the packages, any tools + pydeps.py check [--data-dir DIR] PASS/FAIL rows: Python, the packages, any tools; installs nothing + +A successful install with an interpreter found on PATH writes /python/.hook-ready, which lets the hook skip +starting Python while nothing it records has changed; run and check drop it when the set fails them. exit codes: install 0 ready or installed, 1 broken (reason and repair line on stderr); run 2 packages not installed (repair line on stderr), else the script's own code; @@ -55,6 +58,9 @@ def _version(text): CHECK_LABEL = _CONFIG.get('check', {}).get('label', 'Python packages') CHECK_TOOLS = dict(_CONFIG.get('check', {}).get('tools', {})) # tool -> what to do when it is missing HANDED_OVER = re.sub(r'[^A-Z0-9]', '_', PLUGIN.upper()) + '_PYDEPS_INTERPRETER' +# The hook's own candidate list, in its order; the ready stamp records what each resolves to. +HOOK_CANDIDATES = ('python3.13', 'python3.12', 'python3', 'python') +STAMP_TAG = 'pydeps-hook-ready 1' class Broken(Exception): @@ -196,19 +202,65 @@ def install(data, requirements=REQUIREMENTS, probe=PROBE): shutil.rmtree(partial, ignore_errors=True) +def _which(name): + """shutil.which, which on Windows searches the working directory first unless NoDefaultCurrentDirectoryInExePath + is set, so a python3.bat in the project would run as the interpreter.""" + if sys.platform == 'win32': + os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + return shutil.which(name) + + +def ready_stamp(data): + return Path(data) / 'python' / '.hook-ready' + + +def write_ready_stamp(data, target): + """Record, one value per line, what the hook checks before it skips starting Python: this plugin root, the + installed set, and what each of the hook's candidate names resolves to on PATH. A changed lock shows as a newer + requirements.txt or another root; a different interpreter as another resolution or a newer resolved file. + Written atomically; a failed write leaves the hook on its full path.""" + stamp = ready_stamp(data) + names = (*HOOK_CANDIDATES, *(('py',) if sys.platform == 'win32' else ())) + lines = [STAMP_TAG, str(Path(__file__).resolve().parent.parent), str(target), + *(f'{name}={_which(name) or ""}' for name in names)] + partial = stamp.with_name(f'{stamp.name}.{os.getpid()}') + try: + partial.write_text('\n'.join(lines) + '\n', encoding='utf-8', newline='\n') + os.replace(partial, stamp) + except OSError: + partial.unlink(missing_ok=True) + + +def drop_ready_stamp(data): + """The set failed a run or check: the next session start probes it again, and rebuilds it if it does not load.""" + try: + ready_stamp(data).unlink(missing_ok=True) + except OSError: + pass + + def run(data, script_args, requirements=REQUIREMENTS): """Run a script with the installed packages on PYTHONPATH. Never installs: that is the hook's job.""" target = env_dir(data, requirements) if not target.is_dir(): + drop_ready_stamp(data) sys.stderr.write(f'{PLUGIN}: the Python packages are not installed for {sys.implementation.cache_tag}. ' 'Start a new Claude Code session, whose SessionStart hook installs them, or run: ' f'{repair_line(data)}\n') return 2 - return subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + if code and ready_stamp(data).exists(): + # The hook trusted the stamp and skipped the load probe, so the set may no longer import. + drop_ready_stamp(data) + sys.stderr.write(f'{PLUGIN}: the run failed; if the Python packages no longer load, the next Claude Code ' + 'session start re-probes them and rebuilds them, or run now: ' + f'{repair_line(data)}\n') + return code def check(data, requirements=REQUIREMENTS, probe=PROBE): - """Read-only: one PASS/FAIL row per prerequisite. Returns the exit code, 1 when any row fails.""" + """One PASS/FAIL row per prerequisite; installs nothing, and drops the ready stamp when a row fails. Returns the + exit code, 1 when any row fails.""" target = env_dir(data, requirements) rows = [('PASS', f'Python {sys.version_info[0]}.{sys.version_info[1]}', sys.executable)] if target.is_dir() and loads(target, probe): @@ -221,7 +273,10 @@ def check(data, requirements=REQUIREMENTS, probe=PROBE): rows.append(('PASS', tool, found) if found else ('FAIL', tool, f'not on PATH; {missing}')) for row in rows: print(' '.join(row)) - return 1 if any(r[0] == 'FAIL' for r in rows) else 0 + if any(r[0] == 'FAIL' for r in rows): + drop_ready_stamp(data) + return 1 + return 0 def _runs_supported(path): @@ -255,13 +310,9 @@ def _launcher_listed(): def interpreter(): """The first supported interpreter: CANDIDATES on PATH in order, then on Windows each one the py launcher lists. - The hook and every run hand over to it, so both resolve the same directory. On Windows, - shutil.which searches the working directory first unless NoDefaultCurrentDirectoryInExePath is set, so a - python3.bat in the project would run as the interpreter.""" - if sys.platform == 'win32': - os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + The hook and every run hand over to it, so both resolve the same directory.""" for name in CANDIDATES: - found = shutil.which(name) + found = _which(name) if not found or ('windowsapps' in found.lower() and Path(found).stat().st_size == 0): continue if _runs_supported(found): @@ -271,7 +322,8 @@ def interpreter(): def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) - if not os.environ.get(HANDED_OVER): + chosen = os.environ.get(HANDED_OVER) + if not chosen: chosen = interpreter() if chosen and Path(chosen).resolve() != Path(sys.executable).resolve(): return subprocess.run([chosen, str(Path(__file__).resolve()), *argv], @@ -304,10 +356,16 @@ def main(argv=None): return check(data, a.requirements, a.probe) try: print(install(data, a.requirements, a.probe)) - return 0 except Broken as e: sys.stderr.write(f'{PLUGIN}: {e}\nRepair with: {repair_line(data)}\n') return 1 + # Only the plugin's own lock, and only an interpreter found through a name the hook checks: one the py launcher + # listed, or one named outside HOOK_CANDIDATES (python3.14 for a plugin allowing 3.14), could change without + # the hook seeing it, so no stamp, and the hook keeps its full path. + if (a.requirements.resolve() == REQUIREMENTS and tuple(a.probe) == PROBE + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): + write_ready_stamp(data, env_dir(data, a.requirements)) + return 0 if __name__ == '__main__': diff --git a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py index 5e18ab3e8c3..fbe67483a3e 100644 --- a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py +++ b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py @@ -8,9 +8,12 @@ running the test, so the suite runs under any Python with pip. """ import base64 +import contextlib import builtins import hashlib import importlib.util +import io +import json import os import re import shutil @@ -393,6 +396,116 @@ def test_no_skill_or_script_fetches_a_package(self): self.assertEqual(found, []) +@unittest.skipUnless(os.name == 'posix' and have_pip(), 'needs a POSIX Python with pip: the PATH candidate is a symlink') +class ReadyStamp(unittest.TestCase): + """The ready stamp lets the SessionStart hook skip starting Python. Only an install through an interpreter the + hook can see on PATH writes it; a run or check that finds the set broken drops it.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.root = self.tmp / 'plugin' + (self.root / 'scripts').mkdir(parents=True) + for name in ('pydeps.py', 'pydeps.json'): + shutil.copy(HERE / name, self.root / 'scripts' / name) + wheels = self.tmp / 'wheels' + wheels.mkdir() + _, digest = make_wheel(wheels) + self.lock = self.root / 'requirements.txt' + self.lock.write_text(lock_text(digest), encoding='utf-8') + self.pip = {'PIP_NO_INDEX': '1', 'PIP_FIND_LINKS': str(wheels)} + self.bin = self.tmp / 'bin' + self.bin.mkdir() + self.data = self.tmp / 'data' + self.stamp = self.data / 'python' / '.hook-ready' + self.pydeps = pydeps + + def install_as(self, chosen): + """pydeps.py install in the plugin copy, as the child handed over to `chosen`, with only self.bin on PATH.""" + r = subprocess.run([sys.executable, '-c', AS_SUPPORTED, 'install', '--data-dir', str(self.data)], capture_output=True, text=True, cwd=self.root / 'scripts', + env={**os.environ, **self.pip, 'PATH': str(self.bin), pydeps.HANDED_OVER: str(chosen)}) + self.assertEqual((r.returncode, r.stdout.strip()), (0, 'installed'), r.stderr) + + def install_here(self): + with unittest.mock.patch.dict(os.environ, self.pip): + self.pydeps.install(self.data, self.lock) + + def stamped(self): + self.stamp.parent.mkdir(parents=True, exist_ok=True) + self.stamp.write_text('pydeps-hook-ready 1\n', encoding='utf-8') + + def test_an_install_through_a_candidate_on_path_writes_the_stamp_the_hook_reads(self): + python3 = self.bin / 'python3' + python3.symlink_to(sys.executable) + self.install_as(python3) + (target,) = [p for p in (self.data / 'python').iterdir() if p.is_dir()] + self.assertEqual(self.stamp.read_text(encoding='utf-8').splitlines(), [ + 'pydeps-hook-ready 1', str(self.root.resolve()), str(target), + 'python3.13=', 'python3.12=', f'python3={python3}', 'python=']) + + def test_an_interpreter_only_the_launcher_listed_writes_no_stamp(self): + self.install_as(sys.executable) # not on PATH, as a python.exe that only py -0p lists + self.assertEqual(len([p for p in (self.data / 'python').iterdir() if p.is_dir()]), 1) + self.assertFalse(self.stamp.exists()) + + def test_an_interpreter_named_outside_the_hooks_candidates_writes_no_stamp(self): + # A plugin allowing 3.14 chooses python3.14, which the hook never checks, so a change to it could not + # invalidate a stamp. + config_path = self.root / 'scripts' / 'pydeps.json' + config = json.loads(config_path.read_text(encoding='utf-8')) + config.pop('python_min', None) + config['pythons'] = sorted({f'{sys.version_info[0]}.{sys.version_info[1]}', '3.14'}) + config_path.write_text(json.dumps(config), encoding='utf-8') + python314 = self.bin / 'python3.14' + python314.symlink_to(sys.executable) + self.install_as(python314) + self.assertEqual(len([p for p in (self.data / 'python').iterdir() if p.is_dir()]), 1) + self.assertFalse(self.stamp.exists()) + + def test_run_drops_the_stamp_when_the_set_is_missing_or_the_script_fails(self): + script = self.tmp / 'exit.py' + script.write_text('import sys\nsys.exit(int(sys.argv[1]))\n', encoding='utf-8') + self.stamped() + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 2) + self.assertFalse(self.stamp.exists()) + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 2) # no stamp to drop + self.install_here() + self.stamped() + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 0) + self.assertTrue(self.stamp.exists()) + self.assertEqual(self.pydeps.run(self.data, [str(script), '3'], self.lock), 3) + self.assertFalse(self.stamp.exists()) + + def test_a_failed_run_with_a_stamp_prints_the_repair_line(self): + # A stamped set that stops importing fails the skill's script; the hook skipped the probe, so the run says + # how to repair it. Without a stamp a failing script is the script's own business. + script = self.tmp / 'fail.py' + script.write_text('import sys\nsys.exit(1)\n', encoding='utf-8') + self.install_here() + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.pydeps.run(self.data, [str(script)], self.lock), 1) + self.assertEqual(err.getvalue(), '') + self.stamped() + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.pydeps.run(self.data, [str(script)], self.lock), 1) + self.assertIn(self.pydeps.repair_line(self.data), err.getvalue()) + self.assertFalse(self.stamp.exists()) + + def test_check_drops_the_stamp_when_a_row_fails(self): + self.stamped() + with contextlib.redirect_stdout(io.StringIO()), unittest.mock.patch.object(self.pydeps, 'CHECK_TOOLS', {}): + self.assertEqual(self.pydeps.check(self.data, self.lock), 1) + self.assertFalse(self.stamp.exists()) + self.install_here() + self.stamped() + self.assertEqual(self.pydeps.check(self.data, self.lock), 0) + self.assertTrue(self.stamp.exists()) + + if __name__ == '__main__': if len(sys.argv) == 3 and sys.argv[1] == '--make-wheel': print(make_wheel(sys.argv[2])[1]) diff --git a/plugins/speech/hooks/install-python-deps.sh b/plugins/speech/hooks/install-python-deps.sh index 1e535755307..1c95d43101d 100755 --- a/plugins/speech/hooks/install-python-deps.sh +++ b/plugins/speech/hooks/install-python-deps.sh @@ -4,7 +4,7 @@ # SessionStart hook: install the plugin's hash-locked Python packages (requirements.txt) into # ${CLAUDE_PLUGIN_DATA} on demand, so its skills run against them and nothing fetches a package # while a skill runs (docs/contracts/on-demand-dependencies, Python). A no-op once the set -# loads. A failed install is a user notice with the repair line, never a silent skip; the model +# loads, and one that starts no process while pydeps.py's ready stamp holds. A failed install is a user notice with the repair line, never a silent skip; the model # hears nothing here, since pydeps.py run prints the same repair line when a skill runs. # Always exits 0: a failed install must not block the session. The plugin's wording (its name, # the Pythons it supports, the skills that need the packages) is in install-python-deps.conf. @@ -52,9 +52,72 @@ launcher_listed() { # such an alias too, and it runs, so it is only asked to list. candidates=(python3.13 python3.12 python3 python) case "${OSTYPE:-}" in -msys* | cygwin*) candidates+=(py) ;; -*) ;; + msys* | cygwin*) candidates+=(py) ;; + *) ;; esac + +# as_posix -> REPLY: a drive-letter path (C:\x or C:/x) in this shell's POSIX form under Git Bash or Cygwin, +# by builtins alone: cygpath would cost a process start. The drive mounts at /c/ or /cygdrive/c/, whichever exists: +# OSTYPE does not tell them apart, since Git Bash can report cygwin. Fails when neither exists. Any other path is +# returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + local drive="${BASH_REMATCH[1],,}" rest="${BASH_REMATCH[2]}" + if [[ -d "/$drive" ]]; then + REPLY="/$drive$rest" + elif [[ -d "/cygdrive/$drive" ]]; then + REPLY="/cygdrive/$drive$rest" + else + return 1 + fi + fi + ;; + *) ;; + esac +} + +# stamp_holds -> 0 when pydeps.py's ready stamp (written after a successful install with an interpreter found on +# PATH) still describes this session: the same plugin root, every candidate name resolving to the same file, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# Files are compared by identity (-ef), not spelling: one file can be named through a symlink, another mount, an 8.3 +# short name, another letter case, or without its .exe. Builtins only (hash fills BASH_CMDS the way command -v looks +# up a name, without a subshell), so a session start whose set is ready starts no process. Anything else falls +# through to the full path, which probes and rebuilds. +stamp_holds() { + ((BASH_VERSINFO[0] >= 4)) || return 1 + local stamp lines name got i=3 f + as_posix "$data" || return 1 + stamp="$REPLY/python/.hook-ready" + [[ -f "$stamp" ]] || return 1 + mapfile -t lines <"$stamp" || return 1 + lines=("${lines[@]%$'\r'}") + ((${#lines[@]} == 3 + ${#candidates[@]})) && [[ "${lines[0]}" == 'pydeps-hook-ready 1' ]] || return 1 + as_posix "${lines[1]}" || return 1 + [[ "$REPLY/scripts/pydeps.py" -ef "$ROOT/scripts/pydeps.py" ]] || return 1 + as_posix "${lines[2]}" || return 1 + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + as_posix "${lines[i]#*=}" || return 1 + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + if [[ -n "$REPLY" || -n "$got" ]]; then + [[ -n "$REPLY" && -n "$got" && "$REPLY" -ef "$got" ]] || return 1 + [[ "$got" -nt "$stamp" ]] && return 1 + fi + i=$((i + 1)) + done + for f in requirements.txt scripts/pydeps.py scripts/pydeps.json; do + [[ "$ROOT/$f" -nt "$stamp" ]] && return 1 + done + return 0 +} +stamp_holds && exit 0 + py="" for candidate in "${candidates[@]}"; do resolved="$(command -v "$candidate" 2>/dev/null)" || continue @@ -78,19 +141,19 @@ fi repair_line() { local parts=("$(command -v "$py")" "$ROOT/scripts/pydeps.py" install --data-dir "$data") line="" part case "${OSTYPE:-}" in - msys* | cygwin*) - for part in "${parts[@]}"; do - [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" - line+=" '${part//\'/\'\'}'" - done - printf '&%s' "$line" - ;; - *) - for part in "${parts[@]}"; do - line+=" '${part//\'/\'\\\'\'}'" - done - printf '%s' "${line# }" - ;; + msys* | cygwin*) + for part in "${parts[@]}"; do + [[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")" + line+=" '${part//\'/\'\'}'" + done + printf '&%s' "$line" + ;; + *) + for part in "${parts[@]}"; do + line+=" '${part//\'/\'\\\'\'}'" + done + printf '%s' "${line# }" + ;; esac } @@ -100,15 +163,15 @@ repair_line() { # return the unconverted path. to_native() { case "${OSTYPE:-}" in - msys* | cygwin*) - [[ "$1" == /* ]] || { - printf '%s' "$1" - return 0 - } - local converted - converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" - ;; - *) printf '%s' "$1" ;; + msys* | cygwin*) + [[ "$1" == /* ]] || { + printf '%s' "$1" + return 0 + } + local converted + converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted" + ;; + *) printf '%s' "$1" ;; esac } diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index 530e3ed1203..530a2e10ee4 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -71,6 +71,57 @@ run_hook() { installs() { find "$1/python" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d '[:space:]'; } +# Real Git Bash or Cygwin on Windows (the Windows CI lane): the ready stamp the real install writes, in native +# Windows form (drive-letter paths, 8.3 or long names, .EXE suffixes as shutil.which gives them), holds and the hook +# starts no Python. The fixture stays under /tmp, which Git Bash maps through an 8.3 short name, so the hook's ROOT +# and the stamp's root spell one directory two ways. A sitecustomize on PYTHONPATH logs every Python start that +# imports site, the hook's own interpreter probe included. Every later case stubs interpreters with shell scripts, +# simulates Windows paths or needs Linux namespaces, so on Windows the suite ends here. +if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]] && command -v cygpath >/dev/null 2>&1; then + root="$(new_plugin realwin "$digest")" + data="$WORK/data-realwin" + stamp="$data/python/.hook-ready" + mkdir -p "$WORK/site" + printf 'with open(%s, "a") as f:\n f.write("start\\n")\n' "'$(native "$WORK/py-starts.log")'" >"$WORK/site/sitecustomize.py" + # win_run -> runs the hook with the logging sitecustomize; sets OUT, RC and STARTS. + win_run() { + rm -f "$WORK/py-starts.log" + run_hook "$root" "$1" CLAUDE_PLUGIN_DATA="$(native "$data")" PYTHONPATH="$(native "$WORK/site")" + STARTS=0 + [[ -f "$WORK/py-starts.log" ]] && STARTS="$(wc -l <"$WORK/py-starts.log" | tr -d '[:space:]')" + } + win_run "$WHEELS" + stamp_root="" + if [[ -f "$stamp" ]]; then + mapfile -t stamp_lines <"$stamp" + stamp_root="${stamp_lines[1]-}" + fi + drive_letter="^[A-Za-z]:\\\\" + if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -gt 0 && "$stamp_root" =~ $drive_letter ]]; then + ok "under Git Bash the first session installs, starting Python, and writes the ready stamp in Windows form" + else + fail "Git Bash install: rc=$RC starts=$STARTS stamp root=[$stamp_root] output=[$OUT]" + fi + win_run "$EMPTY" + if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -eq 0 ]]; then + ok "under Git Bash a ready stamp in Windows form starts no Python" + else + fail "Git Bash ready stamp: rc=$RC starts=$STARTS stamp=[$(tr '\n' '|' <"$stamp")] output=[$OUT]" + fi + rm -f "$stamp" + win_run "$EMPTY" + if [[ "$RC" -eq 0 && "$STARTS" -gt 0 ]]; then + ok "under Git Bash without the stamp the hook starts Python, so the start log sees one" + else + fail "Git Bash without stamp: rc=$RC starts=$STARTS output=[$OUT]" + fi + echo "SKIP: the remaining cases stub interpreters with shell scripts, simulate Windows paths or need Linux namespaces" + echo "---" + echo "passed: $PASS, failed: $FAIL" + exit $((FAIL > 0)) +fi +echo "SKIP: the real Git Bash ready-stamp case needs Git Bash or Cygwin with cygpath" + # First session: installs, says nothing. root="$(new_plugin first "$digest")" data="$WORK/data-first" @@ -171,6 +222,175 @@ else fail "linux paths: rc=$RC argv=[$ARGV] output=[$OUT]" fi +# The ready stamp: pydeps.py install, started through a candidate found on PATH, writes /python/.hook-ready, +# and while it holds a session start starts no Python. Every candidate name resolves to a wrapper that logs each start +# and execs the real interpreter. Each case restores the stamp that install wrote, shows that baseline starts nothing, +# changes one input and shows the full path runs. +logged="$WORK/logged" +other="$WORK/other" +mkdir -p "$logged" "$other" +real_py="$(command -v "$py")" +for name in python3.13 python3.12 python3 python; do + printf '#!/bin/sh\necho "%s $*" >>"%s/starts.log"\nexec "%s" "$@"\n' "$name" "$WORK" "$real_py" >"$logged/$name" +done +cp "$logged/python3.13" "$other/python3.13" +chmod +x "$logged/"* "$other/"* +root="$(new_plugin stamped "$digest")" +data="$WORK/data-stamped" +stamp="$data/python/.hook-ready" +# stamp_run [PATH prefix] -> runs the hook with the wrappers first on PATH; sets OUT, RC +# and STARTS, the number of Python starts. +stamp_run() { + rm -f "$WORK/starts.log" + run_hook "$1" "$2" CLAUDE_PLUGIN_DATA="$(native "$data")" PATH="${3:-}$logged:$PATH" + STARTS=0 + [[ -f "$WORK/starts.log" ]] && STARTS="$(wc -l <"$WORK/starts.log" | tr -d '[:space:]')" +} +stamp_run "$root" "$WHEELS" +if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" ]]; then + ok "an install through a candidate on PATH writes the ready stamp" +else + fail "ready stamp write: rc=$RC stamp=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" +fi +cp "$stamp" "$WORK/good-stamp" 2>/dev/null +# restore_stamp -> the stamp the install wrote, newer than every input it checks. +restore_stamp() { + cp "$WORK/good-stamp" "$stamp" + touch -t 202001010000 "$root/requirements.txt" "$root/scripts/pydeps.py" "$root/scripts/pydeps.json" "$logged/"* "$other/"* + touch -t 202101010000 "$stamp" +} +# invalidated -> the baseline starts no Python; after the mutation the hook starts one. +invalidated() { + local what="$1" base + shift + restore_stamp + stamp_run "$root" "$WHEELS" + base="$STARTS/$RC/[$OUT]" + "$@" + if [[ "$base" == "0/0/[]" && "$STARTS" -gt 0 && "$RC" -eq 0 ]]; then + ok "while the ready stamp holds no Python starts; $what runs the full path" + else + fail "$what: baseline starts/rc/output=$base, after: starts=$STARTS rc=$RC output=[$OUT]" + fi +} +moved_root() { + mv "$root" "$root-moved" + stamp_run "$root-moved" "$WHEELS" + mv "$root-moved" "$root" +} +other_candidate() { stamp_run "$root" "$WHEELS" "$other:"; } +newer() { + touch "$1" + stamp_run "$root" "$WHEELS" +} +target_missing() { + find "$data/python" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + stamp_run "$root" "$WHEELS" +} +invalidated "another plugin root" moved_root +invalidated "a candidate resolving elsewhere" other_candidate +invalidated "a resolved candidate newer than the stamp" newer "$logged/python3" +invalidated "a newer requirements.txt" newer "$root/requirements.txt" +invalidated "a newer pydeps.py" newer "$root/scripts/pydeps.py" +invalidated "a newer pydeps.json" newer "$root/scripts/pydeps.json" +invalidated "a missing installed set" target_missing + +# One directory spelled two ways (a symlink here; an 8.3 short name or another mount on Windows): pydeps.py records +# the resolved root, the hook's ROOT keeps the spelling it was started through, and the stamp still holds. +ln -s "$root" "$WORK/stamped-link" +restore_stamp +stamp_run "$WORK/stamped-link" "$EMPTY" +if [[ "$RC" -eq 0 && -z "$OUT" && "$STARTS" -eq 0 ]]; then + ok "a plugin root reached through a symlink still matches the stamp, and no Python starts" +else + fail "symlinked plugin root: starts=$STARTS rc=$RC output=[$OUT]" +fi + +# Under Git Bash, Windows Python writes the stamp with backslashes, while the hook sees POSIX paths. A stamp in that +# shape holds under OSTYPE=msys and, paired, not elsewhere. Mounts, 8.3 names and .EXE suffixes need a Windows file +# system: the real Git Bash case covers them. No case here covers a letter-case difference. +# windows_form -> the path with backslashes. +windows_form() { printf '%s' "${1//\//\\}"; } +target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" +py_at="$(PATH="$logged:$PATH" command -v py 2>/dev/null || true)" +{ + printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(windows_form "$root")" "$(windows_form "$target")" + for name in python3.13 python3.12 python3 python; do + printf '%s=%s\n' "$name" "$(windows_form "$logged/$name")" + done + printf 'py=%s\n' "$(windows_form "$py_at")" +} >"$WORK/good-stamp" +starts_under="" +for ostype in msys linux-gnu; do + restore_stamp + touch "$stamp" # newer than a real py this PATH may hold + rm -f "$WORK/starts.log" + run_hook "$root" "$WHEELS" OSTYPE="$ostype" CLAUDE_PLUGIN_DATA="$data" PATH="$logged:$PATH" + STARTS=0 + [[ -f "$WORK/starts.log" ]] && STARTS="$(wc -l <"$WORK/starts.log" | tr -d '[:space:]')" + starts_under+="$ostype=$STARTS " +done +if [[ "$starts_under" == "msys=0 linux-gnu="[1-9]* ]]; then + ok "a stamp in Windows path form holds under Git Bash and nowhere else" +else + fail "Windows-form stamp: starts under $starts_under" +fi + +# Git Bash on Windows can report OSTYPE=cygwin while it mounts drives at /c/, not /cygdrive/c/. Inside a user and +# mount namespace whose root holds a /c directory (this suite's WORK bound there), a stamp in Windows drive-letter +# form holds under OSTYPE=cygwin and under OSTYPE=msys, and starts no Python. Skipped where unprivileged user +# namespaces are not allowed. +drive_case() { + local nr="$WORK/newroot" d + mkdir -p "$nr" + mount -t tmpfs drives "$nr" || return 1 + for d in /*; do + [[ "$d" == /proc || "$d" == /sys || "$d" == /dev ]] && continue + if [[ -L "$d" ]]; then + cp -P "$d" "$nr$d" + elif [[ -d "$d" ]]; then + mkdir "$nr$d" && mount --rbind "$d" "$nr$d" + fi + done + mkdir "$nr/proc" "$nr/dev" "$nr/c" + mount --rbind /dev "$nr/dev" + mount --rbind "$WORK/drive" "$nr/c" + chroot "$nr" /bin/bash -c "$1" +} +mkdir -p "$WORK/drive/plugin" "$WORK/drive/data/python/0123456789ab-cpython-399" "$WORK/drive/bin" +cp -R "$root/hooks" "$root/scripts" "$root/requirements.txt" "$WORK/drive/plugin/" +for name in python3.13 python3.12 python3 python; do + printf '#!/bin/sh\necho "%s $*" >>/c/starts.log\nexec "%s" "$@"\n' "$name" "$real_py" >"$WORK/drive/bin/$name" +done +chmod +x "$WORK/drive/bin/"* +{ + printf 'pydeps-hook-ready 1\nC:\\plugin\nC:\\data\\python\\0123456789ab-cpython-399\n' + for name in python3.13 python3.12 python3 python; do + printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name" + done + printf 'py=\n' +} >"$WORK/drive/data/python/.hook-ready" +touch -t 202001010000 "$WORK/drive/plugin/requirements.txt" "$WORK/drive/plugin/scripts/"* "$WORK/drive/bin/"* +drive_starts="" +for ostype in cygwin msys; do + rm -f "$WORK/drive/starts.log" + # shellcheck disable=SC2016 # expands inside the namespace + if ! unshare -rm bash -c "$(declare -f drive_case); WORK='$WORK'; drive_case 'OSTYPE=$ostype CLAUDE_PLUGIN_DATA=/c/data PATH=/c/bin:/usr/bin:/bin bash /c/plugin/hooks/install-python-deps.sh /dev/null 2>&1'" 2>/dev/null; then + drive_starts="unavailable" + break + fi + n=0 + [[ -f "$WORK/drive/starts.log" ]] && n="$(wc -l <"$WORK/drive/starts.log" | tr -d '[:space:]')" + drive_starts+="$ostype=$n " +done +if [[ "$drive_starts" == unavailable ]]; then + echo "SKIP: no unprivileged user and mount namespace here -- the /c/ drive-mount case is skipped" +elif [[ "$drive_starts" == "cygwin=0 msys=0 " ]]; then + ok "a drive-letter stamp holds where drives mount at /c/, whether the shell reports cygwin or msys" +else + fail "drive-letter stamp under a /c/ mount: starts $drive_starts" +fi + echo "---" echo "passed: $PASS, failed: $FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/plugins/speech/scripts/pydeps.py b/plugins/speech/scripts/pydeps.py index 0ae952f519e..9358fc1ab57 100755 --- a/plugins/speech/scripts/pydeps.py +++ b/plugins/speech/scripts/pydeps.py @@ -8,7 +8,10 @@ usage: pydeps.py install [--data-dir DIR] install requirements.txt unless already ready; the SessionStart hook runs this, and so does the repair line pydeps.py run [--data-dir DIR] -- SCRIPT ... run SCRIPT against the installed packages; never installs - pydeps.py check [--data-dir DIR] read-only PASS/FAIL rows: Python, the packages, any tools + pydeps.py check [--data-dir DIR] PASS/FAIL rows: Python, the packages, any tools; installs nothing + +A successful install with an interpreter found on PATH writes /python/.hook-ready, which lets the hook skip +starting Python while nothing it records has changed; run and check drop it when the set fails them. exit codes: install 0 ready or installed, 1 broken (reason and repair line on stderr); run 2 packages not installed (repair line on stderr), else the script's own code; @@ -55,6 +58,9 @@ def _version(text): CHECK_LABEL = _CONFIG.get('check', {}).get('label', 'Python packages') CHECK_TOOLS = dict(_CONFIG.get('check', {}).get('tools', {})) # tool -> what to do when it is missing HANDED_OVER = re.sub(r'[^A-Z0-9]', '_', PLUGIN.upper()) + '_PYDEPS_INTERPRETER' +# The hook's own candidate list, in its order; the ready stamp records what each resolves to. +HOOK_CANDIDATES = ('python3.13', 'python3.12', 'python3', 'python') +STAMP_TAG = 'pydeps-hook-ready 1' class Broken(Exception): @@ -196,19 +202,65 @@ def install(data, requirements=REQUIREMENTS, probe=PROBE): shutil.rmtree(partial, ignore_errors=True) +def _which(name): + """shutil.which, which on Windows searches the working directory first unless NoDefaultCurrentDirectoryInExePath + is set, so a python3.bat in the project would run as the interpreter.""" + if sys.platform == 'win32': + os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + return shutil.which(name) + + +def ready_stamp(data): + return Path(data) / 'python' / '.hook-ready' + + +def write_ready_stamp(data, target): + """Record, one value per line, what the hook checks before it skips starting Python: this plugin root, the + installed set, and what each of the hook's candidate names resolves to on PATH. A changed lock shows as a newer + requirements.txt or another root; a different interpreter as another resolution or a newer resolved file. + Written atomically; a failed write leaves the hook on its full path.""" + stamp = ready_stamp(data) + names = (*HOOK_CANDIDATES, *(('py',) if sys.platform == 'win32' else ())) + lines = [STAMP_TAG, str(Path(__file__).resolve().parent.parent), str(target), + *(f'{name}={_which(name) or ""}' for name in names)] + partial = stamp.with_name(f'{stamp.name}.{os.getpid()}') + try: + partial.write_text('\n'.join(lines) + '\n', encoding='utf-8', newline='\n') + os.replace(partial, stamp) + except OSError: + partial.unlink(missing_ok=True) + + +def drop_ready_stamp(data): + """The set failed a run or check: the next session start probes it again, and rebuilds it if it does not load.""" + try: + ready_stamp(data).unlink(missing_ok=True) + except OSError: + pass + + def run(data, script_args, requirements=REQUIREMENTS): """Run a script with the installed packages on PYTHONPATH. Never installs: that is the hook's job.""" target = env_dir(data, requirements) if not target.is_dir(): + drop_ready_stamp(data) sys.stderr.write(f'{PLUGIN}: the Python packages are not installed for {sys.implementation.cache_tag}. ' 'Start a new Claude Code session, whose SessionStart hook installs them, or run: ' f'{repair_line(data)}\n') return 2 - return subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode + if code and ready_stamp(data).exists(): + # The hook trusted the stamp and skipped the load probe, so the set may no longer import. + drop_ready_stamp(data) + sys.stderr.write(f'{PLUGIN}: the run failed; if the Python packages no longer load, the next Claude Code ' + 'session start re-probes them and rebuilds them, or run now: ' + f'{repair_line(data)}\n') + return code def check(data, requirements=REQUIREMENTS, probe=PROBE): - """Read-only: one PASS/FAIL row per prerequisite. Returns the exit code, 1 when any row fails.""" + """One PASS/FAIL row per prerequisite; installs nothing, and drops the ready stamp when a row fails. Returns the + exit code, 1 when any row fails.""" target = env_dir(data, requirements) rows = [('PASS', f'Python {sys.version_info[0]}.{sys.version_info[1]}', sys.executable)] if target.is_dir() and loads(target, probe): @@ -221,7 +273,10 @@ def check(data, requirements=REQUIREMENTS, probe=PROBE): rows.append(('PASS', tool, found) if found else ('FAIL', tool, f'not on PATH; {missing}')) for row in rows: print(' '.join(row)) - return 1 if any(r[0] == 'FAIL' for r in rows) else 0 + if any(r[0] == 'FAIL' for r in rows): + drop_ready_stamp(data) + return 1 + return 0 def _runs_supported(path): @@ -255,13 +310,9 @@ def _launcher_listed(): def interpreter(): """The first supported interpreter: CANDIDATES on PATH in order, then on Windows each one the py launcher lists. - The hook and every run hand over to it, so both resolve the same directory. On Windows, - shutil.which searches the working directory first unless NoDefaultCurrentDirectoryInExePath is set, so a - python3.bat in the project would run as the interpreter.""" - if sys.platform == 'win32': - os.environ['NoDefaultCurrentDirectoryInExePath'] = '1' + The hook and every run hand over to it, so both resolve the same directory.""" for name in CANDIDATES: - found = shutil.which(name) + found = _which(name) if not found or ('windowsapps' in found.lower() and Path(found).stat().st_size == 0): continue if _runs_supported(found): @@ -271,7 +322,8 @@ def interpreter(): def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) - if not os.environ.get(HANDED_OVER): + chosen = os.environ.get(HANDED_OVER) + if not chosen: chosen = interpreter() if chosen and Path(chosen).resolve() != Path(sys.executable).resolve(): return subprocess.run([chosen, str(Path(__file__).resolve()), *argv], @@ -304,10 +356,16 @@ def main(argv=None): return check(data, a.requirements, a.probe) try: print(install(data, a.requirements, a.probe)) - return 0 except Broken as e: sys.stderr.write(f'{PLUGIN}: {e}\nRepair with: {repair_line(data)}\n') return 1 + # Only the plugin's own lock, and only an interpreter found through a name the hook checks: one the py launcher + # listed, or one named outside HOOK_CANDIDATES (python3.14 for a plugin allowing 3.14), could change without + # the hook seeing it, so no stamp, and the hook keeps its full path. + if (a.requirements.resolve() == REQUIREMENTS and tuple(a.probe) == PROBE + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): + write_ready_stamp(data, env_dir(data, a.requirements)) + return 0 if __name__ == '__main__': diff --git a/plugins/speech/scripts/test_speech_pydeps.py b/plugins/speech/scripts/test_speech_pydeps.py index 5aaab54d908..e657a346dab 100644 --- a/plugins/speech/scripts/test_speech_pydeps.py +++ b/plugins/speech/scripts/test_speech_pydeps.py @@ -6,8 +6,11 @@ prints its sha256, for the hook suite (../hooks/install-python-deps.test.sh). """ import base64 +import contextlib import hashlib import importlib.util +import io +import json import os import re import shutil @@ -15,6 +18,7 @@ import sys import tempfile import unittest +from unittest import mock import zipfile from pathlib import Path @@ -226,6 +230,117 @@ def test_no_skill_or_script_fetches_a_package(self): self.assertEqual(found, []) +@unittest.skipUnless(os.name == 'posix' and sys.version_info >= (3, 12) and have_pip(), + 'needs a POSIX Python 3.12 or later with pip: the PATH candidate is a symlink') +class ReadyStamp(unittest.TestCase): + """The ready stamp lets the SessionStart hook skip starting Python. Only an install through an interpreter the + hook can see on PATH writes it; a run or check that finds the set broken drops it.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(tmp.name) + self.root = self.tmp / 'plugin' + (self.root / 'scripts').mkdir(parents=True) + for name in ('pydeps.py', 'pydeps.json'): + shutil.copy(HERE / name, self.root / 'scripts' / name) + wheels = self.tmp / 'wheels' + wheels.mkdir() + _, digest = make_wheel(wheels) + self.lock = self.root / 'requirements.txt' + self.lock.write_text(lock_text(digest), encoding='utf-8') + self.pip = {'PIP_NO_INDEX': '1', 'PIP_FIND_LINKS': str(wheels)} + self.bin = self.tmp / 'bin' + self.bin.mkdir() + self.data = self.tmp / 'data' + self.stamp = self.data / 'python' / '.hook-ready' + self.pydeps = load_pydeps() + + def install_as(self, chosen): + """pydeps.py install in the plugin copy, as the child handed over to `chosen`, with only self.bin on PATH.""" + r = subprocess.run([sys.executable, str(self.root / 'scripts' / 'pydeps.py'), 'install', '--data-dir', str(self.data)], capture_output=True, text=True, cwd=self.root / 'scripts', + env={**os.environ, **self.pip, 'PATH': str(self.bin), 'SPEECH_PYDEPS_INTERPRETER': str(chosen)}) + self.assertEqual((r.returncode, r.stdout.strip()), (0, 'installed'), r.stderr) + + def install_here(self): + with mock.patch.dict(os.environ, self.pip): + self.pydeps.install(self.data, self.lock) + + def stamped(self): + self.stamp.parent.mkdir(parents=True, exist_ok=True) + self.stamp.write_text('pydeps-hook-ready 1\n', encoding='utf-8') + + def test_an_install_through_a_candidate_on_path_writes_the_stamp_the_hook_reads(self): + python3 = self.bin / 'python3' + python3.symlink_to(sys.executable) + self.install_as(python3) + (target,) = [p for p in (self.data / 'python').iterdir() if p.is_dir()] + self.assertEqual(self.stamp.read_text(encoding='utf-8').splitlines(), [ + 'pydeps-hook-ready 1', str(self.root.resolve()), str(target), + 'python3.13=', 'python3.12=', f'python3={python3}', 'python=']) + + def test_an_interpreter_only_the_launcher_listed_writes_no_stamp(self): + self.install_as(sys.executable) # not on PATH, as a python.exe that only py -0p lists + self.assertEqual(len([p for p in (self.data / 'python').iterdir() if p.is_dir()]), 1) + self.assertFalse(self.stamp.exists()) + + def test_an_interpreter_named_outside_the_hooks_candidates_writes_no_stamp(self): + # A plugin allowing 3.14 chooses python3.14, which the hook never checks, so a change to it could not + # invalidate a stamp. + config_path = self.root / 'scripts' / 'pydeps.json' + config = json.loads(config_path.read_text(encoding='utf-8')) + config.pop('python_min', None) + config['pythons'] = sorted({f'{sys.version_info[0]}.{sys.version_info[1]}', '3.14'}) + config_path.write_text(json.dumps(config), encoding='utf-8') + python314 = self.bin / 'python3.14' + python314.symlink_to(sys.executable) + self.install_as(python314) + self.assertEqual(len([p for p in (self.data / 'python').iterdir() if p.is_dir()]), 1) + self.assertFalse(self.stamp.exists()) + + def test_run_drops_the_stamp_when_the_set_is_missing_or_the_script_fails(self): + script = self.tmp / 'exit.py' + script.write_text('import sys\nsys.exit(int(sys.argv[1]))\n', encoding='utf-8') + self.stamped() + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 2) + self.assertFalse(self.stamp.exists()) + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 2) # no stamp to drop + self.install_here() + self.stamped() + self.assertEqual(self.pydeps.run(self.data, [str(script), '0'], self.lock), 0) + self.assertTrue(self.stamp.exists()) + self.assertEqual(self.pydeps.run(self.data, [str(script), '3'], self.lock), 3) + self.assertFalse(self.stamp.exists()) + + def test_a_failed_run_with_a_stamp_prints_the_repair_line(self): + # A stamped set that stops importing fails the skill's script; the hook skipped the probe, so the run says + # how to repair it. Without a stamp a failing script is the script's own business. + script = self.tmp / 'fail.py' + script.write_text('import sys\nsys.exit(1)\n', encoding='utf-8') + self.install_here() + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.pydeps.run(self.data, [str(script)], self.lock), 1) + self.assertEqual(err.getvalue(), '') + self.stamped() + err = io.StringIO() + with contextlib.redirect_stderr(err): + self.assertEqual(self.pydeps.run(self.data, [str(script)], self.lock), 1) + self.assertIn(self.pydeps.repair_line(self.data), err.getvalue()) + self.assertFalse(self.stamp.exists()) + + def test_check_drops_the_stamp_when_a_row_fails(self): + self.stamped() + with contextlib.redirect_stdout(io.StringIO()), mock.patch.object(self.pydeps, 'CHECK_TOOLS', {}): + self.assertEqual(self.pydeps.check(self.data, self.lock), 1) + self.assertFalse(self.stamp.exists()) + self.install_here() + self.stamped() + self.assertEqual(self.pydeps.check(self.data, self.lock), 0) + self.assertTrue(self.stamp.exists()) + + if __name__ == '__main__': if len(sys.argv) == 3 and sys.argv[1] == '--make-wheel': print(make_wheel(sys.argv[2])[1]) diff --git a/scripts/test-windows-plan.txt b/scripts/test-windows-plan.txt index 20fa56e1985..9ea5d066643 100644 --- a/scripts/test-windows-plan.txt +++ b/scripts/test-windows-plan.txt @@ -28,6 +28,9 @@ test-windows-hooks scripts/check-exec-form-windows-probe.sh scripts/check-exec-f test-windows-hooks lib/exec-bash.resolver.test.mjs lib/exec-bash.resolver.test.mjs lib/exec-bash.resolver.test.sh test-windows-hooks lib/write-snapshot.test.mjs lib/write-snapshot.test.mjs lib/write-snapshot.test.sh test-windows-hooks plugins/developer-experience/skills/setup/scripts/pointer-line.test.sh +test-windows-hooks plugins/animation/hooks/install-python-deps.test.sh plugins/animation/hooks/install-python-deps.test.sh plugins/animation/scripts/test_pydeps.py +test-windows-hooks plugins/speech/hooks/install-python-deps.test.sh plugins/speech/hooks/install-python-deps.test.sh plugins/speech/scripts/test_speech_pydeps.py +test-windows-hooks plugins/explainer-video/hooks/install-python-deps.test.sh plugins/explainer-video/hooks/install-python-deps.test.sh plugins/explainer-video/scripts/test_explainer_video_pydeps.py test-windows-lib plugins/guardrails/hooks/block-windows-drive-tmp.test.sh test-windows-lib lib/hook-utils.test.sh test-windows-lib plugins/code-metrics/scripts/python-resolve.test.sh