From d8b46fab5c8fa7d5b7ff70c89fe82590a00db614 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:50:23 -0500 Subject: [PATCH 01/10] perf(animation,speech,explainer-video): skip the pydeps hook's probes while its ready stamp holds The install-python-deps SessionStart hook started about six processes on the ready path (interpreter probe, cygpath twice, pydeps.py, its interpreter probe and the load probe), which set the SessionStart critical path on Windows. - lib/pydeps.py: after a successful install through an interpreter found on PATH (never one only the py launcher listed), write /python/.hook-ready atomically: a format tag, the plugin root, the installed directory and what each of the hook's candidate names resolves to. run drops it when the set is missing or a script exits non-zero; check drops it when a row fails. - lib/install-python-deps.sh: before any probe, exit 0 when the stamp holds, using shell builtins only (hash/BASH_CMDS for the PATH lookup, pure string drive-letter conversion, -nt and -d). Anything else takes the full path. shfmt now applies the repository's switch_case_indent setting to the file. - Copies regenerated with scripts/sync-shared-copies.sh. - Tests in each plugin: the hook starts no Python while the stamp holds and takes the full path on each invalidation; pydeps.py writes the stamp only through a PATH candidate and run and check drop it. - on-demand-dependencies contract 2.4.0: Rules P2-P4 name the stamp, what invalidates it, and the residual repointed-symlink hole. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- ...-12258-pydeps-hook-ready-stamp-a3f19c42.md | 7 ++ ...-12258-pydeps-hook-ready-stamp-c94e2a61.md | 7 ++ ...-12258-pydeps-hook-ready-stamp-5be07d18.md | 7 ++ .../on-demand-dependencies/CHANGELOG.md | 12 ++ .../on-demand-dependencies/README.md | 27 +++- lib/install-python-deps.sh | 118 ++++++++++++++---- lib/pydeps.py | 77 ++++++++++-- .../animation/hooks/install-python-deps.sh | 118 ++++++++++++++---- .../hooks/install-python-deps.test.sh | 73 +++++++++++ plugins/animation/scripts/pydeps.py | 77 ++++++++++-- plugins/animation/scripts/test_pydeps.py | 82 ++++++++++++ .../hooks/install-python-deps.sh | 118 ++++++++++++++---- .../hooks/install-python-deps.test.sh | 73 +++++++++++ plugins/explainer-video/scripts/pydeps.py | 77 ++++++++++-- .../scripts/test_explainer_video_pydeps.py | 81 ++++++++++++ plugins/speech/hooks/install-python-deps.sh | 118 ++++++++++++++---- .../speech/hooks/install-python-deps.test.sh | 73 +++++++++++ plugins/speech/scripts/pydeps.py | 77 ++++++++++-- plugins/speech/scripts/test_speech_pydeps.py | 83 ++++++++++++ 19 files changed, 1153 insertions(+), 152 deletions(-) create mode 100644 .changes/animation/perf-12258-pydeps-hook-ready-stamp-a3f19c42.md create mode 100644 .changes/explainer-video/perf-12258-pydeps-hook-ready-stamp-c94e2a61.md create mode 100644 .changes/speech/perf-12258-pydeps-hook-ready-stamp-5be07d18.md 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/docs/contracts/on-demand-dependencies/CHANGELOG.md b/docs/contracts/on-demand-dependencies/CHANGELOG.md index 0ab6ead5bf9..0080e1bce6c 100644 --- a/docs/contracts/on-demand-dependencies/CHANGELOG.md +++ b/docs/contracts/on-demand-dependencies/CHANGELOG.md @@ -4,6 +4,18 @@ 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 + ([#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. The residual hole, a candidate symlink repointed to an + older interpreter file, is 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..955b7071467 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,16 @@ 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 elsewhere, a resolved interpreter file, `requirements.txt`, `pydeps.py` or +`pydeps.json` newer than the stamp, or the installed directory missing. + **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 +158,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. @@ -160,7 +172,14 @@ the hook emits a notice on both channels (`systemMessage` and `additionalContext is the installer run in the foreground with the same interpreter and data directory (POSIX shell, or Windows PowerShell 5.1 on Windows, as in Rule 3). The hook exits 0, so a failed install never blocks the session, and it leaves no partial directory. A launcher run -with no installed set prints the same line and exits 2. Neither falls back to another interpreter's +with no installed set prints the same line and exits 2. + +A stamped set that breaks surfaces when a skill runs, not at session start: the launcher prints the +repair line for a missing set and drops the ready stamp, as it does when a script exits non-zero +and as `check` does when a row fails, so the next session start probes and rebuilds. One hole +remains: a candidate symlink repointed to an interpreter file older than the stamp passes both the +resolution and the newer-file checks, and the hook keeps trusting the stamp until a failed skill +run drops it. Neither falls back to another interpreter's packages, installs on its own, or reports a partial result as complete. In an interactive session 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). diff --git a/lib/install-python-deps.sh b/lib/install-python-deps.sh index 7a197aa84c0..925fdc31e6d 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,77 @@ 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" + [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + fi + ;; + *) ;; + esac +} + +# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a +# command -v result may drop the .exe that Python's shutil.which keeps. +path_key() { + as_posix "$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY,,}" + REPLY="${REPLY%.exe}" + ;; + *) ;; + 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 where it did, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# 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 want i=3 f + as_posix "$data" + 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 + path_key "${lines[1]}" + want="$REPLY" + path_key "$ROOT" + [[ "$want" == "$REPLY" ]] || return 1 + as_posix "${lines[2]}" + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + path_key "${lines[i]#*=}" + want="$REPLY" + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + path_key "$got" + [[ "$want" == "$REPLY" ]] || return 1 + [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 +144,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 +166,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..fcaaace3580 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,61 @@ 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: + drop_ready_stamp(data) + 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 +267,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 +304,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 +316,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 +350,15 @@ 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 the hook can see on PATH: one the py launcher listed is + # chosen by a listing the hook does not read, 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 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..7f03a07c5e5 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,77 @@ 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" + [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + fi + ;; + *) ;; + esac +} + +# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a +# command -v result may drop the .exe that Python's shutil.which keeps. +path_key() { + as_posix "$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY,,}" + REPLY="${REPLY%.exe}" + ;; + *) ;; + 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 where it did, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# 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 want i=3 f + as_posix "$data" + 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 + path_key "${lines[1]}" + want="$REPLY" + path_key "$ROOT" + [[ "$want" == "$REPLY" ]] || return 1 + as_posix "${lines[2]}" + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + path_key "${lines[i]#*=}" + want="$REPLY" + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + path_key "$got" + [[ "$want" == "$REPLY" ]] || return 1 + [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 +146,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 +168,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..d7292e8896f 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -186,6 +186,79 @@ 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 + 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..346d91339cf 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,61 @@ 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: + drop_ready_stamp(data) + 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 +269,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 +306,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 +318,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 +352,15 @@ 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 the hook can see on PATH: one the py launcher listed is + # chosen by a listing the hook does not read, 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 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..56b4ed5baf0 100644 --- a/plugins/animation/scripts/test_pydeps.py +++ b/plugins/animation/scripts/test_pydeps.py @@ -6,7 +6,9 @@ prints its sha256, for the hook suite (../hooks/install-python-deps.test.sh). """ import base64 +import contextlib import hashlib +import io import os import re import shutil @@ -285,6 +287,86 @@ 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_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_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..7f03a07c5e5 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,77 @@ 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" + [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + fi + ;; + *) ;; + esac +} + +# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a +# command -v result may drop the .exe that Python's shutil.which keeps. +path_key() { + as_posix "$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY,,}" + REPLY="${REPLY%.exe}" + ;; + *) ;; + 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 where it did, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# 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 want i=3 f + as_posix "$data" + 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 + path_key "${lines[1]}" + want="$REPLY" + path_key "$ROOT" + [[ "$want" == "$REPLY" ]] || return 1 + as_posix "${lines[2]}" + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + path_key "${lines[i]#*=}" + want="$REPLY" + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + path_key "$got" + [[ "$want" == "$REPLY" ]] || return 1 + [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 +146,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 +168,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..7f36425bf63 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -281,6 +281,79 @@ 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 + 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..346d91339cf 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,61 @@ 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: + drop_ready_stamp(data) + 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 +269,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 +306,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 +318,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 +352,15 @@ 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 the hook can see on PATH: one the py launcher listed is + # chosen by a listing the hook does not read, 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 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..56ea2e69a61 100644 --- a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py +++ b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py @@ -8,9 +8,11 @@ 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 os import re import shutil @@ -393,6 +395,85 @@ 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_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_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..7f03a07c5e5 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,77 @@ 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +as_posix() { + REPLY="$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY//\\//}" + if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then + REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" + [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + fi + ;; + *) ;; + esac +} + +# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a +# command -v result may drop the .exe that Python's shutil.which keeps. +path_key() { + as_posix "$1" + case "${OSTYPE:-}" in + msys* | cygwin*) + REPLY="${REPLY,,}" + REPLY="${REPLY%.exe}" + ;; + *) ;; + 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 where it did, none of +# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present. +# 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 want i=3 f + as_posix "$data" + 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 + path_key "${lines[1]}" + want="$REPLY" + path_key "$ROOT" + [[ "$want" == "$REPLY" ]] || return 1 + as_posix "${lines[2]}" + [[ -d "$REPLY" ]] || return 1 + for name in "${candidates[@]}"; do + [[ "${lines[i]%%=*}" == "$name" ]] || return 1 + path_key "${lines[i]#*=}" + want="$REPLY" + got="" + hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" + path_key "$got" + [[ "$want" == "$REPLY" ]] || return 1 + [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 +146,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 +168,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..b191613f1de 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -171,6 +171,79 @@ 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 + 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..346d91339cf 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,61 @@ 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: + drop_ready_stamp(data) + 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 +269,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 +306,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 +318,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 +352,15 @@ 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 the hook can see on PATH: one the py launcher listed is + # chosen by a listing the hook does not read, 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 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..935bfe8fff5 100644 --- a/plugins/speech/scripts/test_speech_pydeps.py +++ b/plugins/speech/scripts/test_speech_pydeps.py @@ -6,8 +6,10 @@ 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 os import re import shutil @@ -15,6 +17,7 @@ import sys import tempfile import unittest +from unittest import mock import zipfile from pathlib import Path @@ -226,6 +229,86 @@ 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_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_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]) From 4783e6f0e26b21ac4ce4350ff6525c4d688ae8c2 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:54:30 -0500 Subject: [PATCH 02/10] test(animation,speech,explainer-video): prove a Windows-form ready stamp holds under Git Bash A stamp written the way native Windows Python writes it (backslashes, its own letter case, .EXE suffixes) holds under OSTYPE=msys and starts no Python; the same stamp under linux-gnu takes the full path, so the case and suffix folding applies only under Git Bash and Cygwin. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- .../hooks/install-python-deps.test.sh | 33 +++++++++++++++++++ .../hooks/install-python-deps.test.sh | 33 +++++++++++++++++++ .../speech/hooks/install-python-deps.test.sh | 33 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index d7292e8896f..337c293956b 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -259,6 +259,39 @@ 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 +# Under Git Bash, Windows Python writes the stamp with backslashes, its own letter case and .EXE suffixes, while the +# hook sees POSIX paths without them. A stamp in that shape holds under OSTYPE=msys and, paired, not elsewhere. +# windows_form -> the path with backslashes; upper-cased with .EXE appended when a second argument is given. +windows_form() { + local p="${1//\//\\}" + [[ -n "${2:-}" && -n "$p" ]] && p="${p^^}.EXE" + printf '%s' "$p" +} +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" upper)" + done + printf 'py=%s\n' "$(windows_form "$py_at" upper)" +} >"$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 + echo "---" echo "passed: $PASS, failed: $FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index 7f36425bf63..e513b04b790 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -354,6 +354,39 @@ 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 +# Under Git Bash, Windows Python writes the stamp with backslashes, its own letter case and .EXE suffixes, while the +# hook sees POSIX paths without them. A stamp in that shape holds under OSTYPE=msys and, paired, not elsewhere. +# windows_form -> the path with backslashes; upper-cased with .EXE appended when a second argument is given. +windows_form() { + local p="${1//\//\\}" + [[ -n "${2:-}" && -n "$p" ]] && p="${p^^}.EXE" + printf '%s' "$p" +} +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" upper)" + done + printf 'py=%s\n' "$(windows_form "$py_at" upper)" +} >"$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 + echo "---" echo "passed: $PASS, failed: $FAIL" [[ "$FAIL" -eq 0 ]] diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index b191613f1de..a5eb14850c8 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -244,6 +244,39 @@ 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 +# Under Git Bash, Windows Python writes the stamp with backslashes, its own letter case and .EXE suffixes, while the +# hook sees POSIX paths without them. A stamp in that shape holds under OSTYPE=msys and, paired, not elsewhere. +# windows_form -> the path with backslashes; upper-cased with .EXE appended when a second argument is given. +windows_form() { + local p="${1//\//\\}" + [[ -n "${2:-}" && -n "$p" ]] && p="${p^^}.EXE" + printf '%s' "$p" +} +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" upper)" + done + printf 'py=%s\n' "$(windows_form "$py_at" upper)" +} >"$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 + echo "---" echo "passed: $PASS, failed: $FAIL" [[ "$FAIL" -eq 0 ]] From 7d07e92d380b63473485f9186b27ef5c293238d1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:59:28 -0500 Subject: [PATCH 03/10] fix(animation,speech,explainer-video): find the drive mount by directory, not OSTYPE Windows Git Bash on the laptop lane reports OSTYPE=cygwin yet mounts drives at /c/, so the fast path turned the stamp's C:\ root into /cygdrive/c/... and never matched the hook's /c/... root. as_posix now uses // when that directory exists, else /cygdrive//, and fails when neither does, which sends the hook down the full path. The new hook test case runs the hook in a user and mount namespace whose root holds /c: a drive-letter stamp holds under OSTYPE=cygwin and msys. Before this fix it started Python under cygwin (starts cygwin=1 msys=0). The case skips where unprivileged user namespaces are not allowed. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- lib/install-python-deps.sh | 30 ++++++---- .../animation/hooks/install-python-deps.sh | 30 ++++++---- .../hooks/install-python-deps.test.sh | 55 +++++++++++++++++++ .../hooks/install-python-deps.sh | 30 ++++++---- .../hooks/install-python-deps.test.sh | 55 +++++++++++++++++++ plugins/speech/hooks/install-python-deps.sh | 30 ++++++---- .../speech/hooks/install-python-deps.test.sh | 55 +++++++++++++++++++ 7 files changed, 241 insertions(+), 44 deletions(-) diff --git a/lib/install-python-deps.sh b/lib/install-python-deps.sh index 925fdc31e6d..856786d8deb 100755 --- a/lib/install-python-deps.sh +++ b/lib/install-python-deps.sh @@ -55,15 +55,23 @@ case "${OSTYPE:-}" in 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +# 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 - REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" - [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + 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 ;; *) ;; @@ -71,9 +79,9 @@ as_posix() { } # path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. +# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. path_key() { - as_posix "$1" + as_posix "$1" || return 1 case "${OSTYPE:-}" in msys* | cygwin*) REPLY="${REPLY,,}" @@ -91,25 +99,25 @@ path_key() { stamp_holds() { ((BASH_VERSINFO[0] >= 4)) || return 1 local stamp lines name got want i=3 f - as_posix "$data" + 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 - path_key "${lines[1]}" + path_key "${lines[1]}" || return 1 want="$REPLY" - path_key "$ROOT" + path_key "$ROOT" || return 1 [[ "$want" == "$REPLY" ]] || return 1 - as_posix "${lines[2]}" + as_posix "${lines[2]}" || return 1 [[ -d "$REPLY" ]] || return 1 for name in "${candidates[@]}"; do [[ "${lines[i]%%=*}" == "$name" ]] || return 1 - path_key "${lines[i]#*=}" + path_key "${lines[i]#*=}" || return 1 want="$REPLY" got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" + path_key "$got" || return 1 [[ "$want" == "$REPLY" ]] || return 1 [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 i=$((i + 1)) diff --git a/plugins/animation/hooks/install-python-deps.sh b/plugins/animation/hooks/install-python-deps.sh index 7f03a07c5e5..954b2d657dd 100755 --- a/plugins/animation/hooks/install-python-deps.sh +++ b/plugins/animation/hooks/install-python-deps.sh @@ -57,15 +57,23 @@ case "${OSTYPE:-}" in 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +# 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 - REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" - [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + 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 ;; *) ;; @@ -73,9 +81,9 @@ as_posix() { } # path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. +# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. path_key() { - as_posix "$1" + as_posix "$1" || return 1 case "${OSTYPE:-}" in msys* | cygwin*) REPLY="${REPLY,,}" @@ -93,25 +101,25 @@ path_key() { stamp_holds() { ((BASH_VERSINFO[0] >= 4)) || return 1 local stamp lines name got want i=3 f - as_posix "$data" + 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 - path_key "${lines[1]}" + path_key "${lines[1]}" || return 1 want="$REPLY" - path_key "$ROOT" + path_key "$ROOT" || return 1 [[ "$want" == "$REPLY" ]] || return 1 - as_posix "${lines[2]}" + as_posix "${lines[2]}" || return 1 [[ -d "$REPLY" ]] || return 1 for name in "${candidates[@]}"; do [[ "${lines[i]%%=*}" == "$name" ]] || return 1 - path_key "${lines[i]#*=}" + path_key "${lines[i]#*=}" || return 1 want="$REPLY" got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" + path_key "$got" || return 1 [[ "$want" == "$REPLY" ]] || return 1 [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 i=$((i + 1)) diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index 337c293956b..a12d6f3ffd2 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -292,6 +292,61 @@ 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:\\BIN\\%s.EXE\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/hooks/install-python-deps.sh b/plugins/explainer-video/hooks/install-python-deps.sh index 7f03a07c5e5..954b2d657dd 100755 --- a/plugins/explainer-video/hooks/install-python-deps.sh +++ b/plugins/explainer-video/hooks/install-python-deps.sh @@ -57,15 +57,23 @@ case "${OSTYPE:-}" in 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +# 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 - REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" - [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + 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 ;; *) ;; @@ -73,9 +81,9 @@ as_posix() { } # path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. +# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. path_key() { - as_posix "$1" + as_posix "$1" || return 1 case "${OSTYPE:-}" in msys* | cygwin*) REPLY="${REPLY,,}" @@ -93,25 +101,25 @@ path_key() { stamp_holds() { ((BASH_VERSINFO[0] >= 4)) || return 1 local stamp lines name got want i=3 f - as_posix "$data" + 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 - path_key "${lines[1]}" + path_key "${lines[1]}" || return 1 want="$REPLY" - path_key "$ROOT" + path_key "$ROOT" || return 1 [[ "$want" == "$REPLY" ]] || return 1 - as_posix "${lines[2]}" + as_posix "${lines[2]}" || return 1 [[ -d "$REPLY" ]] || return 1 for name in "${candidates[@]}"; do [[ "${lines[i]%%=*}" == "$name" ]] || return 1 - path_key "${lines[i]#*=}" + path_key "${lines[i]#*=}" || return 1 want="$REPLY" got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" + path_key "$got" || return 1 [[ "$want" == "$REPLY" ]] || return 1 [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 i=$((i + 1)) diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index e513b04b790..070f26c7fc6 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -387,6 +387,61 @@ 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:\\BIN\\%s.EXE\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/hooks/install-python-deps.sh b/plugins/speech/hooks/install-python-deps.sh index 7f03a07c5e5..954b2d657dd 100755 --- a/plugins/speech/hooks/install-python-deps.sh +++ b/plugins/speech/hooks/install-python-deps.sh @@ -57,15 +57,23 @@ case "${OSTYPE:-}" in 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 string operations alone: cygpath would cost a process start. Any other path is returned as is. +# 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 - REPLY="/${BASH_REMATCH[1],,}${BASH_REMATCH[2]}" - [[ "$OSTYPE" == cygwin* ]] && REPLY="/cygdrive$REPLY" + 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 ;; *) ;; @@ -73,9 +81,9 @@ as_posix() { } # path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. +# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. path_key() { - as_posix "$1" + as_posix "$1" || return 1 case "${OSTYPE:-}" in msys* | cygwin*) REPLY="${REPLY,,}" @@ -93,25 +101,25 @@ path_key() { stamp_holds() { ((BASH_VERSINFO[0] >= 4)) || return 1 local stamp lines name got want i=3 f - as_posix "$data" + 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 - path_key "${lines[1]}" + path_key "${lines[1]}" || return 1 want="$REPLY" - path_key "$ROOT" + path_key "$ROOT" || return 1 [[ "$want" == "$REPLY" ]] || return 1 - as_posix "${lines[2]}" + as_posix "${lines[2]}" || return 1 [[ -d "$REPLY" ]] || return 1 for name in "${candidates[@]}"; do [[ "${lines[i]%%=*}" == "$name" ]] || return 1 - path_key "${lines[i]#*=}" + path_key "${lines[i]#*=}" || return 1 want="$REPLY" got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" + path_key "$got" || return 1 [[ "$want" == "$REPLY" ]] || return 1 [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 i=$((i + 1)) diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index a5eb14850c8..b4c7617a3d2 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -277,6 +277,61 @@ 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:\\BIN\\%s.EXE\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 ]] From 5270798ac355a36637bfc6e05fe7ec8b66a7ef4f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:07:50 -0500 Subject: [PATCH 04/10] fix(animation,speech,explainer-video): stamp only interpreters found through the hook's names pydeps.py wrote the ready stamp when the chosen interpreter matched any name in CANDIDATES, which pydeps.json can extend (python3.14 for a plugin allowing 3.14). The stamp and the hook check only HOOK_CANDIDATES, so a later change to such an interpreter would not invalidate the stamp. The stamp is now written only when the chosen path is a HOOK_CANDIDATES resolution. A new test in each plugin shows an interpreter chosen through python3.14 writes no stamp. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- lib/pydeps.py | 7 ++++--- plugins/animation/scripts/pydeps.py | 7 ++++--- plugins/animation/scripts/test_pydeps.py | 15 +++++++++++++++ plugins/explainer-video/scripts/pydeps.py | 7 ++++--- .../scripts/test_explainer_video_pydeps.py | 15 +++++++++++++++ plugins/speech/scripts/pydeps.py | 7 ++++--- plugins/speech/scripts/test_speech_pydeps.py | 15 +++++++++++++++ 7 files changed, 61 insertions(+), 12 deletions(-) diff --git a/lib/pydeps.py b/lib/pydeps.py index fcaaace3580..c285bac6d6a 100755 --- a/lib/pydeps.py +++ b/lib/pydeps.py @@ -353,10 +353,11 @@ def main(argv=None): 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 the hook can see on PATH: one the py launcher listed is - # chosen by a listing the hook does not read, so no stamp, and the hook keeps its full path. + # 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 CANDIDATES} - {None}): + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): write_ready_stamp(data, env_dir(data, a.requirements)) return 0 diff --git a/plugins/animation/scripts/pydeps.py b/plugins/animation/scripts/pydeps.py index 346d91339cf..0251c2598a4 100755 --- a/plugins/animation/scripts/pydeps.py +++ b/plugins/animation/scripts/pydeps.py @@ -355,10 +355,11 @@ def main(argv=None): 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 the hook can see on PATH: one the py launcher listed is - # chosen by a listing the hook does not read, so no stamp, and the hook keeps its full path. + # 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 CANDIDATES} - {None}): + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): write_ready_stamp(data, env_dir(data, a.requirements)) return 0 diff --git a/plugins/animation/scripts/test_pydeps.py b/plugins/animation/scripts/test_pydeps.py index 56b4ed5baf0..c3bfc14933d 100644 --- a/plugins/animation/scripts/test_pydeps.py +++ b/plugins/animation/scripts/test_pydeps.py @@ -9,6 +9,7 @@ import contextlib import hashlib import io +import json import os import re import shutil @@ -341,6 +342,20 @@ def test_an_interpreter_only_the_launcher_listed_writes_no_stamp(self): 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') diff --git a/plugins/explainer-video/scripts/pydeps.py b/plugins/explainer-video/scripts/pydeps.py index 346d91339cf..0251c2598a4 100755 --- a/plugins/explainer-video/scripts/pydeps.py +++ b/plugins/explainer-video/scripts/pydeps.py @@ -355,10 +355,11 @@ def main(argv=None): 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 the hook can see on PATH: one the py launcher listed is - # chosen by a listing the hook does not read, so no stamp, and the hook keeps its full path. + # 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 CANDIDATES} - {None}): + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): write_ready_stamp(data, env_dir(data, a.requirements)) return 0 diff --git a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py index 56ea2e69a61..5b0101b1566 100644 --- a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py +++ b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py @@ -13,6 +13,7 @@ import hashlib import importlib.util import io +import json import os import re import shutil @@ -448,6 +449,20 @@ def test_an_interpreter_only_the_launcher_listed_writes_no_stamp(self): 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') diff --git a/plugins/speech/scripts/pydeps.py b/plugins/speech/scripts/pydeps.py index 346d91339cf..0251c2598a4 100755 --- a/plugins/speech/scripts/pydeps.py +++ b/plugins/speech/scripts/pydeps.py @@ -355,10 +355,11 @@ def main(argv=None): 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 the hook can see on PATH: one the py launcher listed is - # chosen by a listing the hook does not read, so no stamp, and the hook keeps its full path. + # 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 CANDIDATES} - {None}): + and chosen in {_which(name) for name in HOOK_CANDIDATES} - {None}): write_ready_stamp(data, env_dir(data, a.requirements)) return 0 diff --git a/plugins/speech/scripts/test_speech_pydeps.py b/plugins/speech/scripts/test_speech_pydeps.py index 935bfe8fff5..4a8541c52ac 100644 --- a/plugins/speech/scripts/test_speech_pydeps.py +++ b/plugins/speech/scripts/test_speech_pydeps.py @@ -10,6 +10,7 @@ import hashlib import importlib.util import io +import json import os import re import shutil @@ -283,6 +284,20 @@ def test_an_interpreter_only_the_launcher_listed_writes_no_stamp(self): 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') From 21eb09752f287e77eb47cfab3b8b98c341f40d67 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:20:51 -0500 Subject: [PATCH 05/10] test(animation,speech,explainer-video): run the ready-stamp hook case under real Git Bash in Windows CI Nothing in CI exercised the Windows fast path: the drive-mount case needs unprivileged user namespaces and Windows CI ran none of these suites. - Each hook test gains a case that runs only under Git Bash or Cygwin with cygpath. A real install writes the stamp; the test rewrites it in Windows form from the fixture's own paths (cygpath -w for the root, target and each candidate, .EXE where shutil.which would add it) and asserts the hook starts no Python, counted by a sitecustomize on PYTHONPATH that logs each start. Without the stamp the hook does start Python. On Windows the suite ends there: the later cases stub interpreters with shell scripts, simulate paths or need Linux namespaces. Elsewhere the case prints a SKIP line. - pr-test-platforms.yml test-windows-hooks runs the three suites, and scripts/test-windows-plan.txt keys them to each plugin's hook and pydeps suites, which a change to lib/pydeps.py, lib/install-python-deps.sh or any copy selects. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-test-platforms.yml | 13 +++++ .../hooks/install-python-deps.test.sh | 57 +++++++++++++++++++ .../hooks/install-python-deps.test.sh | 57 +++++++++++++++++++ .../speech/hooks/install-python-deps.test.sh | 57 +++++++++++++++++++ scripts/test-windows-plan.txt | 3 + 5 files changed, 187 insertions(+) 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/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index a12d6f3ffd2..20a4349ab34 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -71,6 +71,63 @@ 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 in true Windows form, built from this +# fixture's own paths with cygpath -w, holds and the hook starts no Python. 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" + target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" && -n "$target" && "$STARTS" -gt 0 ]]; then + ok "under Git Bash the first session installs, starting Python, and writes the ready stamp" + else + fail "Git Bash install: rc=$RC starts=$STARTS stamp=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" + fi + { + printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(cygpath -w "$root")" "$(cygpath -w "$target")" + for name in python3.13 python3.12 python3 python py; do + resolved="$(command -v "$name" 2>/dev/null)" || resolved="" + windows="" + if [[ -n "$resolved" ]]; then + windows="$(cygpath -w "$resolved")" + # shutil.which keeps the PATHEXT suffix that command -v may drop. + [[ "${resolved,,}" != *.exe && -e "$resolved.exe" ]] && windows+=".EXE" + fi + printf '%s=%s\n' "$name" "$windows" + done + } >"$stamp" + 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" diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index 070f26c7fc6..b0ea67add67 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -72,6 +72,63 @@ 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 in true Windows form, built from this +# fixture's own paths with cygpath -w, holds and the hook starts no Python. 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" + target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" && -n "$target" && "$STARTS" -gt 0 ]]; then + ok "under Git Bash the first session installs, starting Python, and writes the ready stamp" + else + fail "Git Bash install: rc=$RC starts=$STARTS stamp=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" + fi + { + printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(cygpath -w "$root")" "$(cygpath -w "$target")" + for name in python3.13 python3.12 python3 python py; do + resolved="$(command -v "$name" 2>/dev/null)" || resolved="" + windows="" + if [[ -n "$resolved" ]]; then + windows="$(cygpath -w "$resolved")" + # shutil.which keeps the PATHEXT suffix that command -v may drop. + [[ "${resolved,,}" != *.exe && -e "$resolved.exe" ]] && windows+=".EXE" + fi + printf '%s=%s\n' "$name" "$windows" + done + } >"$stamp" + 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" diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index b4c7617a3d2..ab2f49fd850 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -71,6 +71,63 @@ 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 in true Windows form, built from this +# fixture's own paths with cygpath -w, holds and the hook starts no Python. 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" + target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" && -n "$target" && "$STARTS" -gt 0 ]]; then + ok "under Git Bash the first session installs, starting Python, and writes the ready stamp" + else + fail "Git Bash install: rc=$RC starts=$STARTS stamp=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" + fi + { + printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(cygpath -w "$root")" "$(cygpath -w "$target")" + for name in python3.13 python3.12 python3 python py; do + resolved="$(command -v "$name" 2>/dev/null)" || resolved="" + windows="" + if [[ -n "$resolved" ]]; then + windows="$(cygpath -w "$resolved")" + # shutil.which keeps the PATHEXT suffix that command -v may drop. + [[ "${resolved,,}" != *.exe && -e "$resolved.exe" ]] && windows+=".EXE" + fi + printf '%s=%s\n' "$name" "$windows" + done + } >"$stamp" + 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" 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 From 5097328d5aa35741ebfd0fd848e591d425445e8a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:29:49 -0500 Subject: [PATCH 06/10] fix(animation,speech,explainer-video): compare the ready stamp's paths as files, not strings Windows CI caught the stamp failing on one directory spelled two ways: pydeps.py records the resolved root (C:\Users\runneradmin\...), while the hook's ROOT comes through Git Bash's /tmp mount, an 8.3 short name (RUNNER~1). String comparison cannot match those, nor a symlinked root. stamp_holds now uses the builtin -ef (same device and inode): the stamp root's scripts/pydeps.py against $ROOT/scripts/pydeps.py, and each candidate's recorded resolution against BASH_CMDS, both empty or both the same file. as_posix stays for drive-letter conversion. path_key's case and .exe folding is gone, since -ef matches across case, 8.3 names, mounts and a dropped .exe (checked on Windows Git Bash). A new Linux case reaches the plugin root through a symlink and must start no Python. Before this fix it started four. The simulated Windows cases keep backslash and drive-letter conversion only; the real Git Bash case covers case, 8.3 names and .EXE. The contract states the file comparison. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- .../on-demand-dependencies/CHANGELOG.md | 4 +- .../on-demand-dependencies/README.md | 6 ++- lib/install-python-deps.sh | 39 +++++++------------ .../animation/hooks/install-python-deps.sh | 39 +++++++------------ .../hooks/install-python-deps.test.sh | 34 +++++++++------- .../hooks/install-python-deps.sh | 39 +++++++------------ .../hooks/install-python-deps.test.sh | 34 +++++++++------- plugins/speech/hooks/install-python-deps.sh | 39 +++++++------------ .../speech/hooks/install-python-deps.test.sh | 34 +++++++++------- 9 files changed, 121 insertions(+), 147 deletions(-) diff --git a/docs/contracts/on-demand-dependencies/CHANGELOG.md b/docs/contracts/on-demand-dependencies/CHANGELOG.md index 0080e1bce6c..ea8350acfa0 100644 --- a/docs/contracts/on-demand-dependencies/CHANGELOG.md +++ b/docs/contracts/on-demand-dependencies/CHANGELOG.md @@ -9,8 +9,8 @@ or adopter is a minor bump; wording is a patch. - **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 - ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)). + `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. The residual hole, a candidate symlink repointed to an diff --git a/docs/contracts/on-demand-dependencies/README.md b/docs/contracts/on-demand-dependencies/README.md index 955b7071467..78716fc6954 100644 --- a/docs/contracts/on-demand-dependencies/README.md +++ b/docs/contracts/on-demand-dependencies/README.md @@ -128,8 +128,10 @@ candidate, `pydeps.py` writes `/python/.hook-ready` atomically, 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 elsewhere, a resolved interpreter file, `requirements.txt`, `pydeps.py` or -`pydeps.json` newer than the stamp, or the installed directory missing. +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 diff --git a/lib/install-python-deps.sh b/lib/install-python-deps.sh index 856786d8deb..b2bc8e42c55 100755 --- a/lib/install-python-deps.sh +++ b/lib/install-python-deps.sh @@ -78,48 +78,35 @@ as_posix() { esac } -# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. -path_key() { - as_posix "$1" || return 1 - case "${OSTYPE:-}" in - msys* | cygwin*) - REPLY="${REPLY,,}" - REPLY="${REPLY%.exe}" - ;; - *) ;; - 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 where it did, none of +# 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. -# 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. +# 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 want i=3 f + 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 - path_key "${lines[1]}" || return 1 - want="$REPLY" - path_key "$ROOT" || return 1 - [[ "$want" == "$REPLY" ]] || 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 - path_key "${lines[i]#*=}" || return 1 - want="$REPLY" + as_posix "${lines[i]#*=}" || return 1 got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" || return 1 - [[ "$want" == "$REPLY" ]] || return 1 - [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 diff --git a/plugins/animation/hooks/install-python-deps.sh b/plugins/animation/hooks/install-python-deps.sh index 954b2d657dd..1c95d43101d 100755 --- a/plugins/animation/hooks/install-python-deps.sh +++ b/plugins/animation/hooks/install-python-deps.sh @@ -80,48 +80,35 @@ as_posix() { esac } -# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. -path_key() { - as_posix "$1" || return 1 - case "${OSTYPE:-}" in - msys* | cygwin*) - REPLY="${REPLY,,}" - REPLY="${REPLY%.exe}" - ;; - *) ;; - 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 where it did, none of +# 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. -# 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. +# 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 want i=3 f + 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 - path_key "${lines[1]}" || return 1 - want="$REPLY" - path_key "$ROOT" || return 1 - [[ "$want" == "$REPLY" ]] || 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 - path_key "${lines[i]#*=}" || return 1 - want="$REPLY" + as_posix "${lines[i]#*=}" || return 1 got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" || return 1 - [[ "$want" == "$REPLY" ]] || return 1 - [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index 20a4349ab34..22d2ecf09cd 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -316,22 +316,30 @@ 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 -# Under Git Bash, Windows Python writes the stamp with backslashes, its own letter case and .EXE suffixes, while the -# hook sees POSIX paths without them. A stamp in that shape holds under OSTYPE=msys and, paired, not elsewhere. -# windows_form -> the path with backslashes; upper-cased with .EXE appended when a second argument is given. -windows_form() { - local p="${1//\//\\}" - [[ -n "${2:-}" && -n "$p" ]] && p="${p^^}.EXE" - printf '%s' "$p" -} +# 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. Letter case, 8.3 names and .EXE suffixes need a Windows +# file system: the real Git Bash case covers them. +# 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")" + 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" upper)" + printf '%s=%s\n' "$name" "$(windows_form "$logged/$name")" done - printf 'py=%s\n' "$(windows_form "$py_at" upper)" + printf 'py=%s\n' "$(windows_form "$py_at")" } >"$WORK/good-stamp" starts_under="" for ostype in msys linux-gnu; do @@ -377,9 +385,9 @@ for name in python3.13 python3.12 python3 python; do done chmod +x "$WORK/drive/bin/"* { - printf 'pydeps-hook-ready 1\nC:\\PLUGIN\nC:\\data\\python\\0123456789ab-cpython-399\n' + 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:\\BIN\\%s.EXE\n' "$name" "${name^^}" + printf '%s=C:\\bin\\%s\n' "$name" "$name" done printf 'py=\n' } >"$WORK/drive/data/python/.hook-ready" diff --git a/plugins/explainer-video/hooks/install-python-deps.sh b/plugins/explainer-video/hooks/install-python-deps.sh index 954b2d657dd..1c95d43101d 100755 --- a/plugins/explainer-video/hooks/install-python-deps.sh +++ b/plugins/explainer-video/hooks/install-python-deps.sh @@ -80,48 +80,35 @@ as_posix() { esac } -# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. -path_key() { - as_posix "$1" || return 1 - case "${OSTYPE:-}" in - msys* | cygwin*) - REPLY="${REPLY,,}" - REPLY="${REPLY%.exe}" - ;; - *) ;; - 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 where it did, none of +# 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. -# 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. +# 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 want i=3 f + 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 - path_key "${lines[1]}" || return 1 - want="$REPLY" - path_key "$ROOT" || return 1 - [[ "$want" == "$REPLY" ]] || 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 - path_key "${lines[i]#*=}" || return 1 - want="$REPLY" + as_posix "${lines[i]#*=}" || return 1 got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" || return 1 - [[ "$want" == "$REPLY" ]] || return 1 - [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index b0ea67add67..7a680ce2522 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -411,22 +411,30 @@ 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 -# Under Git Bash, Windows Python writes the stamp with backslashes, its own letter case and .EXE suffixes, while the -# hook sees POSIX paths without them. A stamp in that shape holds under OSTYPE=msys and, paired, not elsewhere. -# windows_form -> the path with backslashes; upper-cased with .EXE appended when a second argument is given. -windows_form() { - local p="${1//\//\\}" - [[ -n "${2:-}" && -n "$p" ]] && p="${p^^}.EXE" - printf '%s' "$p" -} +# 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. Letter case, 8.3 names and .EXE suffixes need a Windows +# file system: the real Git Bash case covers them. +# 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")" + 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" upper)" + printf '%s=%s\n' "$name" "$(windows_form "$logged/$name")" done - printf 'py=%s\n' "$(windows_form "$py_at" upper)" + printf 'py=%s\n' "$(windows_form "$py_at")" } >"$WORK/good-stamp" starts_under="" for ostype in msys linux-gnu; do @@ -472,9 +480,9 @@ for name in python3.13 python3.12 python3 python; do done chmod +x "$WORK/drive/bin/"* { - printf 'pydeps-hook-ready 1\nC:\\PLUGIN\nC:\\data\\python\\0123456789ab-cpython-399\n' + 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:\\BIN\\%s.EXE\n' "$name" "${name^^}" + printf '%s=C:\\bin\\%s\n' "$name" "$name" done printf 'py=\n' } >"$WORK/drive/data/python/.hook-ready" diff --git a/plugins/speech/hooks/install-python-deps.sh b/plugins/speech/hooks/install-python-deps.sh index 954b2d657dd..1c95d43101d 100755 --- a/plugins/speech/hooks/install-python-deps.sh +++ b/plugins/speech/hooks/install-python-deps.sh @@ -80,48 +80,35 @@ as_posix() { esac } -# path_key -> REPLY: the path as compared. Under Git Bash or Cygwin, paths are case-insensitive and a -# command -v result may drop the .exe that Python's shutil.which keeps. Fails as as_posix does. -path_key() { - as_posix "$1" || return 1 - case "${OSTYPE:-}" in - msys* | cygwin*) - REPLY="${REPLY,,}" - REPLY="${REPLY%.exe}" - ;; - *) ;; - 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 where it did, none of +# 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. -# 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. +# 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 want i=3 f + 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 - path_key "${lines[1]}" || return 1 - want="$REPLY" - path_key "$ROOT" || return 1 - [[ "$want" == "$REPLY" ]] || 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 - path_key "${lines[i]#*=}" || return 1 - want="$REPLY" + as_posix "${lines[i]#*=}" || return 1 got="" hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}" - path_key "$got" || return 1 - [[ "$want" == "$REPLY" ]] || return 1 - [[ -n "$got" && "$got" -nt "$stamp" ]] && return 1 + 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 diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index ab2f49fd850..95571a48e6d 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -301,22 +301,30 @@ 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 -# Under Git Bash, Windows Python writes the stamp with backslashes, its own letter case and .EXE suffixes, while the -# hook sees POSIX paths without them. A stamp in that shape holds under OSTYPE=msys and, paired, not elsewhere. -# windows_form -> the path with backslashes; upper-cased with .EXE appended when a second argument is given. -windows_form() { - local p="${1//\//\\}" - [[ -n "${2:-}" && -n "$p" ]] && p="${p^^}.EXE" - printf '%s' "$p" -} +# 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. Letter case, 8.3 names and .EXE suffixes need a Windows +# file system: the real Git Bash case covers them. +# 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")" + 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" upper)" + printf '%s=%s\n' "$name" "$(windows_form "$logged/$name")" done - printf 'py=%s\n' "$(windows_form "$py_at" upper)" + printf 'py=%s\n' "$(windows_form "$py_at")" } >"$WORK/good-stamp" starts_under="" for ostype in msys linux-gnu; do @@ -362,9 +370,9 @@ for name in python3.13 python3.12 python3 python; do done chmod +x "$WORK/drive/bin/"* { - printf 'pydeps-hook-ready 1\nC:\\PLUGIN\nC:\\data\\python\\0123456789ab-cpython-399\n' + 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:\\BIN\\%s.EXE\n' "$name" "${name^^}" + printf '%s=C:\\bin\\%s\n' "$name" "$name" done printf 'py=\n' } >"$WORK/drive/data/python/.hook-ready" From 89ec0d26aa224342eac1b7993ed90d1444281e3a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:59:50 -0500 Subject: [PATCH 07/10] test(animation,speech,explainer-video): check the real install's stamp under Git Bash, not a hand-written one The real Git Bash case rewrote the stamp by hand and appended .EXE after cygpath -w, which already returns python3.13.exe for a file that exists only with the suffix. The recorded python3.13.exe.EXE named no file, so the hook's -ef check rightly failed (Windows CI job 125502035270). Production never hand-writes the stamp, so the case now keeps the stamp the real install wrote, asserts its root line is a Windows drive-letter path, and then asserts the hook starts no Python. The fixture stays under /tmp, so the stamp's long-form root and the hook's 8.3-mapped ROOT still spell one directory two ways. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- .../hooks/install-python-deps.test.sh | 36 ++++++++----------- .../hooks/install-python-deps.test.sh | 36 ++++++++----------- .../speech/hooks/install-python-deps.test.sh | 36 ++++++++----------- 3 files changed, 45 insertions(+), 63 deletions(-) diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index 22d2ecf09cd..bac30ff37ce 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -71,10 +71,12 @@ 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 in true Windows form, built from this -# fixture's own paths with cygpath -w, holds and the hook starts no Python. 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. +# 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" @@ -89,25 +91,17 @@ if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]] && command -v cygpat [[ -f "$WORK/py-starts.log" ]] && STARTS="$(wc -l <"$WORK/py-starts.log" | tr -d '[:space:]')" } win_run "$WHEELS" - target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" - if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" && -n "$target" && "$STARTS" -gt 0 ]]; then - ok "under Git Bash the first session installs, starting Python, and writes the ready stamp" + 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=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" + fail "Git Bash install: rc=$RC starts=$STARTS stamp root=[$stamp_root] output=[$OUT]" fi - { - printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(cygpath -w "$root")" "$(cygpath -w "$target")" - for name in python3.13 python3.12 python3 python py; do - resolved="$(command -v "$name" 2>/dev/null)" || resolved="" - windows="" - if [[ -n "$resolved" ]]; then - windows="$(cygpath -w "$resolved")" - # shutil.which keeps the PATHEXT suffix that command -v may drop. - [[ "${resolved,,}" != *.exe && -e "$resolved.exe" ]] && windows+=".EXE" - fi - printf '%s=%s\n' "$name" "$windows" - done - } >"$stamp" 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" diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index 7a680ce2522..800aa702d92 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -72,10 +72,12 @@ 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 in true Windows form, built from this -# fixture's own paths with cygpath -w, holds and the hook starts no Python. 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. +# 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" @@ -90,25 +92,17 @@ if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]] && command -v cygpat [[ -f "$WORK/py-starts.log" ]] && STARTS="$(wc -l <"$WORK/py-starts.log" | tr -d '[:space:]')" } win_run "$WHEELS" - target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" - if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" && -n "$target" && "$STARTS" -gt 0 ]]; then - ok "under Git Bash the first session installs, starting Python, and writes the ready stamp" + 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=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" + fail "Git Bash install: rc=$RC starts=$STARTS stamp root=[$stamp_root] output=[$OUT]" fi - { - printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(cygpath -w "$root")" "$(cygpath -w "$target")" - for name in python3.13 python3.12 python3 python py; do - resolved="$(command -v "$name" 2>/dev/null)" || resolved="" - windows="" - if [[ -n "$resolved" ]]; then - windows="$(cygpath -w "$resolved")" - # shutil.which keeps the PATHEXT suffix that command -v may drop. - [[ "${resolved,,}" != *.exe && -e "$resolved.exe" ]] && windows+=".EXE" - fi - printf '%s=%s\n' "$name" "$windows" - done - } >"$stamp" 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" diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index 95571a48e6d..02b5b4253b7 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -71,10 +71,12 @@ 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 in true Windows form, built from this -# fixture's own paths with cygpath -w, holds and the hook starts no Python. 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. +# 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" @@ -89,25 +91,17 @@ if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ]] && command -v cygpat [[ -f "$WORK/py-starts.log" ]] && STARTS="$(wc -l <"$WORK/py-starts.log" | tr -d '[:space:]')" } win_run "$WHEELS" - target="$(find "$data/python" -mindepth 1 -maxdepth 1 -type d | head -n 1)" - if [[ "$RC" -eq 0 && -z "$OUT" && -f "$stamp" && -n "$target" && "$STARTS" -gt 0 ]]; then - ok "under Git Bash the first session installs, starting Python, and writes the ready stamp" + 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=$([[ -f "$stamp" ]] && echo yes || echo no) output=[$OUT]" + fail "Git Bash install: rc=$RC starts=$STARTS stamp root=[$stamp_root] output=[$OUT]" fi - { - printf 'pydeps-hook-ready 1\n%s\n%s\n' "$(cygpath -w "$root")" "$(cygpath -w "$target")" - for name in python3.13 python3.12 python3 python py; do - resolved="$(command -v "$name" 2>/dev/null)" || resolved="" - windows="" - if [[ -n "$resolved" ]]; then - windows="$(cygpath -w "$resolved")" - # shutil.which keeps the PATHEXT suffix that command -v may drop. - [[ "${resolved,,}" != *.exe && -e "$resolved.exe" ]] && windows+=".EXE" - fi - printf '%s=%s\n' "$name" "$windows" - done - } >"$stamp" 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" From 91867a1cf38957b6cff5e3bebcc9bacef855c72f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:07:10 -0500 Subject: [PATCH 08/10] fix(animation,speech,explainer-video): print the repair line when a stamped run fails A stamped set whose directory still exists but no longer imports failed the skill's script, and run() dropped the stamp silently, while Rule P4 promises the repair line. run() now prints one stderr line when the script exits non-zero and a ready stamp was present: the run failed, the next session start re-probes and rebuilds, or run the repair line now. The script's own exit code stands. With no stamp, a failing script prints nothing extra. A new test in each plugin's pydeps suite covers both cases. Rule P4 now says so, and its "Neither falls back" sentence is back after the sentences it refers to. The hook tests' comment on the simulated Windows case names only what the real Git Bash case covers: mounts, 8.3 names and .EXE suffixes, not letter case. Refs: #12258 Co-Authored-By: Claude Opus 5.5 --- .../on-demand-dependencies/CHANGELOG.md | 3 ++- docs/contracts/on-demand-dependencies/README.md | 17 +++++++++-------- lib/pydeps.py | 6 +++++- .../animation/hooks/install-python-deps.test.sh | 4 ++-- plugins/animation/scripts/pydeps.py | 6 +++++- plugins/animation/scripts/test_pydeps.py | 17 +++++++++++++++++ .../hooks/install-python-deps.test.sh | 4 ++-- plugins/explainer-video/scripts/pydeps.py | 6 +++++- .../scripts/test_explainer_video_pydeps.py | 17 +++++++++++++++++ .../speech/hooks/install-python-deps.test.sh | 4 ++-- plugins/speech/scripts/pydeps.py | 6 +++++- plugins/speech/scripts/test_speech_pydeps.py | 17 +++++++++++++++++ 12 files changed, 88 insertions(+), 19 deletions(-) diff --git a/docs/contracts/on-demand-dependencies/CHANGELOG.md b/docs/contracts/on-demand-dependencies/CHANGELOG.md index ea8350acfa0..eb6e481636f 100644 --- a/docs/contracts/on-demand-dependencies/CHANGELOG.md +++ b/docs/contracts/on-demand-dependencies/CHANGELOG.md @@ -13,7 +13,8 @@ or adopter is a minor bump; wording is a patch. 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. The residual hole, a candidate symlink repointed to an + stamp so the next session start rebuilds, and a failed launcher run with a stamp present prints + the repair line. The residual hole, a candidate symlink repointed to an older interpreter file, is stated. ## [2.3.0] - 2026-10-04 diff --git a/docs/contracts/on-demand-dependencies/README.md b/docs/contracts/on-demand-dependencies/README.md index 78716fc6954..78437c57784 100644 --- a/docs/contracts/on-demand-dependencies/README.md +++ b/docs/contracts/on-demand-dependencies/README.md @@ -174,18 +174,19 @@ the hook emits a notice on both channels (`systemMessage` and `additionalContext is the installer run in the foreground with the same interpreter and data directory (POSIX shell, or Windows PowerShell 5.1 on Windows, as in Rule 3). The hook exits 0, so a failed install never blocks the session, and it leaves no partial directory. A launcher run -with no installed set prints the same line and exits 2. - -A stamped set that breaks surfaces when a skill runs, not at session start: the launcher prints the -repair line for a missing set and drops the ready stamp, as it does when a script exits non-zero -and as `check` does when a row fails, so the next session start probes and rebuilds. One hole -remains: a candidate symlink repointed to an interpreter file older than the stamp passes both the -resolution and the newer-file checks, and the hook keeps trusting the stamp until a failed skill -run drops it. Neither falls back to another interpreter's +with no installed set prints the same line and exits 2. Neither falls back to another interpreter's packages, installs on its own, or reports a partial result as complete. In an interactive session 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. One hole remains: a candidate symlink repointed to an interpreter file older +than the stamp passes both the resolution and the newer-file checks, and the hook keeps trusting +the stamp until a failed skill run drops it. + ## Exceptions | Component | Rule | Exception and reason | diff --git a/lib/pydeps.py b/lib/pydeps.py index c285bac6d6a..ed43e18604b 100755 --- a/lib/pydeps.py +++ b/lib/pydeps.py @@ -247,8 +247,12 @@ def run(data, script_args, requirements=REQUIREMENTS): f'{repair_line(data)}\n') return 2 code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode - if code: + 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 diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index bac30ff37ce..830692fe167 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -322,8 +322,8 @@ else 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. Letter case, 8.3 names and .EXE suffixes need a Windows -# file system: the real Git Bash case covers them. +# 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)" diff --git a/plugins/animation/scripts/pydeps.py b/plugins/animation/scripts/pydeps.py index 0251c2598a4..9358fc1ab57 100755 --- a/plugins/animation/scripts/pydeps.py +++ b/plugins/animation/scripts/pydeps.py @@ -249,8 +249,12 @@ def run(data, script_args, requirements=REQUIREMENTS): f'{repair_line(data)}\n') return 2 code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode - if code: + 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 diff --git a/plugins/animation/scripts/test_pydeps.py b/plugins/animation/scripts/test_pydeps.py index c3bfc14933d..9552501eaa6 100644 --- a/plugins/animation/scripts/test_pydeps.py +++ b/plugins/animation/scripts/test_pydeps.py @@ -371,6 +371,23 @@ def test_run_drops_the_stamp_when_the_set_is_missing_or_the_script_fails(self): 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', {}): diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index 800aa702d92..1765b3507de 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -417,8 +417,8 @@ else 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. Letter case, 8.3 names and .EXE suffixes need a Windows -# file system: the real Git Bash case covers them. +# 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)" diff --git a/plugins/explainer-video/scripts/pydeps.py b/plugins/explainer-video/scripts/pydeps.py index 0251c2598a4..9358fc1ab57 100755 --- a/plugins/explainer-video/scripts/pydeps.py +++ b/plugins/explainer-video/scripts/pydeps.py @@ -249,8 +249,12 @@ def run(data, script_args, requirements=REQUIREMENTS): f'{repair_line(data)}\n') return 2 code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode - if code: + 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 diff --git a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py index 5b0101b1566..fbe67483a3e 100644 --- a/plugins/explainer-video/scripts/test_explainer_video_pydeps.py +++ b/plugins/explainer-video/scripts/test_explainer_video_pydeps.py @@ -478,6 +478,23 @@ def test_run_drops_the_stamp_when_the_set_is_missing_or_the_script_fails(self): 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', {}): diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index 02b5b4253b7..c6b7a61efce 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -307,8 +307,8 @@ else 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. Letter case, 8.3 names and .EXE suffixes need a Windows -# file system: the real Git Bash case covers them. +# 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)" diff --git a/plugins/speech/scripts/pydeps.py b/plugins/speech/scripts/pydeps.py index 0251c2598a4..9358fc1ab57 100755 --- a/plugins/speech/scripts/pydeps.py +++ b/plugins/speech/scripts/pydeps.py @@ -249,8 +249,12 @@ def run(data, script_args, requirements=REQUIREMENTS): f'{repair_line(data)}\n') return 2 code = subprocess.run([sys.executable, '-S', *script_args], env=_env(target)).returncode - if code: + 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 diff --git a/plugins/speech/scripts/test_speech_pydeps.py b/plugins/speech/scripts/test_speech_pydeps.py index 4a8541c52ac..e657a346dab 100644 --- a/plugins/speech/scripts/test_speech_pydeps.py +++ b/plugins/speech/scripts/test_speech_pydeps.py @@ -313,6 +313,23 @@ def test_run_drops_the_stamp_when_the_set_is_missing_or_the_script_fails(self): 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', {}): From fc8c49b33472049586e0ef86f9f1bb8328b2a2ab Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:15:51 -0500 Subject: [PATCH 09/10] docs(on-demand-dependencies): name version-manager shims as a case the ready stamp outlives A pyenv, asdf or mise shim keeps its path and file time across a version switch, so the hook's resolution and newer-file checks both pass. Name it beside the repointed-symlink case; both heal on the first skill run. Co-Authored-By: Claude Opus 5.5 --- docs/contracts/on-demand-dependencies/CHANGELOG.md | 4 ++-- docs/contracts/on-demand-dependencies/README.md | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/contracts/on-demand-dependencies/CHANGELOG.md b/docs/contracts/on-demand-dependencies/CHANGELOG.md index eb6e481636f..e39a0cab8f2 100644 --- a/docs/contracts/on-demand-dependencies/CHANGELOG.md +++ b/docs/contracts/on-demand-dependencies/CHANGELOG.md @@ -14,8 +14,8 @@ or adopter is a minor bump; wording is a patch. - **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 residual hole, a candidate symlink repointed to an - older interpreter file, is stated. + 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 diff --git a/docs/contracts/on-demand-dependencies/README.md b/docs/contracts/on-demand-dependencies/README.md index 78437c57784..8f4c2c2e4da 100644 --- a/docs/contracts/on-demand-dependencies/README.md +++ b/docs/contracts/on-demand-dependencies/README.md @@ -183,9 +183,12 @@ A stamped set that breaks surfaces when a skill runs, not at session start. 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. One hole remains: a candidate symlink repointed to an interpreter file older -than the stamp passes both the resolution and the newer-file checks, and the hook keeps trusting -the stamp until a failed skill run drops it. +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 From 67f538ac8c55725d82383c3544b85734f932b551 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:30:22 -0500 Subject: [PATCH 10/10] test(animation,speech,explainer-video): keep the drive stamp's backslashes out of the printf format lint-shell's shell-portability check flags \\b in a printf format, which some shells read as a backspace escape. Pass the backslashes as arguments; the stamp text is unchanged. Co-Authored-By: Claude Opus 5.5 --- plugins/animation/hooks/install-python-deps.test.sh | 2 +- plugins/explainer-video/hooks/install-python-deps.test.sh | 2 +- plugins/speech/hooks/install-python-deps.test.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/animation/hooks/install-python-deps.test.sh b/plugins/animation/hooks/install-python-deps.test.sh index 830692fe167..faeae5ec212 100755 --- a/plugins/animation/hooks/install-python-deps.test.sh +++ b/plugins/animation/hooks/install-python-deps.test.sh @@ -381,7 +381,7 @@ 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:\\bin\\%s\n' "$name" "$name" + printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name" done printf 'py=\n' } >"$WORK/drive/data/python/.hook-ready" diff --git a/plugins/explainer-video/hooks/install-python-deps.test.sh b/plugins/explainer-video/hooks/install-python-deps.test.sh index 1765b3507de..f9f636a7360 100755 --- a/plugins/explainer-video/hooks/install-python-deps.test.sh +++ b/plugins/explainer-video/hooks/install-python-deps.test.sh @@ -476,7 +476,7 @@ 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:\\bin\\%s\n' "$name" "$name" + printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name" done printf 'py=\n' } >"$WORK/drive/data/python/.hook-ready" diff --git a/plugins/speech/hooks/install-python-deps.test.sh b/plugins/speech/hooks/install-python-deps.test.sh index c6b7a61efce..530a2e10ee4 100755 --- a/plugins/speech/hooks/install-python-deps.test.sh +++ b/plugins/speech/hooks/install-python-deps.test.sh @@ -366,7 +366,7 @@ 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:\\bin\\%s\n' "$name" "$name" + printf '%s=C:%sbin%s%s\n' "$name" "\\" "\\" "$name" done printf 'py=\n' } >"$WORK/drive/data/python/.hook-ready"