Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/select-uv-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Choose a current security-only Python 3.10 build for the installation smoke test."""
import json
import os
import platform
import urllib.request

os_name = "windows" if platform.system() == "Windows" else "linux"
request = urllib.request.Request(
"https://vault.vfox.dev/python/uv-build?os=" + os_name + "&arch=x86_64",
headers={"User-Agent": "vfox"},
)
with urllib.request.urlopen(request, timeout=30) as response:
items = json.load(response)["items"]
versions = set()
for item in items:
version = item.get("version", "")
parts = version.split(".")
if (item.get("implementation") == "cpython"
and item.get("variant", "default") == "default"
and len(parts) == 3 and parts[:2] == ["3", "10"]
and all(part.isdigit() for part in parts)
and item.get("filename", "").endswith((".tar.gz", ".zip"))):
versions.add(version)
if not versions:
raise RuntimeError("No Python 3.10 uv-build installer is available for " + os_name)
version = max(versions, key=lambda value: tuple(map(int, value.split("."))))
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output:
output.write("VFOX_TEST_UV_VERSION=" + version + "\n")
print("Testing uv-build Python " + version + " on " + os_name)
23 changes: 23 additions & 0 deletions .github/workflows/hooks-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Test Plugin

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: '1.24.0'
cache: false
- name: Test plugin hooks with vfox's Lua interpreter
run: go run github.com/yuin/gopher-lua/cmd/glua@v1.1.1 tests/hooks_test.lua
50 changes: 33 additions & 17 deletions .github/workflows/vfox-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ on:
tags: ["v*"]

permissions:
contents: write
contents: read

jobs:
test-on-linux:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python-version: [3.12.2]
Expand All @@ -33,10 +34,13 @@ jobs:
exclusions: "*.git* manifest.json"

- name: Install vfox
env:
GITHUB_TOKEN: ${{ github.token }}
shell: bash
run: |
for i in {1..10}; do
curl -sSL https://raw.githubusercontent.com/version-fox/vfox/main/install.sh | bash && break || sleep 5;
for _ in {1..10}; do
if curl -fsSL https://raw.githubusercontent.com/version-fox/vfox/main/install.sh | bash; then break; fi
sleep 5
done
vfox --version

Expand All @@ -57,39 +61,47 @@ jobs:
run: |
eval "$(vfox activate bash)"
python_version=$(python -c 'import sys;print(sys.version, sys.path)')
echo $python_version
echo "$python_version"

if [[ ! $python_version == ${{ matrix.python-version }}* ]]; then
exit 1
fi

- name: Install Python 3.10.20 with uv-build
- name: Select an available uv-build Python 3.10 version
if: matrix.mirror == ''
run: python .github/select-uv-version.py

- name: Install Python 3.10 with uv-build
if: matrix.mirror == ''
env:
VFOX_PYTHON_USE_UV_BUILD: "1"
run: |
eval "$(vfox activate bash)"
vfox install python@3.10.20
vfox use -g python@3.10.20
vfox install python@"$VFOX_TEST_UV_VERSION"
vfox use -g python@"$VFOX_TEST_UV_VERSION"
vfox current

- name: Check uv-build python 3.10.20
- name: Check uv-build python 3.10
if: matrix.mirror == ''
run: |
eval "$(vfox activate bash)"
python_version=$(python -c 'import sys;print(sys.version)')
echo $python_version
echo "$python_version"

if [[ ! $python_version == 3.10.20* ]]; then
if [[ ! $python_version == "$VFOX_TEST_UV_VERSION"* ]]; then
exit 1
fi

python -m pip --version
pip --version

test-on-windows:
defaults:
run:
shell: pwsh
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest]
python-version: [3.12.2]
Expand Down Expand Up @@ -143,17 +155,21 @@ jobs:
exit 1
}

- name: Install Python 3.10.20 with uv-build
- name: Select an available uv-build Python 3.10 version
if: matrix.mirror == ''
run: python .github/select-uv-version.py

- name: Install Python 3.10 with uv-build
if: runner.os == 'Windows' && matrix.mirror == ''
env:
VFOX_PYTHON_USE_UV_BUILD: "1"
run: |
Invoke-Expression "$(vfox activate pwsh)"
vfox install python@3.10.20
vfox install "python@$env:VFOX_TEST_UV_VERSION"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
vfox use -g python@3.10.20
vfox use -g "python@$env:VFOX_TEST_UV_VERSION"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Expand All @@ -165,19 +181,19 @@ jobs:
}
Write-Output $python_version

if ($python_version -notlike "3.10.20*") {
Write-Error "expected uv-build python 3.10.20, got $python_version"
if ($python_version -notlike "$env:VFOX_TEST_UV_VERSION*") {
Write-Error "expected uv-build python $env:VFOX_TEST_UV_VERSION, got $python_version"
exit 1
}

- name: Check uv-build python 3.10.20
- name: Check uv-build python 3.10
if: runner.os == 'Windows' && matrix.mirror == ''
run: |
Invoke-Expression "$(vfox activate pwsh)"
$python_version = $(python -c 'import sys;print(sys.version)')
Write-Output $python_version

if ($python_version -notlike "3.10.20*") {
if ($python_version -notlike "$env:VFOX_TEST_UV_VERSION*") {
exit 1
}

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export VFOX_PYTHON_MIRROR=https://mirrors.huaweicloud.com/python/

## uv-build

On Windows, the default version list contains stable installers published on
python.org for the current architecture. Source-only releases (for example
3.8.18) are omitted. `VFOX_PYTHON_MIRROR` still controls installer downloads;
version discovery uses the official Windows download index. The optional uv-build
mode below uses its own platform-specific build list.

Set `VFOX_PYTHON_USE_UV_BUILD=1` to install prebuilt Python archives from the
vfox vault uv-build endpoint instead of building from pyenv/python-build.

Expand Down
2 changes: 1 addition & 1 deletion hooks/available.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ function PLUGIN:Available(ctx)
end

if OS_TYPE == "windows" then
return parseVersion()
return parseWindowsVersions()
else
return parseVersionFromPyenv()
end
Expand Down
40 changes: 40 additions & 0 deletions lib/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,46 @@ function fixHeaders()
REQUEST_HEADERS["User-Agent"] = "vfox v" .. RUNTIME.version;
end

-- The FTP index includes source-only security releases. The Windows download
-- page lists actual installers, so one request can filter by host architecture.
function parseWindowsVersions()
fixHeaders()
local url = "https://www.python.org/downloads/windows/"
local resp, err = http.get({ url = url, headers = REQUEST_HEADERS })
if err ~= nil or resp == nil then
error("Failed to list Windows Python installers: " .. tostring(err or "empty response"))
end
if resp.status_code ~= 200 then
error("Failed to list Windows Python installers: HTTP " .. tostring(resp.status_code))
end
local arch = RUNTIME.archType
if arch ~= "amd64" and arch ~= "386" and arch ~= "arm64" then
error("Unsupported Windows Python architecture: " .. tostring(arch))
end
local result, seen = {}, {}
html.parse(resp.body):find("a"):each(function(_, selection)
local href = selection:attr("href") or ""
local filename = href:match("([^/]+)$") or ""
local version = filename:match("^python%-(%d+%.%d+%.%d+)") or filename:match("^python%-(%d+%.%d+)")
if not version then return end
local exeSuffix = arch == "386" and "" or "-" .. arch
local msiSuffix = arch == "386" and "" or "." .. arch
if filename ~= "python-" .. version .. exeSuffix .. ".exe"
and filename ~= "python-" .. version .. msiSuffix .. ".msi" then
return
end
if compare_versions(version, "2.5.0") >= 0 and not seen[version] then
seen[version] = true
table.insert(result, { version = version, note = "" })
end
end)
table.sort(result, function(a, b) return compare_versions(a.version, b.version) > 0 end)
if #result == 0 then
error("No Windows Python installers found for " .. arch .. " at " .. url)
end
return result
end

function parseVersion()
fixHeaders()

Expand Down
86 changes: 86 additions & 0 deletions tests/hooks_test.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package.path = "./lib/?.lua;" .. package.path
PLUGIN = {}
RUNTIME = { osType = "windows", archType = "amd64", version = "1.0.12" }
OS_TYPE = "windows"
os.getenv = function()
return nil
end
local state = {}
state.links = {
"https://www.python.org/ftp/python/3.12.8/python-3.12.8-amd64.exe",
"https://www.python.org/ftp/python/3.12.8/python-3.12.8-amd64.exe",
"https://www.python.org/ftp/python/3.12.8/python-3.12.8.exe",
"https://www.python.org/ftp/python/3.11.9/python-3.11.9-arm64.exe",
"https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe",
"https://www.python.org/ftp/python/3.4.4/python-3.4.4.amd64.msi",
"https://www.python.org/ftp/python/3.4.4/python-3.4.4.msi",
"https://www.python.org/ftp/python/3.8.18/Python-3.8.18.tar.xz",
"https://www.python.org/ftp/python/3.13.0/python-3.13.0a1-amd64.exe",
"https://www.python.org/ftp/python/pymanager/python-manager-25.0.msi",
"https://www.python.org/ftp/python/3.12.8/python-3.12.8-embed-amd64.zip",
"https://www.python.org/ftp/python/2.4/python-2.4.msi",
}
package.preload.http = function()
return {
get = function(args)
state.requests = state.requests + 1
assert(args.url == "https://www.python.org/downloads/windows/", args.url)
return state.response, state.requestError
end,
head = function()
error("version listing must not request individual installers")
end,
}
end
package.preload.html = function()
return {
parse = function()
return {
find = function()
return {
each = function(_, fn)
for i, href in ipairs(state.links) do
fn(i, {
attr = function()
return href
end,
})
end
end,
}
end,
}
end,
}
end
package.preload.json = function()
return {}
end
dofile("hooks/available.lua")
local function listing(arch, expected)
RUNTIME.archType = arch
state.response, state.requestError, state.requests = { status_code = 200, body = "fixture" }, nil, 0
local result = PLUGIN:Available({})
assert(#result == #expected, #result)
for i, version in ipairs(expected) do
assert(result[i].version == version, result[i].version)
end
assert(state.requests == 1, state.requests)
end
listing("amd64", { "3.12.8", "3.11.9", "3.4.4" })
listing("386", { "3.12.8", "3.4.4" })
listing("arm64", { "3.11.9" })
local function fails(expected)
local ok, err = pcall(function()
PLUGIN:Available({})
end)
assert(not ok and tostring(err):find(expected, 1, true), tostring(err))
end
state.response, state.requestError = nil, "timeout"
fails("timeout")
state.response, state.requestError = { status_code = 503 }, nil
fails("503")
state.response = { status_code = 200, body = "no installers" }
state.links = {}
fails("No Windows Python installers")
print("Python Windows architecture, filtering and failure cases passed")