Skip to content
Open
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
6 changes: 3 additions & 3 deletions docs/Flash_Deploy_Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ This guide walks through deploying a Flash application from local development to

### Python version selection

Flash apps ship as a single tarball, so every resource in an app shares one Python version. The worker runtime defaults to 3.12 (the version torch is pre-installed for in the GPU base image). Select a different version in two ways:
Flash apps ship as a single tarball, so every resource in an app shares one Python version. By default, `flash build` and `flash deploy` target the Python version you're running flash from — if you're on 3.11 locally, your deploy runs on 3.11; on 3.13, it runs on 3.13. The build prints the resolved version and its source. If your local interpreter is outside the supported set (`3.10`–`3.13`), the build fails with an actionable error. Override the default in two ways:

- **Per-resource declaration**: set `python_version="3.11"` on any resource config — all resources in the same app must agree or leave it unset.
- **App-level override**: pass `--python-version 3.11` to `flash build` or `flash deploy`. The override wins over per-resource values that are unset and must match any that are set.
- **Per-resource declaration**: set `python_version="3.11"` on any resource config — all resources in the same app must agree or leave it unset. For projects shared across a team or CI, declaring it explicitly makes the deploy result identical regardless of who runs `flash build` and which interpreter they have.
- **App-level override**: pass `--python-version 3.11` to `flash build` or `flash deploy`. The override sets the target when no resource declares one and must match any that are set (a conflict raises).

| Version | Status | GPU cold-start | Notes |
|---------|--------|----------------|-------|
Expand Down
26 changes: 26 additions & 0 deletions src/runpod_flash/cli/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,28 @@ def _resolve_pip_python_version(manifest: dict) -> str | None:
return max(versions)


def _python_version_source(override: str | None, resources_dict: dict) -> str:
"""Return a human-readable source string for the resolved Python version.

Used at build time to surface where the resolved version came from:
explicit override, per-resource declaration, or local interpreter match.
"""
if override:
return "--python-version override"
declared = {
name: r["python_version"]
for name, r in resources_dict.items()
if r.get("python_version")
}
if declared:
# All declared values are identical at this point — reconcile would
# have raised otherwise — so report the lexicographically-first
# resource name for stability.
name = next(iter(sorted(declared)))
return f"declared on resource {name}"
return "matched local interpreter"


def run_build(
project_dir: Path,
app_name: str,
Expand Down Expand Up @@ -288,6 +310,10 @@ def run_build(
python_version=manifest_python_version_override,
)
manifest = manifest_builder.build()
console.print(
f"[dim]targeting Python {manifest_builder.python_version} "
f"({_python_version_source(manifest_python_version_override, manifest.get('resources', {}))})[/dim]"
)
manifest["source_fingerprint"] = compute_source_fingerprint(
project_dir, files
)
Expand Down
33 changes: 24 additions & 9 deletions src/runpod_flash/cli/commands/build_utils/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from typing import Any, Dict, List, Optional

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

Expand Down Expand Up @@ -284,18 +284,23 @@ def _extract_config_properties(config: Dict[str, Any], resource_config) -> None:
def _reconcile_python_version(
self, resources_dict: Dict[str, Dict[str, Any]]
) -> str:
"""Pick one Python version for the app from per-resource declarations.
"""Pick one Python version for the app.

Flash apps ship as a single tarball, so every resource must target the
same Python ABI. Resolution order:
1. Explicit override passed to ManifestBuilder (validated)
2. Exactly one distinct ``python_version`` declared across resources
3. ``DEFAULT_PYTHON_VERSION`` when no resource declares one
3. The local interpreter (``sys.version_info``) — the user's
environment is the source of truth when nothing else is declared.

There is no fallback to a hardcoded default. A local interpreter
outside ``SUPPORTED_PYTHON_VERSIONS`` raises an actionable error.

Raises:
ValueError: When resources declare conflicting ``python_version``
values, or when the override conflicts with a resource's
explicit declaration.
ValueError: when resources declare conflicting ``python_version``
values; when the override conflicts with a resource's explicit
declaration; or when the local interpreter is unsupported and
no override or declaration was provided.
"""
per_resource: Dict[str, str] = {
name: r["python_version"]
Expand Down Expand Up @@ -329,14 +334,24 @@ def _reconcile_python_version(
raise ValueError(
"Flash apps require one python_version across all resources "
f"(found {sorted(distinct)}): {details}. Set python_version to the "
"same value on every resource, or omit it to use the default "
f"({DEFAULT_PYTHON_VERSION})."
"same value on every resource, pass --python-version, or run "
"flash from a single-version interpreter."
)

if distinct:
return validate_python_version(next(iter(distinct)))

return DEFAULT_PYTHON_VERSION
# Match the user's local interpreter — parity, not policy.
local = f"{sys.version_info.major}.{sys.version_info.minor}"
if local not in SUPPORTED_PYTHON_VERSIONS:
supported = ", ".join(SUPPORTED_PYTHON_VERSIONS)
raise ValueError(
f"Local Python {local} is not supported by Flash workers "
f"(supported: {supported}). Pass --python-version, declare "
f"python_version on a resource config, or run flash from a "
f"supported interpreter."
)
return local

def build(self) -> Dict[str, Any]:
"""Build the manifest dictionary.
Expand Down
8 changes: 4 additions & 4 deletions src/runpod_flash/cli/docs/flash-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ flash build [OPTIONS]
- `--no-deps`: Skip transitive dependencies during pip install (default: false)
- `--output, -o`: Custom archive name (default: artifact.tar.gz)
- `--exclude`: Comma-separated packages to exclude (e.g., 'torch,torchvision')
- `--python-version`: Target Python version for worker images (`3.10`, `3.11`, or `3.12`). Overrides per-resource `python_version`. Default: value declared on resource configs, or 3.12 if none set.
- `--python-version`: Target Python version for worker images (`3.10`, `3.11`, `3.12`, or `3.13`). Overrides the local-interpreter default; must match any per-resource `python_version` declarations (conflicting declarations raise). By default, `flash build` targets the Python version you're running flash from.

To launch a local preview environment, use `flash deploy --preview` instead.

Expand Down Expand Up @@ -68,7 +68,7 @@ After `flash build` completes:
Flash automatically handles cross-platform builds, ensuring compatibility with Runpod's Linux x86_64 serverless infrastructure:

- **Automatic Platform Targeting**: Dependencies are always installed for Linux x86_64, regardless of your build platform (macOS, Windows, or Linux)
- **Python Version**: Targets Python 3.12 for wheel ABI selection regardless of local interpreter
- **Python Version**: Targets the resolved Python version (your local interpreter by default, or whatever `--python-version` / per-resource `python_version` selects) for wheel ABI selection
- **Binary Wheel Enforcement**: Only pre-built binary wheels are used, preventing platform-specific compilation issues

This means you can safely build on macOS ARM64, Windows, or any platform, and the deployment will work correctly on Runpod.
Expand Down Expand Up @@ -181,8 +181,8 @@ ls .flash/.build/my-project/
If a package doesn't have pre-built Linux x86_64 wheels:

1. **Install standard pip**: `python -m ensurepip --upgrade` -- standard pip has better manylinux compatibility than uv pip
2. **Check package availability**: Visit PyPI and verify the package has Linux wheels for Python 3.12
3. **Python 3.12**: All flash workers run Python 3.12. Ensure packages are available for this version.
2. **Check package availability**: Visit PyPI and verify the package has Linux wheels for your target Python version (`3.10`, `3.11`, `3.12`, or `3.13`)
3. **Match interpreter**: Flash builds default to your local Python version. If a wheel is missing for that version, either pick a different `--python-version` or upgrade/downgrade the package.
4. **Pure-Python packages**: These work regardless, as they don't require platform-specific builds

## Managing Deployment Size
Expand Down
2 changes: 1 addition & 1 deletion src/runpod_flash/cli/docs/flash-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ flash deploy [OPTIONS]
- `--exclude`: Comma-separated packages to exclude (e.g., 'torch,torchvision')
- `--output, -o`: Custom archive name (default: artifact.tar.gz)
- `--preview`: Build and launch local preview environment instead of deploying
- `--python-version`: Target Python version for worker images (`3.10`, `3.11`, or `3.12`). Overrides per-resource `python_version`.
- `--python-version`: Target Python version for worker images (`3.10`, `3.11`, `3.12`, or `3.13`). Overrides the local-interpreter default; must match any per-resource `python_version` declarations (conflicting declarations raise). By default, `flash deploy` targets the Python version you're running flash from.

## Examples

Expand Down
81 changes: 74 additions & 7 deletions tests/unit/cli/commands/build_utils/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -910,13 +910,13 @@ def test_manifest_includes_python_version():
)
]

builder = ManifestBuilder("test_app", functions)
# Explicit override avoids depending on the runner's local interpreter,
# which is the new resolution default after AE-2827.
builder = ManifestBuilder("test_app", functions, python_version="3.12")
manifest = builder.build()

assert "python_version" in manifest
from runpod_flash.core.resources.constants import DEFAULT_PYTHON_VERSION

assert manifest["python_version"] == DEFAULT_PYTHON_VERSION
assert manifest["python_version"] == "3.12"


def test_manifest_uses_explicit_python_version():
Expand Down Expand Up @@ -966,13 +966,21 @@ class TestReconcilePythonVersion:
def _builder(self, python_version: Optional[str] = None) -> ManifestBuilder:
return ManifestBuilder("test_app", [], python_version=python_version)

def test_no_resources_declare_version_uses_default(self):
from runpod_flash.core.resources.constants import DEFAULT_PYTHON_VERSION
def test_no_resources_no_override_uses_local_interpreter(self, monkeypatch):
"""With no override and no declaration, reconcile reads sys.version_info."""

class _StubVersionInfo:
pass

info = _StubVersionInfo()
info.major = 3
info.minor = 11
monkeypatch.setattr(sys, "version_info", info)

resolved = self._builder()._reconcile_python_version(
_make_resources_dict(gpu=None, cpu=None)
)
assert resolved == DEFAULT_PYTHON_VERSION
assert resolved == "3.11"

def test_single_declared_version_wins(self):
resolved = self._builder()._reconcile_python_version(
Expand Down Expand Up @@ -1025,3 +1033,62 @@ def test_reconcile_python_version_accepts_3_13_declaration(self):
_make_resources_dict(gpu="3.13")
)
assert resolved == "3.13"


class TestReconcileLocalInterpreter:
"""Tests for AE-2827 match-local default in _reconcile_python_version."""

def _builder(self, python_version: Optional[str] = None) -> ManifestBuilder:
return ManifestBuilder("test_app", [], python_version=python_version)

def _stub_version_info(self, monkeypatch, major: int, minor: int):
class _StubVersionInfo:
pass

info = _StubVersionInfo()
info.major = major
info.minor = minor
monkeypatch.setattr(sys, "version_info", info)

@pytest.mark.parametrize(
"major,minor,expected",
[
(3, 10, "3.10"),
(3, 11, "3.11"),
(3, 12, "3.12"),
(3, 13, "3.13"),
],
)
def test_resolves_to_local_when_no_override_or_declaration(
self, monkeypatch, major, minor, expected
):
self._stub_version_info(monkeypatch, major, minor)
resolved = self._builder()._reconcile_python_version(
_make_resources_dict(gpu=None, cpu=None)
)
assert resolved == expected

@pytest.mark.parametrize("major,minor", [(3, 9), (3, 14)])
def test_raises_when_local_is_unsupported(self, monkeypatch, major, minor):
self._stub_version_info(monkeypatch, major, minor)
with pytest.raises(ValueError, match="Local Python") as excinfo:
self._builder()._reconcile_python_version(
_make_resources_dict(gpu=None, cpu=None)
)
message = str(excinfo.value)
assert f"{major}.{minor}" in message
assert "--python-version" in message

def test_local_does_not_shadow_override(self, monkeypatch):
self._stub_version_info(monkeypatch, 3, 10)
resolved = self._builder("3.12")._reconcile_python_version(
_make_resources_dict(gpu=None, cpu=None)
)
assert resolved == "3.12"

def test_local_does_not_shadow_declaration(self, monkeypatch):
self._stub_version_info(monkeypatch, 3, 13)
resolved = self._builder()._reconcile_python_version(
_make_resources_dict(gpu="3.11", cpu=None)
)
assert resolved == "3.11"
22 changes: 22 additions & 0 deletions tests/unit/cli/commands/test_build_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from runpod_flash.cli.commands.build import (
_bundle_runpod_flash,
_find_runpod_flash,
_python_version_source,
_remove_runpod_flash_from_requirements,
)

Expand Down Expand Up @@ -191,3 +192,24 @@ def test_find_spec_returns_spec_without_origin(self, tmp_path):

# No flash repo in tmp_path, so returns None
assert result is None


class TestPythonVersionSource:
"""Tests for _python_version_source (build-time provenance string)."""

def test_override_takes_precedence(self):
assert (
_python_version_source("3.12", {"gpu": {"python_version": "3.11"}})
== "--python-version override"
)

def test_declared_resource_reported_by_first_name(self):
resources = {
"zeta": {"python_version": "3.11"},
"alpha": {"python_version": "3.11"},
}
assert _python_version_source(None, resources) == "declared on resource alpha"

def test_no_override_or_declaration_reports_local_match(self):
resources = {"gpu": {}, "cpu": {"python_version": None}}
assert _python_version_source(None, resources) == "matched local interpreter"
Loading