Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
requires-python = ">=3.10,<3.15"
requires-python = ">=3.10,<3.13"

dependencies = [
"cloudpickle>=3.1.1",
Expand Down
34 changes: 29 additions & 5 deletions src/runpod_flash/cli/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
except ImportError:
import tomli as tomllib # Python 3.9-3.10

from runpod_flash.core.resources.constants import MAX_TARBALL_SIZE_MB
from runpod_flash.core.resources.constants import (
MAX_TARBALL_SIZE_MB,
SUPPORTED_PYTHON_VERSIONS,
validate_python_version,
)

from ..utils.ignore import get_file_tree, load_ignore_patterns
from .build_utils.handler_generator import HandlerGenerator
Expand Down Expand Up @@ -233,6 +237,24 @@ def run_build(
spec = load_ignore_patterns(project_dir)
files = get_file_tree(project_dir, spec)

# Validate Python version unconditionally — even projects with no dependencies
# must build on a supported Python to avoid runtime ABI mismatches.
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
try:
validate_python_version(python_version)
except ValueError:
console.print(
f"\n[red]Python {python_version} is not supported for Flash deployment.[/red]"
)
console.print(
f"[yellow]Supported versions: {', '.join(SUPPORTED_PYTHON_VERSIONS)}[/yellow]"
)
console.print(
"[yellow]Please switch your local Python interpreter to a supported "
"version, or build inside a virtual environment that uses one.[/yellow]"
)
raise typer.Exit(1)

try:
copy_project_files(files, project_dir, build_dir)

Expand All @@ -241,7 +263,11 @@ def run_build(
remote_functions = scanner.discover_remote_functions()

manifest_builder = ManifestBuilder(
app_name, remote_functions, scanner, build_dir=build_dir
app_name,
remote_functions,
scanner,
build_dir=build_dir,
python_version=python_version,
)
manifest = manifest_builder.build()
manifest_path = build_dir / "flash_manifest.json"
Expand Down Expand Up @@ -792,13 +818,11 @@ def install_dependencies(
console.print(f" • {UV_COMMAND} {PIP_MODULE} install {PIP_MODULE}")
return False

# Get current Python version for compatibility
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"

# Determine if using uv pip or standard pip (different flag formats)
is_uv_pip = pip_cmd[0] == UV_COMMAND

# Build pip command with platform-specific flags for RunPod serverless
python_version = f"{sys.version_info.major}.{sys.version_info.minor}"
cmd = pip_cmd + [
"install",
"--target",
Expand Down
5 changes: 5 additions & 0 deletions src/runpod_flash/cli/commands/build_utils/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,17 @@ def __init__(
remote_functions: List[RemoteFunctionMetadata],
scanner=None,
build_dir: Optional[Path] = None,
python_version: Optional[str] = None,
):
self.project_name = project_name
self.remote_functions = remote_functions
self.scanner = (
scanner # Optional: RemoteDecoratorScanner with resource config info
)
self.build_dir = build_dir
self.python_version = (
python_version or f"{sys.version_info.major}.{sys.version_info.minor}"
)

def _import_module(self, file_path: Path):
"""Import a module from file path, returning (module, cleanup_fn).
Expand Down Expand Up @@ -406,6 +410,7 @@ def build(self) -> Dict[str, Any]:

manifest = {
"version": "1.0",
"python_version": self.python_version,
"generated_at": datetime.now(timezone.utc)
.isoformat()
.replace("+00:00", "Z"),
Expand Down
5 changes: 5 additions & 0 deletions src/runpod_flash/cli/utils/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,12 @@ async def provision_resources_for_build(
resources_to_provision = []

# Create resource configs from manifest
manifest_python_version = manifest.get("python_version")
for resource_name, resource_config in manifest["resources"].items():
resource = create_resource_from_manifest(
resource_name,
resource_config,
python_version=manifest_python_version,
)
resources_to_provision.append((resource_name, resource))

Expand Down Expand Up @@ -236,6 +238,7 @@ async def reconcile_and_provision_resources(
# Create resource manager
manager = ResourceManager()
actions = []
manifest_python_version = local_manifest.get("python_version")

# Provision new resources
for resource_name in sorted(to_provision):
Expand All @@ -244,6 +247,7 @@ async def reconcile_and_provision_resources(
resource_name,
resource_config,
flash_environment_id=environment_id,
python_version=manifest_python_version,
)
actions.append(
("provision", resource_name, manager.get_or_deploy_resource(resource))
Expand All @@ -267,6 +271,7 @@ async def reconcile_and_provision_resources(
resource_name,
local_config,
flash_environment_id=environment_id,
python_version=manifest_python_version,
)
actions.append(
("update", resource_name, manager.get_or_deploy_resource(resource))
Expand Down
106 changes: 102 additions & 4 deletions src/runpod_flash/core/resources/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,113 @@ def _endpoint_domain_from_base_url(base_url: str) -> str:
ENDPOINT_DOMAIN = _endpoint_domain_from_base_url(runpod.endpoint_url_base)


# Python version support
SUPPORTED_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12")
GPU_PYTHON_VERSIONS: tuple[str, ...] = ("3.11", "3.12")
CPU_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12")
DEFAULT_PYTHON_VERSION: str = "3.11"

# Image type to repository mapping
_IMAGE_REPOS: dict[str, str] = {
"gpu": "runpod/flash",
"cpu": "runpod/flash-cpu",
"lb": "runpod/flash-lb",
"lb-cpu": "runpod/flash-lb-cpu",
}

# Image types that require GPU-compatible Python versions
_GPU_IMAGE_TYPES: frozenset[str] = frozenset({"gpu", "lb"})

# Image type to environment variable override mapping
_IMAGE_ENV_VARS: dict[str, str] = {
"gpu": "FLASH_GPU_IMAGE",
"cpu": "FLASH_CPU_IMAGE",
"lb": "FLASH_LB_IMAGE",
"lb-cpu": "FLASH_CPU_LB_IMAGE",
}


def validate_python_version(version: str) -> str:
"""Validate that a Python version string is supported.

Args:
version: Python version string (e.g. "3.11").

Returns:
The validated version string.

Raises:
ValueError: If version is not in SUPPORTED_PYTHON_VERSIONS.
"""
if version not in SUPPORTED_PYTHON_VERSIONS:
supported = ", ".join(SUPPORTED_PYTHON_VERSIONS)
raise ValueError(
f"Python {version} is not supported. Supported versions: {supported}"
)
return version


def get_image_name(
image_type: str,
python_version: str,
*,
tag: str | None = None,
) -> str:
"""Resolve a versioned Docker image name for the given type and Python version.

Args:
image_type: One of 'gpu', 'cpu', 'lb', 'lb-cpu'.
python_version: Python version string (e.g. "3.11", "3.12").
tag: Image tag suffix. Defaults to FLASH_IMAGE_TAG env var or "latest".

Returns:
Fully qualified image name, e.g. "runpod/flash:py3.12-latest".

Raises:
ValueError: If image_type is unknown, python_version is unsupported,
or a GPU image type is requested with a CPU-only Python version.
"""
if image_type not in _IMAGE_REPOS:
raise ValueError(
f"Unknown image type '{image_type}'. "
f"Valid types: {', '.join(sorted(_IMAGE_REPOS))}"
)

# Environment variable override takes precedence, bypassing version validation
env_var = _IMAGE_ENV_VARS[image_type]
override = os.environ.get(env_var)
if override:
return override

validate_python_version(python_version)

if image_type in _GPU_IMAGE_TYPES and python_version not in GPU_PYTHON_VERSIONS:
gpu_versions = ", ".join(GPU_PYTHON_VERSIONS)
raise ValueError(
f"GPU endpoints require Python {gpu_versions}. Got Python {python_version}."
)

resolved_tag = tag or os.environ.get("FLASH_IMAGE_TAG", "latest")
repo = _IMAGE_REPOS[image_type]
return f"{repo}:py{python_version}-{resolved_tag}"


# Docker image configuration
FLASH_IMAGE_TAG = os.environ.get("FLASH_IMAGE_TAG", "latest")
_RESOLVED_TAG = FLASH_IMAGE_TAG

FLASH_GPU_IMAGE = os.environ.get("FLASH_GPU_IMAGE", f"runpod/flash:{_RESOLVED_TAG}")
FLASH_CPU_IMAGE = os.environ.get("FLASH_CPU_IMAGE", f"runpod/flash-cpu:{_RESOLVED_TAG}")
FLASH_LB_IMAGE = os.environ.get("FLASH_LB_IMAGE", f"runpod/flash-lb:{_RESOLVED_TAG}")
FLASH_GPU_IMAGE = os.environ.get(
"FLASH_GPU_IMAGE", f"runpod/flash:py{DEFAULT_PYTHON_VERSION}-{_RESOLVED_TAG}"
)
FLASH_CPU_IMAGE = os.environ.get(
"FLASH_CPU_IMAGE", f"runpod/flash-cpu:py{DEFAULT_PYTHON_VERSION}-{_RESOLVED_TAG}"
)
FLASH_LB_IMAGE = os.environ.get(
"FLASH_LB_IMAGE", f"runpod/flash-lb:py{DEFAULT_PYTHON_VERSION}-{_RESOLVED_TAG}"
)
FLASH_CPU_LB_IMAGE = os.environ.get(
"FLASH_CPU_LB_IMAGE", f"runpod/flash-lb-cpu:{_RESOLVED_TAG}"
"FLASH_CPU_LB_IMAGE",
f"runpod/flash-lb-cpu:py{DEFAULT_PYTHON_VERSION}-{_RESOLVED_TAG}",
)

# Worker configuration defaults
Expand Down
Loading
Loading