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
5 changes: 3 additions & 2 deletions comfy_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,6 @@ class GPU_OPTION(str, Enum):

NODE_ZIP_FILENAME = "node.zip"

# The default version to download from python-build-standalone.
DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION = "3.12.13"
# The default minor version series to download from python-build-standalone.
# The exact patch version is resolved dynamically from the release metadata.
DEFAULT_STANDALONE_PYTHON_MINOR_VERSION = "3.12"
47 changes: 42 additions & 5 deletions comfy_cli/standalone.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import logging
import re
import shutil
import subprocess
from pathlib import Path

import requests

from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION, OS, PROC
from comfy_cli.constants import DEFAULT_STANDALONE_PYTHON_MINOR_VERSION, OS, PROC
from comfy_cli.typing import PathLike
from comfy_cli.utils import create_tarball, download_url, extract_tarball, get_os, get_proc
from comfy_cli.uv import DependencyCompiler

logger = logging.getLogger(__name__)

_here = Path(__file__).expanduser().resolve().parent

_platform_targets = {
Expand All @@ -19,15 +23,43 @@
}

_latest_release_json_url = (
"https://raw.githubusercontent.com/indygreg/python-build-standalone/latest-release/latest-release.json"
"https://raw.githubusercontent.com/astral-sh/python-build-standalone/latest-release/latest-release.json"
)
_asset_url_prefix = "https://github.com/indygreg/python-build-standalone/releases/download/{tag}"
_asset_url_prefix = "https://github.com/astral-sh/python-build-standalone/releases/download/{tag}"


def _resolve_python_version(asset_url_prefix: str, minor_version: str) -> str:
"""Resolve the exact patch version for a minor version series from the release SHA256SUMS.

Downloads the SHA256SUMS file (~45 KB) from the release and parses it to find
the available patch version for the requested minor series (e.g. "3.12" -> "3.12.13").
"""
sha256sums_url = f"{asset_url_prefix.rstrip('/')}/SHA256SUMS"
response = requests.get(sha256sums_url)
response.raise_for_status()

pattern = re.compile(rf"cpython-({re.escape(minor_version)}\.\d+)\+")
versions = set()
for line in response.text.splitlines():
match = pattern.search(line)
if match:
versions.add(match.group(1))

if not versions:
raise RuntimeError(
f"No Python {minor_version}.x found in release. Available versions can be checked at {sha256sums_url}"
)

# There should be exactly one patch version per minor series in a release, but pick the highest just in case.
resolved = max(versions, key=lambda v: tuple(int(x) for x in v.split(".")))
logger.info("Resolved Python %s -> %s", minor_version, resolved)
return resolved


def download_standalone_python(
platform: str | None = None,
proc: str | None = None,
version: str = DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION,
version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION,
tag: str = "latest",
flavor: str = "install_only",
cwd: PathLike = ".",
Expand All @@ -52,6 +84,11 @@ def download_standalone_python(
else:
asset_url_prefix = _asset_url_prefix.format(tag=tag)

# If version is a minor version (e.g. "3.12"), resolve the exact patch version
# from the release metadata. Full versions (e.g. "3.12.13") are used as-is.
if version.count(".") == 1:
version = _resolve_python_version(asset_url_prefix, version)

name = f"cpython-{version}+{tag}-{target}-{flavor}"
fname = f"{name}.tar.gz"
url = f"{asset_url_prefix.rstrip('/')}/{fname.lstrip('/')}"
Expand All @@ -64,7 +101,7 @@ class StandalonePython:
def FromDistro(
platform: str | None = None,
proc: str | None = None,
version: str = DEFAULT_STANDALONE_PYTHON_DOWNLOAD_VERSION,
version: str = DEFAULT_STANDALONE_PYTHON_MINOR_VERSION,
tag: str = "latest",
flavor: str = "install_only",
cwd: PathLike = ".",
Expand Down
171 changes: 171 additions & 0 deletions tests/comfy_cli/test_standalone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import os
import re
from unittest.mock import MagicMock, patch

import pytest
import requests

from comfy_cli.standalone import (
_latest_release_json_url,
_resolve_python_version,
download_standalone_python,
)

# Minimal SHA256SUMS content matching real format
SAMPLE_SHA256SUMS = """\
aaa cpython-3.10.20+20260310-aarch64-apple-darwin-install_only.tar.gz
bbb cpython-3.10.20+20260310-x86_64-pc-windows-msvc-install_only.tar.gz
ccc cpython-3.12.13+20260310-aarch64-apple-darwin-install_only.tar.gz
ddd cpython-3.12.13+20260310-x86_64-pc-windows-msvc-install_only.tar.gz
eee cpython-3.12.13+20260310-x86_64_v3-unknown-linux-gnu-install_only.tar.gz
fff cpython-3.13.12+20260310-x86_64-pc-windows-msvc-install_only.tar.gz
"""


def _mock_response(text, status_code=200):
resp = MagicMock()
resp.text = text
resp.status_code = status_code
resp.raise_for_status = MagicMock()
if status_code != 200:
resp.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
return resp


class TestResolvePythonVersion:
@patch("comfy_cli.standalone.requests.get")
def test_resolves_312(self, mock_get):
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
result = _resolve_python_version("https://example.com/release", "3.12")
assert result == "3.12.13"

@patch("comfy_cli.standalone.requests.get")
def test_resolves_310(self, mock_get):
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
result = _resolve_python_version("https://example.com/release", "3.10")
assert result == "3.10.20"

@patch("comfy_cli.standalone.requests.get")
def test_resolves_313(self, mock_get):
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
result = _resolve_python_version("https://example.com/release", "3.13")
assert result == "3.13.12"

@patch("comfy_cli.standalone.requests.get")
def test_missing_version_raises(self, mock_get):
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
with pytest.raises(RuntimeError, match="No Python 3.14.x found"):
_resolve_python_version("https://example.com/release", "3.14")

@patch("comfy_cli.standalone.requests.get")
def test_http_error_propagates(self, mock_get):
mock_get.return_value = _mock_response("", status_code=404)
with pytest.raises(Exception, match="HTTP 404"):
_resolve_python_version("https://example.com/release", "3.12")

@patch("comfy_cli.standalone.requests.get")
def test_picks_highest_patch(self, mock_get):
"""If multiple patch versions exist for a minor series, pick the highest."""
sha256sums = """\
aaa cpython-3.12.10+20260310-x86_64-install_only.tar.gz
bbb cpython-3.12.13+20260310-x86_64-install_only.tar.gz
ccc cpython-3.12.9+20260310-x86_64-install_only.tar.gz
"""
mock_get.return_value = _mock_response(sha256sums)
result = _resolve_python_version("https://example.com/release", "3.12")
assert result == "3.12.13"

@patch("comfy_cli.standalone.requests.get")
def test_url_construction(self, mock_get):
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
_resolve_python_version("https://example.com/release/", "3.12")
mock_get.assert_called_once_with("https://example.com/release/SHA256SUMS")

@patch("comfy_cli.standalone.requests.get")
def test_no_false_match_across_minor(self, mock_get):
"""3.1 should not match 3.12 or 3.10."""
mock_get.return_value = _mock_response(SAMPLE_SHA256SUMS)
with pytest.raises(RuntimeError, match="No Python 3.1.x found"):
_resolve_python_version("https://example.com/release", "3.1")


class TestDownloadStandalonePython:
@patch("comfy_cli.standalone.download_url")
@patch("comfy_cli.standalone.requests.get")
def test_minor_version_triggers_resolution(self, mock_get, mock_download):
"""When version is a minor version (X.Y), it should resolve the patch."""
mock_get.side_effect = [
_mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}'),
_mock_response(SAMPLE_SHA256SUMS),
]
mock_download.return_value = "python.tar.gz"

download_standalone_python(platform="linux", proc="x86_64", version="3.12")

# Should have fetched latest-release.json and SHA256SUMS
assert mock_get.call_count == 2
# Download URL should contain resolved version
call_args = mock_download.call_args
assert "3.12.13" in call_args[1].get("url", "") or "3.12.13" in str(call_args)

@patch("comfy_cli.standalone.download_url")
@patch("comfy_cli.standalone.requests.get")
def test_full_version_skips_resolution(self, mock_get, mock_download):
"""When version is a full version (X.Y.Z), no resolution needed."""
mock_get.return_value = _mock_response('{"tag": "20260310", "asset_url_prefix": "https://example.com/release"}')
mock_download.return_value = "python.tar.gz"

download_standalone_python(platform="linux", proc="x86_64", version="3.12.13")

# Should have fetched only latest-release.json, not SHA256SUMS
assert mock_get.call_count == 1


_require_network = pytest.mark.skipif(
os.getenv("TEST_NETWORK", "false").lower() != "true",
reason="Set TEST_NETWORK=true to run integration tests that hit the network",
)


@_require_network
class TestResolveVersionIntegration:
"""Integration tests that hit the real python-build-standalone release endpoints."""

def test_latest_release_json_is_reachable(self):
response = requests.get(_latest_release_json_url)
assert response.status_code == 200
data = response.json()
assert "tag" in data
assert "asset_url_prefix" in data
# tag should be a date string like "20260310"
assert re.fullmatch(r"\d{8}", data["tag"]), f"unexpected tag format: {data['tag']}"

def test_resolve_312_from_real_release(self):
response = requests.get(_latest_release_json_url)
data = response.json()
asset_url_prefix = data["asset_url_prefix"]

version = _resolve_python_version(asset_url_prefix, "3.12")

# Should be a valid 3.12.x version
assert re.fullmatch(r"3\.12\.\d+", version), f"unexpected version: {version}"

def test_sha256sums_contains_expected_platforms(self):
"""Verify the platforms we use in _platform_targets actually exist in the release."""
response = requests.get(_latest_release_json_url)
data = response.json()
asset_url_prefix = data["asset_url_prefix"]

sha256sums_url = f"{asset_url_prefix}/SHA256SUMS"
sha_response = requests.get(sha256sums_url)
assert sha_response.status_code == 200

content = sha_response.text
expected_targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64_v3-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
]
for target in expected_targets:
assert target in content, f"platform target '{target}' not found in SHA256SUMS"
Loading