From 5d73bdb5bb39ae31a06eb7aacbcb72e3cbc8a8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dean=20Qui=C3=B1anola?= Date: Mon, 6 Jul 2026 00:57:13 -0700 Subject: [PATCH] feat(build): match local Python version by default flash build/deploy now resolve the app Python version from the local interpreter (sys.version_info) when no --python-version override and no per-resource python_version is declared, instead of a hardcoded 3.12 fallback. An unsupported local interpreter raises an actionable error. The build prints the resolved version and its source. Extracted from #330 (which is stale/corrupted and built on the reverted #346); the 'add 3.13' half of #330 already landed on main, so only the match-local behavior is carried here. - manifest.py: _reconcile_python_version falls back to local interpreter - build.py: _python_version_source() + build-time 'targeting Python X' line - docs: flash-build/flash-deploy/deploy-guide describe parity-by-default - tests: local-match, unsupported-local, override/declaration precedence --- docs/Flash_Deploy_Guide.md | 6 +- src/runpod_flash/cli/commands/build.py | 26 ++++++ .../cli/commands/build_utils/manifest.py | 33 +++++--- src/runpod_flash/cli/docs/flash-build.md | 8 +- src/runpod_flash/cli/docs/flash-deploy.md | 2 +- .../cli/commands/build_utils/test_manifest.py | 81 +++++++++++++++++-- tests/unit/cli/commands/test_build_helpers.py | 22 +++++ 7 files changed, 154 insertions(+), 24 deletions(-) diff --git a/docs/Flash_Deploy_Guide.md b/docs/Flash_Deploy_Guide.md index 90f1ab16..67b1634e 100644 --- a/docs/Flash_Deploy_Guide.md +++ b/docs/Flash_Deploy_Guide.md @@ -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 | |---------|--------|----------------|-------| diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 65414a97..c8ebbc34 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -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, @@ -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 ) diff --git a/src/runpod_flash/cli/commands/build_utils/manifest.py b/src/runpod_flash/cli/commands/build_utils/manifest.py index 520792ec..b27b57ab 100644 --- a/src/runpod_flash/cli/commands/build_utils/manifest.py +++ b/src/runpod_flash/cli/commands/build_utils/manifest.py @@ -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, ) @@ -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"] @@ -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. diff --git a/src/runpod_flash/cli/docs/flash-build.md b/src/runpod_flash/cli/docs/flash-build.md index 0798e0de..849e05a7 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 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. @@ -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..42b15ad0 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 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 diff --git a/tests/unit/cli/commands/build_utils/test_manifest.py b/tests/unit/cli/commands/build_utils/test_manifest.py index 598adc6d..a2f474b2 100644 --- a/tests/unit/cli/commands/build_utils/test_manifest.py +++ b/tests/unit/cli/commands/build_utils/test_manifest.py @@ -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(): @@ -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( @@ -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" diff --git a/tests/unit/cli/commands/test_build_helpers.py b/tests/unit/cli/commands/test_build_helpers.py index 8b8ec4eb..0bc63176 100644 --- a/tests/unit/cli/commands/test_build_helpers.py +++ b/tests/unit/cli/commands/test_build_helpers.py @@ -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, ) @@ -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"