Skip to content

Commit cd30c77

Browse files
authored
feat(build): per-app python_version with cross-resource validation (#322)
* feat(build): per-app python_version with cross-resource validation (AE-2827) Expand GPU and CPU worker images to support Python 3.10, 3.11, and 3.12. Flash apps ship as one tarball so every resource must share a Python version; the build step now reconciles per-resource python_version declarations into a single app-level value or accepts an explicit --python-version override. - constants.py: expand GPU_PYTHON_VERSIONS / CPU_PYTHON_VERSIONS tuples - manifest.py: add _reconcile_python_version; stamp target_python_version on every resource; raise on conflicting declarations - build.py / deploy.py: add --python-version CLI flag, thread through run_build and ManifestBuilder - docs: document per-app python_version, cold-start tradeoff for non-3.12 GPU images, and the 3.10 EOL window - tests/unit/test_dotenv_loading.py: add preserve_runpod_flash_modules fixture so module-deletion tests don't leak stale module references into sibling test files (unblocks deterministic test ordering) * refactor(manifest): drop dead python_version assignment in __init__ The effective app-level Python version is assigned by build() via _reconcile_python_version(). Pre-build access always saw a value that build() would overwrite — misleading and never read. Initialize to None so readers fail fast if they touch the attribute before build(). * docs(flash-build): drop stale --keep-build and --preview refs Neither flag is exposed by flash/cli/commands/build.py::build_command; --preview now lives on flash deploy. Aligns the docs with actual CLI behavior — build directory is always retained on success, so the --keep-build toggle is gone. Caught by Copilot review on PR #322.
1 parent ebd416f commit cd30c77

12 files changed

Lines changed: 306 additions & 155 deletions

File tree

docs/Deployment_Architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ flash build
3131
3232
├── 4. Dependency Installation
3333
│ ├── Install Python packages for linux/x86_64
34-
│ ├── Target Python 3.12 for wheel ABI selection
34+
│ ├── Target the app's python_version for wheel ABI selection (3.10 / 3.11 / 3.12)
3535
│ └── Binary wheels only (no compilation)
3636
3737
└── 5. Packaging

docs/Flash_Deploy_Guide.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,23 @@ This guide walks through deploying a Flash application from local development to
66

77
## Prerequisites
88

9-
- Python 3.10+
9+
- Python 3.10, 3.11, or 3.12
1010
- `pip install runpod-flash`
1111
- A Runpod account with API key ([get one here](https://docs.runpod.io/get-started/api-keys))
1212

13+
### Python version selection
14+
15+
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:
16+
17+
- **Per-resource declaration**: set `python_version="3.11"` on any resource config — all resources in the same app must agree or leave it unset.
18+
- **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.
19+
20+
| Version | Status | GPU cold-start | Notes |
21+
|---------|--------|----------------|-------|
22+
| 3.10 | Supported (EOL 2026-10-31) | +~7 GB alt-Python install | Consider migrating to 3.11 before EOL |
23+
| 3.11 | Supported | +~7 GB alt-Python install | |
24+
| 3.12 | Supported (default) | No overhead | Torch pre-installed in base image |
25+
1326
## Quick Start
1427

1528
```bash

src/runpod_flash/cli/commands/build.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@
2222
import tomli as tomllib # Python 3.10
2323

2424
from runpod_flash.cli.utils.formatting import print_error, print_warning
25-
from runpod_flash.core.resources.constants import (
26-
DEFAULT_PYTHON_VERSION,
27-
MAX_TARBALL_SIZE_MB,
28-
)
25+
from runpod_flash.core.resources.constants import MAX_TARBALL_SIZE_MB
2926

3027
from ..utils.ignore import get_file_tree, load_ignore_patterns
3128
from .build_utils.handler_generator import HandlerGenerator
@@ -273,6 +270,7 @@ def run_build(
273270
output_name: str | None = None,
274271
exclude: str | None = None,
275272
verbose: bool = False,
273+
python_version: str | None = None,
276274
) -> Path:
277275
"""Run the build process and return the artifact path.
278276
@@ -287,6 +285,10 @@ def run_build(
287285
output_name: Custom archive name (default: artifact.tar.gz)
288286
exclude: Comma-separated packages to exclude
289287
verbose: Show archive and build directory paths in summary
288+
python_version: Optional app-level Python version override. When None,
289+
inferred from resource configs (defaulting to DEFAULT_PYTHON_VERSION
290+
if none declare one). One tarball serves every resource in an app,
291+
so all resources must agree on one version.
290292
291293
Returns:
292294
Path to the created artifact archive
@@ -311,10 +313,9 @@ def run_build(
311313
spec = load_ignore_patterns(project_dir)
312314
files = get_file_tree(project_dir, spec)
313315

314-
# all packaging and image selection targets 3.12 regardless of local python.
315-
# pip downloads wheels for 3.12 via --python-version, and all worker images
316-
# run 3.12, so the local interpreter version does not affect the build output.
317-
python_version = DEFAULT_PYTHON_VERSION
316+
# Resolved later by ManifestBuilder from resource configs (or the override
317+
# above). Pip wheel selection re-reads this via _resolve_pip_python_version.
318+
manifest_python_version_override = python_version
318319

319320
try:
320321
copy_project_files(files, project_dir, build_dir)
@@ -335,7 +336,7 @@ def run_build(
335336
remote_functions,
336337
scanner,
337338
build_dir=build_dir,
338-
python_version=python_version,
339+
python_version=manifest_python_version_override,
339340
)
340341
manifest = manifest_builder.build()
341342
manifest["source_fingerprint"] = compute_source_fingerprint(
@@ -513,6 +514,15 @@ def build_command(
513514
"--exclude",
514515
help="Comma-separated additional packages to exclude (torch packages are auto-excluded)",
515516
),
517+
python_version: str | None = typer.Option(
518+
None,
519+
"--python-version",
520+
help=(
521+
"Target Python version for worker images (3.10, 3.11, or 3.12). "
522+
"Overrides per-resource python_version declarations. "
523+
"Defaults to the version declared on resource configs, or 3.12 if none set."
524+
),
525+
),
516526
):
517527
"""
518528
Build Flash application for debugging (build only, no deploy).
@@ -525,6 +535,7 @@ def build_command(
525535
flash build --no-deps # Skip transitive dependencies
526536
flash build -o my-app.tar.gz # Custom archive name
527537
flash build --exclude transformers # Exclude additional large packages
538+
flash build --python-version 3.11 # Target Python 3.11 workers
528539
"""
529540
try:
530541
project_dir, app_name = discover_flash_project()
@@ -536,6 +547,7 @@ def build_command(
536547
output_name=output_name,
537548
exclude=exclude,
538549
verbose=True,
550+
python_version=python_version,
539551
)
540552

541553
except KeyboardInterrupt:

src/runpod_flash/cli/commands/build_utils/manifest.py

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from runpod_flash.core.resources.constants import (
1313
DEFAULT_PYTHON_VERSION,
14-
GPU_BASE_IMAGE_PYTHON_VERSION,
14+
validate_python_version,
1515
)
1616

1717
from .scanner import (
@@ -93,7 +93,10 @@ def __init__(
9393
self.remote_functions = remote_functions
9494
self.scanner = scanner # Optional: RuntimeScanner with resource config info
9595
self.build_dir = build_dir
96-
self.python_version = python_version or DEFAULT_PYTHON_VERSION
96+
# User-supplied app-level override; None means "infer from resources".
97+
self._python_version_override = python_version
98+
# Effective app-level version; set by build() via _reconcile_python_version.
99+
self.python_version: Optional[str] = None
97100

98101
def _import_module(self, file_path: Path):
99102
"""Import a module from file path, returning (module, cleanup_fn).
@@ -216,6 +219,12 @@ def _extract_config_properties(config: Dict[str, Any], resource_config) -> None:
216219
if hasattr(resource_config, "imageName") and resource_config.imageName:
217220
config["imageName"] = resource_config.imageName
218221

222+
if (
223+
hasattr(resource_config, "python_version")
224+
and resource_config.python_version
225+
):
226+
config["python_version"] = resource_config.python_version
227+
219228
if hasattr(resource_config, "templateId") and resource_config.templateId:
220229
config["templateId"] = resource_config.templateId
221230

@@ -309,6 +318,63 @@ def _extract_config_properties(config: Dict[str, Any], resource_config) -> None:
309318

310319
return config
311320

321+
def _reconcile_python_version(
322+
self, resources_dict: Dict[str, Dict[str, Any]]
323+
) -> str:
324+
"""Pick one Python version for the app from per-resource declarations.
325+
326+
Flash apps ship as a single tarball, so every resource must target the
327+
same Python ABI. Resolution order:
328+
1. Explicit override passed to ManifestBuilder (validated)
329+
2. Exactly one distinct ``python_version`` declared across resources
330+
3. ``DEFAULT_PYTHON_VERSION`` when no resource declares one
331+
332+
Raises:
333+
ValueError: When resources declare conflicting ``python_version``
334+
values, or when the override conflicts with a resource's
335+
explicit declaration.
336+
"""
337+
per_resource: Dict[str, str] = {
338+
name: r["python_version"]
339+
for name, r in resources_dict.items()
340+
if r.get("python_version")
341+
}
342+
distinct = set(per_resource.values())
343+
344+
if self._python_version_override:
345+
chosen = validate_python_version(self._python_version_override)
346+
conflicting = {
347+
name: version
348+
for name, version in per_resource.items()
349+
if version != chosen
350+
}
351+
if conflicting:
352+
details = ", ".join(
353+
f"{name}={version}" for name, version in sorted(conflicting.items())
354+
)
355+
raise ValueError(
356+
f"python_version override '{chosen}' conflicts with resource "
357+
f"declarations: {details}. Either remove the override or "
358+
f"align all resources to '{chosen}'."
359+
)
360+
return chosen
361+
362+
if len(distinct) > 1:
363+
details = ", ".join(
364+
f"{name}={version}" for name, version in sorted(per_resource.items())
365+
)
366+
raise ValueError(
367+
"Flash apps require one python_version across all resources "
368+
f"(found {sorted(distinct)}): {details}. Set python_version to the "
369+
"same value on every resource, or omit it to use the default "
370+
f"({DEFAULT_PYTHON_VERSION})."
371+
)
372+
373+
if distinct:
374+
return validate_python_version(next(iter(distinct)))
375+
376+
return DEFAULT_PYTHON_VERSION
377+
312378
def build(self) -> Dict[str, Any]:
313379
"""Build the manifest dictionary.
314380
@@ -436,20 +502,6 @@ def build(self) -> Dict[str, Any]:
436502
# Determine if this resource makes remote calls
437503
makes_remote_calls = any(func.calls_remote_functions for func in functions)
438504

439-
# One tarball serves all resources, so target_python_version must agree.
440-
# GPU resources are pinned to the base image's Python; CPU resources
441-
# use DEFAULT_PYTHON_VERSION (aligned to GPU to avoid ABI mismatch).
442-
_GPU_RESOURCE_TYPES = {
443-
"LiveServerless",
444-
"LiveLoadBalancer",
445-
"LoadBalancerSlsResource",
446-
"ServerlessEndpoint",
447-
}
448-
if resource_type in _GPU_RESOURCE_TYPES:
449-
target_python_version = GPU_BASE_IMAGE_PYTHON_VERSION
450-
else:
451-
target_python_version = DEFAULT_PYTHON_VERSION
452-
453505
resources_dict[resource_name] = {
454506
"resource_type": resource_type,
455507
"file_path": file_path_str,
@@ -460,8 +512,7 @@ def build(self) -> Dict[str, Any]:
460512
"is_live_resource": is_live_resource,
461513
"config_variable": config_variable,
462514
"makes_remote_calls": makes_remote_calls,
463-
"target_python_version": target_python_version,
464-
**deployment_config, # Include imageName, templateId, gpuIds, workers config
515+
**deployment_config, # Include imageName, templateId, gpuIds, workers config, python_version
465516
}
466517

467518
# max_concurrency is QB-only; warn and remove for LB endpoints
@@ -495,6 +546,15 @@ def build(self) -> Dict[str, Any]:
495546
)
496547
function_registry[f.function_name] = resource_name
497548

549+
# Reconcile app-level python_version across resources. One tarball serves
550+
# every resource in an app, so all resources must agree on one version.
551+
self.python_version = self._reconcile_python_version(resources_dict)
552+
553+
# Stamp every resource's target_python_version with the reconciled
554+
# app-level value so the runtime and pip-wheel step see a consistent ABI.
555+
for resource in resources_dict.values():
556+
resource["target_python_version"] = self.python_version
557+
498558
manifest = {
499559
"version": "1.0",
500560
"python_version": self.python_version,

src/runpod_flash/cli/commands/deploy.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ def deploy_command(
4343
"--preview",
4444
help="Build and launch local preview environment instead of deploying",
4545
),
46+
python_version: str | None = typer.Option(
47+
None,
48+
"--python-version",
49+
help=(
50+
"Target Python version for worker images (3.10, 3.11, or 3.12). "
51+
"Overrides per-resource python_version declarations."
52+
),
53+
),
4654
):
4755
"""
4856
Build and deploy Flash application.
@@ -56,6 +64,7 @@ def deploy_command(
5664
flash deploy --app my-app --env prod # deploy a different app
5765
flash deploy --preview # build + launch local preview
5866
flash deploy --exclude transformers # exclude additional packages from build
67+
flash deploy --python-version 3.11 # target Python 3.11 workers
5968
"""
6069
try:
6170
project_dir, discovered_app_name = discover_flash_project()
@@ -68,6 +77,7 @@ def deploy_command(
6877
no_deps=no_deps,
6978
output_name=output_name,
7079
exclude=exclude,
80+
python_version=python_version,
7181
)
7282

7383
if preview:

0 commit comments

Comments
 (0)