diff --git a/docs/self-evolution.md b/docs/self-evolution.md index 2f07d1e..c510a34 100644 --- a/docs/self-evolution.md +++ b/docs/self-evolution.md @@ -9,7 +9,7 @@ Historical verification snapshot: `51be33361422e55e1f2f00c33a0e0f8c56132a91` (the post-#54 `main` revision, captured before this #55 documentation-only update). Snapshot date: 2026-09-04. -Current repository test count at this snapshot: **196 unittest cases**. +Current repository test count at this snapshot: **198 unittest cases**. ## Verified surface diff --git a/install.ps1 b/install.ps1 index 394b440..9798794 100644 --- a/install.ps1 +++ b/install.ps1 @@ -85,6 +85,13 @@ if (-not $pythonVersion) { Write-Output "[INFO] Installing OpenKyrozen $releaseTag from its immutable GitHub release with Python $pythonVersion..." & $uvCommand.Source tool install --python $pythonVersion --force --with fastapi --with uvicorn $releaseWheelUrl +if ($LASTEXITCODE -ne 0) { + Write-Output "[INFO] uv cache was incomplete; retrying without the existing cache..." + & $uvCommand.Source --no-cache tool install --python $pythonVersion --force --with fastapi --with uvicorn $releaseWheelUrl +} +if ($LASTEXITCODE -ne 0) { + Fail "OpenKyrozen installation failed from release $releaseTag." +} try { & $uvCommand.Source tool update-shell *> $null } catch { } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") diff --git a/install.sh b/install.sh index 207e55b..2d7a550 100755 --- a/install.sh +++ b/install.sh @@ -70,8 +70,14 @@ else fi info "Installing OpenKyrozen $release_tag from its immutable GitHub release with Python $python_version..." -"$uv_bin" tool install --python "$python_version" --force \ - --with fastapi --with uvicorn "$release_wheel_url" +install_openkyrozen() { + "$uv_bin" "$@" tool install --python "$python_version" --force \ + --with fastapi --with uvicorn "$release_wheel_url" +} +if ! install_openkyrozen; then + info 'uv cache was incomplete; retrying without the existing cache...' + install_openkyrozen --no-cache +fi "$uv_bin" tool update-shell >/dev/null 2>&1 || true profile='' diff --git a/main.py b/main.py index 166b614..3eb9498 100644 --- a/main.py +++ b/main.py @@ -1287,14 +1287,26 @@ def _self_update() -> str: f"{sys.version_info.major}.{sys.version_info.minor}" if sys.version_info[:2] in {(3, 12), (3, 13)} else "3.12" ) + command = [ + "uv", "tool", "install", "--python", requested_python, "--force", + "--with", "fastapi", "--with", "uvicorn", RELEASE_WHEEL_URL, + ] try: result = subprocess.run( - ["uv", "tool", "install", "--python", requested_python, "--force", - "--with", "fastapi", "--with", "uvicorn", RELEASE_WHEEL_URL], + command, capture_output=True, text=True, timeout=60, ) + if result.returncode != 0: + retry = subprocess.run( + ["uv", "--no-cache", *command[1:]], + capture_output=True, + text=True, + timeout=60, + ) + if retry.returncode == 0: + result = retry out = result.stdout.strip() or "" err = result.stderr.strip() or "" if result.returncode != 0: diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 2f2c880..b71502f 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -92,6 +92,7 @@ def test_posix_installer_has_valid_syntax_and_idempotent_user_path_logic(self): self.assertIn("release_version='2.0.2'", text) self.assertIn("releases/download", text) self.assertIn("--with fastapi --with uvicorn", text) + self.assertIn("--no-cache", text) self.assertNotIn("pypi.org", text) self.assertIn("installed_version=", text) @@ -117,6 +118,7 @@ def test_powershell_installer_is_static_safe_and_parses_when_available(self): self.assertIn("v2.0.2", text) self.assertIn("releases/download", text) self.assertIn("--with fastapi --with uvicorn", text) + self.assertIn("--no-cache", text) self.assertNotIn("pypi.org", text) powershell = shutil.which("pwsh") or shutil.which("powershell") if powershell is None: @@ -129,6 +131,56 @@ def test_powershell_installer_is_static_safe_and_parses_when_available(self): ) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_posix_installer_retries_with_uncached_uv_install(self): + with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as bin_dir: + home_path = Path(home) + bin_path = Path(bin_dir) + log_path = home_path / "uv.log" + uv_path = bin_path / "uv" + uv_path.write_text( + r"""#!/bin/sh +printf '%s\n' "$*" >> "$UV_TEST_LOG" +if [ "$1" = "python" ] && [ "$2" = "find" ]; then + exit 0 +fi +if [ "$1" = "tool" ] && [ "$2" = "install" ]; then + exit 1 +fi +if [ "$1" = "--no-cache" ] && [ "$2" = "tool" ] && [ "$3" = "install" ]; then + mkdir -p "$HOME/.local/bin" + printf '%s\n' '#!/bin/sh' 'if [ "$1" = "--version" ]; then printf "%s\n" "OpenKyrozen 2.0.2"; fi' > "$HOME/.local/bin/kyrozen" + chmod +x "$HOME/.local/bin/kyrozen" + exit 0 +fi +exit 0 +""", + encoding="utf-8", + ) + curl_path = bin_path / "curl" + curl_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + os.chmod(uv_path, 0o755) + os.chmod(curl_path, 0o755) + env = os.environ.copy() + env.update({ + "HOME": str(home_path), + "PATH": f"{bin_path}:{env.get('PATH', '')}", + "SHELL": "/bin/sh", + "UV_TEST_LOG": str(log_path), + }) + result = subprocess.run( + ["sh", str(ROOT / "install.sh")], + cwd=ROOT, env=env, capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + install_calls = [ + line for line in log_path.read_text(encoding="utf-8").splitlines() + if "tool install" in line + ] + self.assertEqual(len(install_calls), 2) + self.assertTrue(install_calls[0].startswith("tool install ")) + self.assertTrue(install_calls[1].startswith("--no-cache tool install ")) + self.assertIn("Installation complete.", result.stdout) + def test_cli_and_web_help_version_flags_work_without_provider_setup(self): with tempfile.TemporaryDirectory() as home: env = os.environ.copy() @@ -195,6 +247,25 @@ def test_update_uses_the_package_manager_instead_of_git_pull(self): self.assertIn("uvicorn", command) self.assertTrue(command[-1].endswith("/v2.0.2/openkyrozen-2.0.2-py3-none-any.whl")) + def test_update_retries_without_uv_cache_after_install_failure(self): + import main + + failed = subprocess.CompletedProcess( + ["uv", "tool", "install"], 1, stdout="", stderr="missing archive", + ) + completed = subprocess.CompletedProcess( + ["uv", "--no-cache", "tool", "install"], 0, stdout="upgraded", stderr="", + ) + with patch("main.shutil.which", return_value="/usr/local/bin/uv"), \ + patch("main.subprocess.run", side_effect=[failed, completed]) as run: + result = main._self_update() + self.assertIn("upgraded", result) + self.assertEqual(run.call_count, 2) + self.assertEqual( + run.call_args_list[1].args[0][:3], + ["uv", "--no-cache", "tool"], + ) + def test_docker_starts_server_in_explicit_project_mode(self): dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") self.assertIn('"server.py", "--project", "/app"', dockerfile)