Skip to content

Commit 8983473

Browse files
committed
fix: dynamically resolve standalone Python patch version from release metadata
Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
1 parent 749ea1f commit 8983473

3 files changed

Lines changed: 159 additions & 7 deletions

File tree

comfy_cli/constants.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,6 @@ class GPU_OPTION(str, Enum):
8787

8888
NODE_ZIP_FILENAME = "node.zip"
8989

90-
# The default version to download from python-build-standalone.
91-
DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION = "3.12.13"
90+
# The default minor version series to download from python-build-standalone.
91+
# The exact patch version is resolved dynamically from the release metadata.
92+
DEFAULT_STANDALONE_PYTHON_MINOR_VERSION = "3.12"

comfy_cli/standalone.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import logging
2+
import re
13
import shutil
24
import subprocess
35
from pathlib import Path
46

57
import requests
68

7-
from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION, OS, PROC
9+
from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, OS, PROC
810
from comfy_cli.typing import PathLike
911
from comfy_cli.utils import create_tarball, download_url, extract_tarball, get_os, get_proc
1012
from comfy_cli.uv import DependencyCompiler
1113

14+
logger = logging.getLogger(__name__)
15+
1216
_here = Path(__file__).expanduser().resolve().parent
1317

1418
_platform_targets = {
@@ -19,15 +23,43 @@
1923
}
2024

2125
_latest_release_json_url = (
22-
"https://raw.githubusercontent.com/indygreg/python-build-standalone/latest-release/latest-release.json"
26+
"https://raw.githubusercontent.com/astral-sh/python-build-standalone/latest-release/latest-release.json"
2327
)
24-
_asset_url_prefix = "https://github.com/indygreg/python-build-standalone/releases/download/{tag}"
28+
_asset_url_prefix = "https://github.com/astral-sh/python-build-standalone/releases/download/{tag}"
29+
30+
31+
def _resolve_python_version(asset_url_prefix: str, minor_version: str) -> str:
32+
"""Resolve the exact patch version for a minor version series from the release SHA256SUMS.
33+
34+
Downloads the SHA256SUMS file (~45 KB) from the release and parses it to find
35+
the available patch version for the requested minor series (e.g. "3.12" -> "3.12.13").
36+
"""
37+
sha256sums_url = f"{asset_url_prefix.rstrip('/')}/SHA256SUMS"
38+
response = requests.get(sha256sums_url)
39+
response.raise_for_status()
40+
41+
pattern = re.compile(rf"cpython-({re.escape(minor_version)}\.\d+)\+")
42+
versions = set()
43+
for line in response.text.splitlines():
44+
match = pattern.search(line)
45+
if match:
46+
versions.add(match.group(1))
47+
48+
if not versions:
49+
raise RuntimeError(
50+
f"No Python {minor_version}.x found in release. Available versions can be checked at {sha256sums_url}"
51+
)
52+
53+
# There should be exactly one patch version per minor series in a release, but pick the highest just in case.
54+
resolved = max(versions, key=lambda v: tuple(int(x) for x in v.split(".")))
55+
logger.info("Resolved Python %s -> %s", minor_version, resolved)
56+
return resolved
2557

2658

2759
def download_standalone_python(
2860
platform: str | None = None,
2961
proc: str | None = None,
30-
version: str = DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION,
62+
version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION,
3163
tag: str = "latest",
3264
flavor: str = "install_only",
3365
cwd: PathLike = ".",
@@ -52,6 +84,11 @@ def download_standalone_python(
5284
else:
5385
asset_url_prefix = _asset_url_prefix.format(tag=tag)
5486

87+
# If version is a minor version (e.g. "3.12"), resolve the exact patch version
88+
# from the release metadata. Full versions (e.g. "3.12.13") are used as-is.
89+
if version.count(".") == 1:
90+
version = _resolve_python_version(asset_url_prefix, version)
91+
5592
name = f"cpython-{version}+{tag}-{target}-{flavor}"
5693
fname = f"{name}.tar.gz"
5794
url = f"{asset_url_prefix.rstrip('/')}/{fname.lstrip('/')}"
@@ -64,7 +101,7 @@ class StandalonePython:
64101
def FromDistro(
65102
platform: str | None = None,
66103
proc: str | None = None,
67-
version: str = DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION,
104+
version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION,
68105
tag: str = "latest",
69106
flavor: str = "install_only",
70107
cwd: PathLike = ".",

tests/comfy_cli/test_standalone.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
5+
from comfy_cli.standalone import _resolve_python_version, download_standalone_python
6+
7+
# Minimal SHA256SUMS content matching real format
8+
SAMPLE_SHA256SUMS = """\
9+
aaa cpython-3.10.20+20260310-aarch64-apple-darwin-install_only.tar.gz
10+
bbb cpython-3.10.20+20260310-x86_64-pc-windows-msvc-install_only.tar.gz
11+
ccc cpython-3.12.13+20260310-aarch64-apple-darwin-install_only.tar.gz
12+
ddd cpython-3.12.13+20260310-x86_64-pc-windows-msvc-install_only.tar.gz
13+
eee cpython-3.12.13+20260310-x86_64_v3-unknown-linux-gnu-install_only.tar.gz
14+
fff cpython-3.13.12+20260310-x86_64-pc-windows-msvc-install_only.tar.gz
15+
"""
16+
17+
18+
def _mock_response(text, status_code=200):
19+
resp = MagicMock()
20+
resp.text = text
21+
resp.status_code = status_code
22+
resp.raise_for_status = MagicMock()
23+
if status_code != 200:
24+
resp.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
25+
return resp
26+
27+
28+
class TestResolvePythonVersion:
29+
@patch("comfy_cli.standalone.requests.get")
30+
def test_resolves_312(self, mock_get):
31+
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
32+
result = _resolve_python_version("https://example.com/release", "3.12")
33+
assert result == "3.12.13"
34+
35+
@patch("comfy_cli.standalone.requests.get")
36+
def test_resolves_310(self, mock_get):
37+
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
38+
result = _resolve_python_version("https://example.com/release", "3.10")
39+
assert result == "3.10.20"
40+
41+
@patch("comfy_cli.standalone.requests.get")
42+
def test_resolves_313(self, mock_get):
43+
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
44+
result = _resolve_python_version("https://example.com/release", "3.13")
45+
assert result == "3.13.12"
46+
47+
@patch("comfy_cli.standalone.requests.get")
48+
def test_missing_version_raises(self, mock_get):
49+
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
50+
with pytest.raises(RuntimeError, match="No Python 3.14.x found"):
51+
_resolve_python_version("https://example.com/release", "3.14")
52+
53+
@patch("comfy_cli.standalone.requests.get")
54+
def test_http_error_propagates(self, mock_get):
55+
mock_get.return_value = _mock_response("", status_code=404)
56+
with pytest.raises(Exception, match="HTTP 404"):
57+
_resolve_python_version("https://example.com/release", "3.12")
58+
59+
@patch("comfy_cli.standalone.requests.get")
60+
def test_picks_highest_patch(self, mock_get):
61+
"""If multiple patch versions exist for a minor series, pick the highest."""
62+
sha256sums = """\
63+
aaa cpython-3.12.10+20260310-x86_64-install_only.tar.gz
64+
bbb cpython-3.12.13+20260310-x86_64-install_only.tar.gz
65+
ccc cpython-3.12.9+20260310-x86_64-install_only.tar.gz
66+
"""
67+
mock_get.return_value = _mock_response(sha256sums)
68+
result = _resolve_python_version("https://example.com/release", "3.12")
69+
assert result == "3.12.13"
70+
71+
@patch("comfy_cli.standalone.requests.get")
72+
def test_url_construction(self, mock_get):
73+
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
74+
_resolve_python_version("https://example.com/release/", "3.12")
75+
mock_get.assert_called_once_with("https://example.com/release/SHA256SUMS")
76+
77+
@patch("comfy_cli.standalone.requests.get")
78+
def test_no_false_match_across_minor(self, mock_get):
79+
"""3.1 should not match 3.12 or 3.10."""
80+
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
81+
with pytest.raises(RuntimeError, match="No Python 3.1.x found"):
82+
_resolve_python_version("https://example.com/release", "3.1")
83+
84+
85+
class TestDownloadStandalonePython:
86+
@patch("comfy_cli.standalone.download_url")
87+
@patch("comfy_cli.standalone.requests.get")
88+
def test_minor_version_triggers_resolution(self, mock_get, mock_download):
89+
"""When version is a minor version (X.Y), it should resolve the patch."""
90+
mock_get.side_effect = [
91+
_mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}'),
92+
_mock_response(SAMPLE_SHA256SUMS),
93+
]
94+
mock_download.return_value = "python.tar.gz"
95+
96+
download_standalone_python(platform="linux", proc="x86_64", version="3.12")
97+
98+
# Should have fetched latest-release.json and SHA256SUMS
99+
assert mock_get.call_count == 2
100+
# Download URL should contain resolved version
101+
call_args = mock_download.call_args
102+
assert "3.12.13" in call_args[1].get("url", "") or "3.12.13" in str(call_args)
103+
104+
@patch("comfy_cli.standalone.download_url")
105+
@patch("comfy_cli.standalone.requests.get")
106+
def test_full_version_skips_resolution(self, mock_get, mock_download):
107+
"""When version is a full version (X.Y.Z), no resolution needed."""
108+
mock_get.return_value = _mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}')
109+
mock_download.return_value = "python.tar.gz"
110+
111+
download_standalone_python(platform="linux", proc="x86_64", version="3.12.13")
112+
113+
# Should have fetched only latest-release.json, not SHA256SUMS
114+
assert mock_get.call_count == 1

0 commit comments

Comments
 (0)