diff --git a/docs/Flash_Deploy_Guide.md b/docs/Flash_Deploy_Guide.md index c2dc3162..20d84d9c 100644 --- a/docs/Flash_Deploy_Guide.md +++ b/docs/Flash_Deploy_Guide.md @@ -6,22 +6,26 @@ This guide walks through deploying a Flash application from local development to ## Prerequisites -- Python 3.10, 3.11, or 3.12 +- Python 3.10, 3.11, 3.12, or 3.13 - `pip install runpod-flash` - A Runpod account with API key ([get one here](https://docs.runpod.io/get-started/api-keys)) ### 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 workers support Python 3.10, 3.11, 3.12, and 3.13. Native per-version images mean each interpreter has torch, numpy, and the worker runtime built directly for it — no side-by-side overhead, no cold-start tax for non-default versions. -- **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. +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 on the first line of output. -| Version | Status | GPU cold-start | Notes | -|---------|--------|----------------|-------| -| 3.10 | Supported (EOL 2026-10-31) | +~7 GB alt-Python install | Consider migrating to 3.11 before EOL | -| 3.11 | Supported | +~7 GB alt-Python install | | -| 3.12 | Supported (default) | No overhead | Torch pre-installed in base image | +You can override the choice in two ways: + +- **CLI flag:** `flash build --python-version 3.12` or `flash deploy --python-version 3.12`. Validated against the supported set. +- **Per-resource declaration:** set `python_version="3.12"` on any `LiveServerless`, `CpuLiveServerless`, `LiveLoadBalancer`, or `CpuLiveLoadBalancer` config. The build reconciles to the declared value (or raises if resources disagree). + +For projects shared across a team, declare `python_version` explicitly on each resource config. That makes the deploy result identical regardless of who runs `flash build` and what Python they happen to have installed locally — useful when team members and CI run different interpreters. + +Flash will refuse to build if your local Python is outside the supported set (e.g. 3.9 or 3.14). The error message tells you to pass `--python-version`, declare on a resource, or upgrade your interpreter. + +The `runpod/flash:latest`, `runpod/flash-lb:latest`, `runpod/flash-cpu:latest`, and `runpod/flash-lb-cpu:latest` aliases all point at the 3.12 image variant — Dockerfiles or compose files that pin `:latest` will keep getting 3.12. Use the `:py3.X-{tag}` form for explicit version pinning. ## Quick Start diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 2146ad31..a183428d 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -263,6 +263,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, @@ -339,6 +361,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 ) diff --git a/src/runpod_flash/cli/commands/build_utils/manifest.py b/src/runpod_flash/cli/commands/build_utils/manifest.py index 87cd0975..32f19e0a 100644 --- a/src/runpod_flash/cli/commands/build_utils/manifest.py +++ b/src/runpod_flash/cli/commands/build_utils/manifest.py @@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional from runpod_flash.core.resources.constants import ( - DEFAULT_PYTHON_VERSION, + SUPPORTED_PYTHON_VERSIONS, validate_python_version, ) @@ -321,18 +321,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"] @@ -366,14 +371,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. diff --git a/src/runpod_flash/cli/docs/flash-build.md b/src/runpod_flash/cli/docs/flash-build.md index 0798e0de..baaddc64 100644 --- a/src/runpod_flash/cli/docs/flash-build.md +++ b/src/runpod_flash/cli/docs/flash-build.md @@ -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 per-resource `python_version` declarations and the local-interpreter default. By default, `flash build` targets the Python version you're running flash from. To launch a local preview environment, use `flash deploy --preview` instead. @@ -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. @@ -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 diff --git a/src/runpod_flash/cli/docs/flash-deploy.md b/src/runpod_flash/cli/docs/flash-deploy.md index ef2c288a..24bcafde 100644 --- a/src/runpod_flash/cli/docs/flash-deploy.md +++ b/src/runpod_flash/cli/docs/flash-deploy.md @@ -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 per-resource `python_version` declarations and the local-interpreter default. By default, `flash deploy` targets the Python version you're running flash from. ## Examples diff --git a/src/runpod_flash/core/resources/constants.py b/src/runpod_flash/core/resources/constants.py index 188a2567..9b46390d 100644 --- a/src/runpod_flash/core/resources/constants.py +++ b/src/runpod_flash/core/resources/constants.py @@ -1,20 +1,20 @@ import os -# Worker runtime Python versions. One tarball serves every resource in an app, -# so all resources must share a single Python version. GPU images ship 3.12 -# with torch pre-installed; 3.10 and 3.11 are available via side-by-side -# install (~7 GB alt-Python overhead) in the same base image. -WORKER_PYTHON_VERSION: str = "3.12" -GPU_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12") -CPU_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12") - -# Base image ships 3.12 with torch pre-installed; non-3.12 targets reinstall -# torch side-by-side for the selected interpreter. -GPU_BASE_IMAGE_PYTHON_VERSION: str = "3.12" +# Single source of truth for Python versions Flash supports end-to-end. +# Phase 1 of AE-2827 publishes native per-version worker images for each. +SUPPORTED_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12", "3.13") + +# DEFAULT_PYTHON_VERSION drives the :latest tag aliases on Docker Hub +# (runpod/flash:latest -> runpod/flash:py3.12-latest). It is NOT a fallback +# the SDK reaches for — _reconcile_python_version uses sys.version_info +# when no override or per-resource declaration is set. DEFAULT_PYTHON_VERSION: str = "3.12" -# Python versions that can run the flash SDK locally (for flash build, etc.) -SUPPORTED_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12") +# Per-image-type Python sets are now uniform — native per-version images +# cover GPU and CPU equally. Aliased to SUPPORTED_PYTHON_VERSIONS for any +# downstream callers that still reference the old names. +GPU_PYTHON_VERSIONS: tuple[str, ...] = SUPPORTED_PYTHON_VERSIONS +CPU_PYTHON_VERSIONS: tuple[str, ...] = SUPPORTED_PYTHON_VERSIONS def local_python_version() -> str: diff --git a/src/runpod_flash/runtime/resource_provisioner.py b/src/runpod_flash/runtime/resource_provisioner.py index 981a234b..e10935f6 100644 --- a/src/runpod_flash/runtime/resource_provisioner.py +++ b/src/runpod_flash/runtime/resource_provisioner.py @@ -124,8 +124,9 @@ def create_resource_from_manifest( deployment_kwargs = {"name": prefixed_name, "env": env} # Use per-resource target_python_version (set by manifest builder based on - # resource type: GPU uses GPU_BASE_IMAGE_PYTHON_VERSION, CPU uses DEFAULT). - # Falls back to the caller-provided python_version for backward compatibility. + # the resource's declared python_version, or DEFAULT_PYTHON_VERSION when + # not specified). Falls back to the caller-provided python_version for + # backward compatibility. effective_python_version = ( resource_data.get("target_python_version") or python_version ) diff --git a/tests/unit/cli/commands/build_utils/test_manifest.py b/tests/unit/cli/commands/build_utils/test_manifest.py index 3a86c121..c1572caa 100644 --- a/tests/unit/cli/commands/build_utils/test_manifest.py +++ b/tests/unit/cli/commands/build_utils/test_manifest.py @@ -944,13 +944,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(): @@ -1000,13 +1000,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( @@ -1053,3 +1061,62 @@ def test_unsupported_override_raises(self): def test_unsupported_resource_version_raises(self): with pytest.raises(ValueError, match="not supported"): self._builder()._reconcile_python_version(_make_resources_dict(gpu="3.8")) + + +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" diff --git a/tests/unit/core/resources/test_constants.py b/tests/unit/core/resources/test_constants.py index 6384c6a4..a23b24ef 100644 --- a/tests/unit/core/resources/test_constants.py +++ b/tests/unit/core/resources/test_constants.py @@ -8,7 +8,6 @@ from runpod_flash.core.resources.constants import ( CPU_PYTHON_VERSIONS, DEFAULT_PYTHON_VERSION, - GPU_BASE_IMAGE_PYTHON_VERSION, GPU_PYTHON_VERSIONS, SUPPORTED_PYTHON_VERSIONS, get_image_name, @@ -19,19 +18,27 @@ class TestSupportedPythonVersions: def test_supported_versions(self): - assert SUPPORTED_PYTHON_VERSIONS == ("3.10", "3.11", "3.12") + assert SUPPORTED_PYTHON_VERSIONS == ("3.10", "3.11", "3.12", "3.13") def test_gpu_python_versions(self): - assert GPU_PYTHON_VERSIONS == ("3.10", "3.11", "3.12") + assert GPU_PYTHON_VERSIONS == ("3.10", "3.11", "3.12", "3.13") def test_cpu_python_versions(self): - assert CPU_PYTHON_VERSIONS == ("3.10", "3.11", "3.12") + assert CPU_PYTHON_VERSIONS == ("3.10", "3.11", "3.12", "3.13") def test_default_python_version_is_3_12(self): assert DEFAULT_PYTHON_VERSION == "3.12" - def test_gpu_base_image_python_version(self): - assert GPU_BASE_IMAGE_PYTHON_VERSION == "3.12" + def test_supported_python_versions_contains_310_through_313(self): + from runpod_flash.core.resources.constants import SUPPORTED_PYTHON_VERSIONS + + assert SUPPORTED_PYTHON_VERSIONS == ("3.10", "3.11", "3.12", "3.13") + + def test_default_python_version_unchanged_for_latest_alias(self): + """DEFAULT_PYTHON_VERSION drives the :latest tag alias, not SDK fallback.""" + from runpod_flash.core.resources.constants import DEFAULT_PYTHON_VERSION + + assert DEFAULT_PYTHON_VERSION == "3.12" class TestGetImageName: @@ -82,7 +89,7 @@ def test_invalid_image_type_raises(self): def test_invalid_python_version_raises(self): with pytest.raises(ValueError, match="not supported"): - get_image_name("gpu", "3.13") + get_image_name("gpu", "3.14") def test_custom_tag(self): assert get_image_name("gpu", "3.12", tag="v2.0") == "runpod/flash:py3.12-v2.0" @@ -128,7 +135,7 @@ def test_valid_versions(self): def test_invalid_version_raises(self): with pytest.raises(ValueError, match="not supported"): - validate_python_version("3.13") + validate_python_version("3.14") def test_old_version_raises(self): with pytest.raises(ValueError, match="not supported"): diff --git a/tests/unit/resources/test_live_serverless.py b/tests/unit/resources/test_live_serverless.py index 4dfeb929..87b00e5d 100644 --- a/tests/unit/resources/test_live_serverless.py +++ b/tests/unit/resources/test_live_serverless.py @@ -4,7 +4,8 @@ import pytest from runpod_flash.core.resources.constants import ( - GPU_BASE_IMAGE_PYTHON_VERSION, + DEFAULT_PYTHON_VERSION, + SUPPORTED_PYTHON_VERSIONS, ) from runpod_flash.core.resources.cpu import CpuInstanceType from runpod_flash.core.resources.live_serverless import ( @@ -223,14 +224,14 @@ class TestLiveServerlessPythonVersion: def test_gpu_default_image_uses_gpu_base_python(self): ls = LiveServerless(name="test") - assert f"py{GPU_BASE_IMAGE_PYTHON_VERSION}" in ls.imageName + assert f"py{DEFAULT_PYTHON_VERSION}" in ls.imageName - @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + @pytest.mark.parametrize("version", list(SUPPORTED_PYTHON_VERSIONS)) def test_gpu_explicit_supported_versions(self, version): ls = LiveServerless(name="test", python_version=version) assert f"py{version}" in ls.imageName - @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + @pytest.mark.parametrize("version", list(SUPPORTED_PYTHON_VERSIONS)) def test_cpu_explicit_supported_versions(self, version): ls = CpuLiveServerless(name="test", python_version=version) assert f"py{version}" in ls.imageName @@ -250,16 +251,16 @@ class TestLiveLoadBalancerPythonVersion: def test_lb_default_image_uses_gpu_base_python(self): lb = LiveLoadBalancer(name="test") - assert f"py{GPU_BASE_IMAGE_PYTHON_VERSION}" in lb.imageName + assert f"py{DEFAULT_PYTHON_VERSION}" in lb.imageName assert "runpod/flash-lb:" in lb.imageName - @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + @pytest.mark.parametrize("version", list(SUPPORTED_PYTHON_VERSIONS)) def test_lb_explicit_supported_versions(self, version): lb = LiveLoadBalancer(name="test", python_version=version) assert f"py{version}" in lb.imageName assert "runpod/flash-lb:" in lb.imageName - @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + @pytest.mark.parametrize("version", list(SUPPORTED_PYTHON_VERSIONS)) def test_cpu_lb_explicit_supported_versions(self, version): lb = CpuLiveLoadBalancer(name="test", python_version=version) assert f"py{version}" in lb.imageName diff --git a/tests/unit/resources/test_serverless.py b/tests/unit/resources/test_serverless.py index 366d29f3..5341261e 100644 --- a/tests/unit/resources/test_serverless.py +++ b/tests/unit/resources/test_serverless.py @@ -2429,7 +2429,7 @@ def test_python_version_accepts_valid_values(self): def test_python_version_rejects_invalid(self): with pytest.raises(ValueError, match="not supported"): ServerlessEndpoint( - name="test", imageName="test:latest", python_version="3.13" + name="test", imageName="test:latest", python_version="3.14" ) def test_python_version_rejects_3_9(self):