fix(managed_uv): fall forward to next Python minor when current line has no fixed SQLite build

When every patch on the current minor line (e.g. 3.11) still links a
vulnerable SQLite (e.g. 3.50.4 on Windows), the provisioner now tries
the next supported minor line (3.12, then 3.13) before giving up.

Previously, _install_safe_python_generation only tried patches within
the same minor line. On Windows, where python-build-standalone may not
publish a fixed build for the installed patch, users were stuck with a
repeated warning on every `hermes update` with no path forward.

The requires-python constraint (>=3.11,<3.14) and the downstream
import smoke test already gate compatibility, so the minor-line
upgrade is safe.

Adds allow_minor_upgrade parameter to _attempt_install_generation to
relax the same-minor-line version guard when called from the fallback
path.

Fixes #76106
This commit is contained in:
RelaxJonh
2026-08-01 22:28:08 +07:00
committed by Teknium
parent 62014d8dd3
commit 2bccd6ad08
2 changed files with 61 additions and 4 deletions
+52 -1
View File
@@ -514,6 +514,7 @@ def _attempt_install_generation(
project_root: Path,
python_root: Path,
current: SQLiteRuntimeInfo,
allow_minor_upgrade: bool = False,
) -> tuple[Path, Path, SQLiteRuntimeInfo] | None:
"""One install+probe attempt for a specific version request (bare minor
like "3.11", or an explicit patch like "3.11.15"). Each attempt gets its
@@ -593,7 +594,18 @@ def _attempt_install_generation(
logger.warning("could not probe candidate Python runtime: %s", python)
_remove_tree(generation, boundary=python_root)
return None
if candidate.python_version[:2] != current.python_version[:2] or (
if allow_minor_upgrade:
# When falling forward to a higher minor line (e.g. 3.11 → 3.12),
# only reject downgrades — allow the minor to differ.
if candidate.python_version < current.python_version:
logger.warning(
"candidate Python downgraded from %s: %s",
".".join(str(p) for p in current.python_version),
candidate.python_version,
)
_remove_tree(generation, boundary=python_root)
return None
elif candidate.python_version[:2] != current.python_version[:2] or (
candidate.python_version < current.python_version
):
logger.warning(
@@ -673,6 +685,45 @@ def _install_safe_python_generation(
)
if result is not None:
return result
# All patches on the current minor line are vulnerable or rejected.
# Fall forward to the next supported minor (e.g. 3.11 → 3.12) so the
# user isn't stuck on every `hermes update` with no path to a fixed
# runtime (issue #76106). The requires-python constraint
# (>=3.11,<3.14) and the downstream import smoke-test gate
# compatibility; we only need to stay inside that window.
cur_major, cur_minor = current.python_version[:2]
for next_minor in range(cur_minor + 1, 14): # up to 3.13
next_request = f"{cur_major}.{next_minor}"
print(
f" → No fixed {cur_major}.{cur_minor} build available; "
f"trying {next_request} as fallback..."
)
result = _attempt_install_generation(
uv_bin, next_request, project_root=project_root,
python_root=python_root, current=current,
allow_minor_upgrade=True,
)
if result is not None:
return result
# Also try explicit patches on this minor line
env_for_list = managed_python_env(project_root, install_dir=python_root)
fb_patches = _list_available_patches(
uv_bin, next_request, cwd=project_root, env=env_for_list
)
fb_attempts = 0
for version_tuple in fb_patches:
if fb_attempts >= _MAX_PATCH_RETRIES:
break
explicit = ".".join(str(p) for p in version_tuple)
fb_attempts += 1
result = _attempt_install_generation(
uv_bin, explicit, project_root=project_root,
python_root=python_root, current=current,
allow_minor_upgrade=True,
)
if result is not None:
return result
return None
+9 -3
View File
@@ -778,8 +778,9 @@ class TestPatchRetryOnVulnerableCandidate:
def test_retry_is_bounded_by_max_retries_constant(self, tmp_path, monkeypatch):
"""A very long patch list must not result in unbounded retries --
capped at _MAX_PATCH_RETRIES attempts."""
"""A very long patch list must not result in unbounded retries -- capped at
_MAX_PATCH_RETRIES attempts. After exhausting same-minor retries the
fallback tries the next minor line, which may succeed."""
import hermes_cli.managed_uv as managed_uv
install_calls = []
@@ -792,6 +793,7 @@ class TestPatchRetryOnVulnerableCandidate:
return original_fake_run(cmd, **kwargs)
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo
current = SQLiteRuntimeInfo(
executable=Path("/venv/bin/python"), base_prefix=Path("/venv"),
python_version=(3, 11, 14), sqlite_version=(3, 50, 4),
@@ -810,7 +812,11 @@ class TestPatchRetryOnVulnerableCandidate:
result = managed_uv._install_safe_python_generation(
"uv", project_root=tmp_path, current=current
)
assert result is None
# The same-minor retries are bounded, but the minor-line fallback
# (3.11 → 3.12) succeeds because the mock returns a fixed build.
assert result is not None, (
"Minor-line fallback should find a fixed 3.12 build"
)
# 1 initial bare-minor attempt + at most _MAX_PATCH_RETRIES retries.
assert managed_uv._MAX_PATCH_RETRIES <= 5, (
"sanity: constant should stay small since each attempt is a "