refactor(uv): single managed-uv path, delete fts5 installer escalation#37660
Conversation
Replace the multi-path UV resolution chain (PATH probing, conda guards,
5-location trust ordering, temp-dir fallback installs) with a single
managed uv binary at $HERMES_HOME/bin/uv. Every code path that needs
uv resolves it from that one location; if missing, ensure_uv()
bootstraps it via the official standalone installer.
Key changes:
- New hermes_cli/managed_uv.py: managed_uv_path(), resolve_uv(),
ensure_uv() (returns (path, freshly_bootstrapped) tuple),
update_managed_uv(), rebuild_venv(), installer internals.
- hermes_cli/main.py: replace all shutil.which('uv') with ensure_uv(),
add venv rebuild on first-time managed uv bootstrap, update_managed_uv
before dep install on all 3 update paths.
- scripts/install.sh: install_uv() always installs to
$HERMES_HOME/bin/uv; delete ensure_fts5, _python_has_fts5,
_reinstall_python_with_fts5, _warn_no_fts5 (61 lines).
Managed uv always installs current Python with FTS5.
- scripts/install.ps1: Install-Uv always installs to
$HermesHome\bin\uv.exe; Resolve-UvCmd checks managed location first.
- hermes_state.py: simplified FTS5 warning now suggests 'hermes update'
as the fix instead of blaming install method.
- tests: 15 tests in test_managed_uv.py, autouse _patch_managed_uv
fixture in test_cmd_update.py.
Closes #37605, Closes #37622
212eca2 to
1a9da1a
Compare
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
1 |
First entries
tests/hermes_cli/test_managed_uv.py:10: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
✅ Fixed issues: none
Unchanged: 5016 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
There was a problem hiding this comment.
Pull request overview
This PR centralizes uv handling by introducing a single “managed uv” binary location under $HERMES_HOME/bin/uv (or uv.exe on Windows), and removes the installer-side FTS5 escalation logic that previously worked around stale externally-managed uv binaries. It updates both runtime update flows and bootstrap installers to align on the managed-uv approach.
Changes:
- Added
hermes_cli/managed_uv.pyto resolve/install/update a single manageduvbinary and optionally rebuild the venv on first bootstrap. - Updated
hermes_cli/main.pyupdate flows to use managed uv (ensure_uv,update_managed_uv) and trigger a venv rebuild on first-time bootstrap. - Simplified installers (
install.sh/install.ps1) to always install/use managed uv at$HERMES_HOME/bin, and removed the shell-based FTS5 escalation logic.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
hermes_cli/managed_uv.py |
New managed-uv resolver/installer/updater utilities and venv rebuild helper. |
hermes_cli/main.py |
Switched update flows from PATH uv to managed uv; added self-update + venv rebuild on first bootstrap. |
scripts/install.sh |
Installer now installs/uses managed uv only; removed FTS5 escalation logic. |
scripts/install.ps1 |
PowerShell installer now installs/uses managed uv first; resolver checks managed location before PATH. |
hermes_state.py |
Updated FTS5 warning messaging to recommend hermes update. |
tests/hermes_cli/test_managed_uv.py |
New tests covering managed_uv behavior. |
tests/hermes_cli/test_cmd_update.py |
Added autouse fixture to keep existing tests compatible with managed_uv changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Call this during ``hermes update`` so the managed copy stays current. | ||
| Returns the managed path on success, ``None`` if uv isn't available or | ||
| the self-update fails (non-fatal — the old version still works). |
| if fresh_bootstrap and uv_bin: | ||
| rebuild_venv(uv_bin, PROJECT_ROOT / "venv") |
| if fresh_bootstrap and uv_bin: | ||
| rebuild_venv(uv_bin, PROJECT_ROOT / "venv") |
| $prevEAP = $ErrorActionPreference | ||
| try { | ||
| # Relax ErrorActionPreference around the nested astral installer. | ||
| # The astral installer (a separate `powershell -c "irm ... | iex"`) | ||
| # writes download progress to stderr. With $ErrorActionPreference | ||
| # = "Stop" set at the top of this script, PowerShell wraps stderr | ||
| # lines from native commands (which `powershell -c` is, from our | ||
| # perspective) as ErrorRecord objects when captured via 2>&1, then | ||
| # throws a terminating exception on the first one -- even though | ||
| # uv installs successfully and the child exits 0. Same fix | ||
| # pattern Test-Python uses for `uv python install`; verify success | ||
| # via Test-Path on the expected binary afterwards, which is more | ||
| # reliable than exit-code/stderr signal anyway. | ||
| $ErrorActionPreference = "Continue" | ||
| $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" | ||
| powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null |
| } catch { | ||
| # Restore EAP in case the try block threw before the assignment | ||
| if ($prevEAP) { $ErrorActionPreference = $prevEAP } | ||
| Write-Err "Failed to install uv: $_" | ||
| Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" |
| class TestInstallUvInternals: | ||
| def test_posix_sets_uv_unmanaged_install(self, tmp_path): | ||
| target = tmp_path / "bin" / "uv" | ||
| with patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix: |
| if is_uv_tool_install(): | ||
| if not uv: | ||
| print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.") | ||
| print("✗ Detected a uv-tool install but managed uv install failed.") | ||
| print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/") | ||
| sys.exit(1) |
…t files
Production code now uses ensure_uv()/update_managed_uv() from
managed_uv.py instead of shutil.which("uv") directly. Tests that
patched shutil.which to control uv availability no longer controlled
the actual code path, causing CI failures.
Add an autouse _patch_managed_uv fixture to test_update_autostash.py
and test_uv_tool_update.py (matching the existing fixture in
test_cmd_update.py). The fixture makes managed_uv functions delegate
to shutil.which so existing test patches flow through naturally.
- Fix update_managed_uv() docstring: returns path even on self-update failure, None only when no managed uv exists - Fail fast on rebuild_venv() failure in _update_via_zip and _cmd_update_impl — avoids continuing with a nuked venv - Save/restore $env:UV_INSTALL_DIR in install.ps1 Install-Uv to prevent env var leak into subsequent stages - Patch platform.system() in test_posix_sets_uv_unmanaged_install for determinism on Windows - Soften FTS5 warning: drop 'managed uv guarantees FTS5' claim, keep actionable 'hermes update' advice
|
until this goes in, i'm broken by every update. |
What does this PR do?
Replaces the complicated multi-path UV resolution chain with a single managed uv binary at
$HERMES_HOME/bin/uv. Every code path that needs uv resolves it from that one location. If missing,ensure_uv()bootstraps it via the official standalone installer. No PATH probing, no conda guards, no 5-location trust ordering, no temp-dir fallback installs.Also deletes the entire FTS5 installer escalation (
ensure_fts5,_python_has_fts5,_reinstall_python_with_fts5,_warn_no_fts5— 61 lines of shell). These existed to work around stale uv binaries that could notself update(pip/apt/brew-managed uv). With managed uv (always from the official installer → always supportsself update), the escalation is dead code: managed uv always installs a current Python with FTS5.For existing installs transitioning from non-managed uv:
ensure_uv()returns a(path, freshly_bootstrapped)tuple. The update paths detect a first-time bootstrap and nuke+recreate the venv with the new managed uv, guaranteeing a Python with FTS5. The runtime FTS5 guard inhermes_state.pyis kept but its warning now suggestshermes updateas the fix.Closes #37605, Closes #37622 — both PRs tried to add more paths and more resolution logic to handle edge cases around stale/pip/apt/brew-managed uv. The right fix is simpler: own one uv, always current.
Related Issue
Closes #37605
Closes #37622
Type of Change
Changes Made
hermes_cli/managed_uv.py(new):managed_uv_path(),resolve_uv(),ensure_uv()(returns(path, freshly_bootstrapped)tuple),update_managed_uv(),rebuild_venv(),_install_uv_posix(),_install_uv_windows()hermes_cli/main.py: replaced all 3shutil.which("uv")sites withensure_uv(); addedrebuild_venv()call on first-time managed uv bootstrap; addedupdate_managed_uv()before dep install on all 3 update pathsscripts/install.sh:install_uv()always installs to$HERMES_HOME/bin/uv(no PATH probing, no~/.local/bin/~/.cargo/binchecks); deletedensure_fts5,_python_has_fts5,_reinstall_python_with_fts5,_warn_no_fts5(61 lines removed)scripts/install.ps1:Install-Uvalways installs to$HermesHome\bin\uv.exe;Resolve-UvCmdchecks managed location firsthermes_state.py: simplified FTS5 warning — now suggestshermes updateas the fix instead of blaming install methodtests/hermes_cli/test_managed_uv.py(new): 15 tests covering the moduletests/hermes_cli/test_cmd_update.py: autouse_patch_managed_uvfixture delegates toshutil.which; addedrebuild_venvstubHow to Test
python -m pytest tests/hermes_cli/test_managed_uv.py tests/hermes_cli/test_cmd_update.py -q— 40/40 passinstall.shstill works end-to-end on a fresh machine (managed uv gets installed to$HERMES_HOME/bin/uv)hermes updateon an existing install: if managed uv already exists, no venv rebuild; if first-time bootstrap, venv gets rebuilt with fresh PythonWhy this is better than #37605 and #37622
Both PRs added more resolution paths and more conditional logic to handle edge cases around stale/externally-managed uv. More paths = more things to test, more things to break, more code to maintain. This PR takes the opposite approach: own one uv. Managed uv is always from the official installer, always supports
self update, always installs current Python with FTS5. No edge cases to handle because there is only one path.$HERMES_HOME/bin/uv