diff --git a/docs/Deployment_Architecture.md b/docs/Deployment_Architecture.md index 65147395..bdf666d6 100644 --- a/docs/Deployment_Architecture.md +++ b/docs/Deployment_Architecture.md @@ -31,7 +31,7 @@ flash build │ ├── 4. Dependency Installation │ ├── Install Python packages for linux/x86_64 - │ ├── Target Python 3.12 for wheel ABI selection + │ ├── Target the app's python_version for wheel ABI selection (3.10 / 3.11 / 3.12) │ └── Binary wheels only (no compilation) │ └── 5. Packaging diff --git a/docs/Flash_Deploy_Guide.md b/docs/Flash_Deploy_Guide.md index 0bb7495a..985f6848 100644 --- a/docs/Flash_Deploy_Guide.md +++ b/docs/Flash_Deploy_Guide.md @@ -6,10 +6,23 @@ This guide walks through deploying a Flash application from local development to ## Prerequisites -- Python 3.10+ +- Python 3.10, 3.11, or 3.12 - `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: + +- **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. + +| 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 | + ## Quick Start ```bash diff --git a/src/runpod_flash/cli/commands/build.py b/src/runpod_flash/cli/commands/build.py index 195fbf99..e4dc372d 100644 --- a/src/runpod_flash/cli/commands/build.py +++ b/src/runpod_flash/cli/commands/build.py @@ -22,10 +22,7 @@ import tomli as tomllib # Python 3.10 from runpod_flash.cli.utils.formatting import print_error, print_warning -from runpod_flash.core.resources.constants import ( - DEFAULT_PYTHON_VERSION, - MAX_TARBALL_SIZE_MB, -) +from runpod_flash.core.resources.constants import MAX_TARBALL_SIZE_MB from ..utils.ignore import get_file_tree, load_ignore_patterns from .build_utils.handler_generator import HandlerGenerator @@ -273,6 +270,7 @@ def run_build( output_name: str | None = None, exclude: str | None = None, verbose: bool = False, + python_version: str | None = None, ) -> Path: """Run the build process and return the artifact path. @@ -287,6 +285,10 @@ def run_build( output_name: Custom archive name (default: artifact.tar.gz) exclude: Comma-separated packages to exclude verbose: Show archive and build directory paths in summary + python_version: Optional app-level Python version override. When None, + inferred from resource configs (defaulting to DEFAULT_PYTHON_VERSION + if none declare one). One tarball serves every resource in an app, + so all resources must agree on one version. Returns: Path to the created artifact archive @@ -311,10 +313,9 @@ def run_build( spec = load_ignore_patterns(project_dir) files = get_file_tree(project_dir, spec) - # all packaging and image selection targets 3.12 regardless of local python. - # pip downloads wheels for 3.12 via --python-version, and all worker images - # run 3.12, so the local interpreter version does not affect the build output. - python_version = DEFAULT_PYTHON_VERSION + # Resolved later by ManifestBuilder from resource configs (or the override + # above). Pip wheel selection re-reads this via _resolve_pip_python_version. + manifest_python_version_override = python_version try: copy_project_files(files, project_dir, build_dir) @@ -335,7 +336,7 @@ def run_build( remote_functions, scanner, build_dir=build_dir, - python_version=python_version, + python_version=manifest_python_version_override, ) manifest = manifest_builder.build() manifest["source_fingerprint"] = compute_source_fingerprint( @@ -506,6 +507,15 @@ def build_command( "--exclude", help="Comma-separated additional packages to exclude (torch packages are auto-excluded)", ), + python_version: str | None = typer.Option( + None, + "--python-version", + help=( + "Target Python version for worker images (3.10, 3.11, or 3.12). " + "Overrides per-resource python_version declarations. " + "Defaults to the version declared on resource configs, or 3.12 if none set." + ), + ), ): """ Build Flash application for debugging (build only, no deploy). @@ -518,6 +528,7 @@ def build_command( flash build --no-deps # Skip transitive dependencies flash build -o my-app.tar.gz # Custom archive name flash build --exclude transformers # Exclude additional large packages + flash build --python-version 3.11 # Target Python 3.11 workers """ try: project_dir, app_name = discover_flash_project() @@ -529,6 +540,7 @@ def build_command( output_name=output_name, exclude=exclude, verbose=True, + python_version=python_version, ) except KeyboardInterrupt: diff --git a/src/runpod_flash/cli/commands/build_utils/manifest.py b/src/runpod_flash/cli/commands/build_utils/manifest.py index 06b045ae..cb2bb7b6 100644 --- a/src/runpod_flash/cli/commands/build_utils/manifest.py +++ b/src/runpod_flash/cli/commands/build_utils/manifest.py @@ -11,7 +11,7 @@ from runpod_flash.core.resources.constants import ( DEFAULT_PYTHON_VERSION, - GPU_BASE_IMAGE_PYTHON_VERSION, + validate_python_version, ) from .scanner import ( @@ -93,7 +93,10 @@ def __init__( self.remote_functions = remote_functions self.scanner = scanner # Optional: RuntimeScanner with resource config info self.build_dir = build_dir - self.python_version = python_version or DEFAULT_PYTHON_VERSION + # User-supplied app-level override; None means "infer from resources". + self._python_version_override = python_version + # Effective app-level version; set by build() via _reconcile_python_version. + self.python_version: Optional[str] = None def _import_module(self, file_path: Path): """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: if hasattr(resource_config, "imageName") and resource_config.imageName: config["imageName"] = resource_config.imageName + if ( + hasattr(resource_config, "python_version") + and resource_config.python_version + ): + config["python_version"] = resource_config.python_version + if hasattr(resource_config, "templateId") and resource_config.templateId: config["templateId"] = resource_config.templateId @@ -309,6 +318,63 @@ def _extract_config_properties(config: Dict[str, Any], resource_config) -> None: return config + def _reconcile_python_version( + self, resources_dict: Dict[str, Dict[str, Any]] + ) -> str: + """Pick one Python version for the app from per-resource declarations. + + 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 + + Raises: + ValueError: When resources declare conflicting ``python_version`` + values, or when the override conflicts with a resource's + explicit declaration. + """ + per_resource: Dict[str, str] = { + name: r["python_version"] + for name, r in resources_dict.items() + if r.get("python_version") + } + distinct = set(per_resource.values()) + + if self._python_version_override: + chosen = validate_python_version(self._python_version_override) + conflicting = { + name: version + for name, version in per_resource.items() + if version != chosen + } + if conflicting: + details = ", ".join( + f"{name}={version}" for name, version in sorted(conflicting.items()) + ) + raise ValueError( + f"python_version override '{chosen}' conflicts with resource " + f"declarations: {details}. Either remove the override or " + f"align all resources to '{chosen}'." + ) + return chosen + + if len(distinct) > 1: + details = ", ".join( + f"{name}={version}" for name, version in sorted(per_resource.items()) + ) + 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})." + ) + + if distinct: + return validate_python_version(next(iter(distinct))) + + return DEFAULT_PYTHON_VERSION + def build(self) -> Dict[str, Any]: """Build the manifest dictionary. @@ -426,20 +492,6 @@ def build(self) -> Dict[str, Any]: # Determine if this resource makes remote calls makes_remote_calls = any(func.calls_remote_functions for func in functions) - # One tarball serves all resources, so target_python_version must agree. - # GPU resources are pinned to the base image's Python; CPU resources - # use DEFAULT_PYTHON_VERSION (aligned to GPU to avoid ABI mismatch). - _GPU_RESOURCE_TYPES = { - "LiveServerless", - "LiveLoadBalancer", - "LoadBalancerSlsResource", - "ServerlessEndpoint", - } - if resource_type in _GPU_RESOURCE_TYPES: - target_python_version = GPU_BASE_IMAGE_PYTHON_VERSION - else: - target_python_version = DEFAULT_PYTHON_VERSION - resources_dict[resource_name] = { "resource_type": resource_type, "file_path": file_path_str, @@ -450,8 +502,7 @@ def build(self) -> Dict[str, Any]: "is_live_resource": is_live_resource, "config_variable": config_variable, "makes_remote_calls": makes_remote_calls, - "target_python_version": target_python_version, - **deployment_config, # Include imageName, templateId, gpuIds, workers config + **deployment_config, # Include imageName, templateId, gpuIds, workers config, python_version } # max_concurrency is QB-only; warn and remove for LB endpoints @@ -485,6 +536,15 @@ def build(self) -> Dict[str, Any]: ) function_registry[f.function_name] = resource_name + # Reconcile app-level python_version across resources. One tarball serves + # every resource in an app, so all resources must agree on one version. + self.python_version = self._reconcile_python_version(resources_dict) + + # Stamp every resource's target_python_version with the reconciled + # app-level value so the runtime and pip-wheel step see a consistent ABI. + for resource in resources_dict.values(): + resource["target_python_version"] = self.python_version + manifest = { "version": "1.0", "python_version": self.python_version, diff --git a/src/runpod_flash/cli/commands/deploy.py b/src/runpod_flash/cli/commands/deploy.py index eefa2cc5..c119a90a 100644 --- a/src/runpod_flash/cli/commands/deploy.py +++ b/src/runpod_flash/cli/commands/deploy.py @@ -43,6 +43,14 @@ def deploy_command( "--preview", help="Build and launch local preview environment instead of deploying", ), + python_version: str | None = typer.Option( + None, + "--python-version", + help=( + "Target Python version for worker images (3.10, 3.11, or 3.12). " + "Overrides per-resource python_version declarations." + ), + ), ): """ Build and deploy Flash application. @@ -56,6 +64,7 @@ def deploy_command( flash deploy --app my-app --env prod # deploy a different app flash deploy --preview # build + launch local preview flash deploy --exclude transformers # exclude additional packages from build + flash deploy --python-version 3.11 # target Python 3.11 workers """ try: project_dir, discovered_app_name = discover_flash_project() @@ -68,6 +77,7 @@ def deploy_command( no_deps=no_deps, output_name=output_name, exclude=exclude, + python_version=python_version, ) if preview: diff --git a/src/runpod_flash/cli/docs/flash-build.md b/src/runpod_flash/cli/docs/flash-build.md index b2c9f421..4edb8444 100644 --- a/src/runpod_flash/cli/docs/flash-build.md +++ b/src/runpod_flash/cli/docs/flash-build.md @@ -30,6 +30,7 @@ flash build [OPTIONS] - `--output, -o`: Custom archive name (default: artifact.tar.gz) - `--exclude`: Comma-separated packages to exclude (e.g., 'torch,torchvision') - `--preview`: Launch local test environment after successful build (auto-enables `--keep-build`) +- `--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. ## Examples diff --git a/src/runpod_flash/cli/docs/flash-deploy.md b/src/runpod_flash/cli/docs/flash-deploy.md index 2666a748..6c4aff05 100644 --- a/src/runpod_flash/cli/docs/flash-deploy.md +++ b/src/runpod_flash/cli/docs/flash-deploy.md @@ -88,6 +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`. ## Examples diff --git a/src/runpod_flash/core/resources/constants.py b/src/runpod_flash/core/resources/constants.py index 6a56438e..a47932c9 100644 --- a/src/runpod_flash/core/resources/constants.py +++ b/src/runpod_flash/core/resources/constants.py @@ -19,22 +19,25 @@ def _endpoint_domain_from_base_url(base_url: str) -> str: ENDPOINT_DOMAIN = _endpoint_domain_from_base_url(runpod.endpoint_url_base) -# worker runtime Python versions. all flash workers run Python 3.12. -# one tarball serves every resource type (GPU and CPU), so packages, -# images, and the runtime must all target 3.12. +# 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.12",) -CPU_PYTHON_VERSIONS: tuple[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" DEFAULT_PYTHON_VERSION: str = "3.12" -# python versions that can run the flash SDK locally (for flash build, etc.) +# Python versions that can run the flash SDK locally (for flash build, etc.) SUPPORTED_PYTHON_VERSIONS: tuple[str, ...] = ("3.10", "3.11", "3.12") def local_python_version() -> str: - """Return the Python version used by flash workers (always 3.12).""" + """Return the default worker Python version.""" return DEFAULT_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 ec01bdce..3a86c121 100644 --- a/tests/unit/cli/commands/build_utils/test_manifest.py +++ b/tests/unit/cli/commands/build_utils/test_manifest.py @@ -4,8 +4,11 @@ import sys import tempfile from pathlib import Path +from typing import Optional from unittest.mock import MagicMock +import pytest + from runpod_flash.cli.commands.build_utils.manifest import ManifestBuilder from runpod_flash.cli.commands.build_utils.scanner import RemoteFunctionMetadata @@ -968,3 +971,85 @@ def test_manifest_uses_explicit_python_version(): manifest = builder.build() assert manifest["python_version"] == "3.12" + + +def _make_resources_dict(**python_versions: Optional[str]) -> dict: + """Build a resources_dict fixture keyed by resource name with python_version.""" + resources: dict = {} + for name, version in python_versions.items(): + entry = { + "resource_type": "LiveServerless", + "file_path": f"workers/{name}.py", + "local_path_prefix": f"/workers/{name}", + "module_path": f"workers.{name}", + "functions": [], + "is_load_balanced": False, + "is_live_resource": True, + "config_variable": None, + "makes_remote_calls": False, + } + if version is not None: + entry["python_version"] = version + resources[name] = entry + return resources + + +class TestReconcilePythonVersion: + """Tests for ManifestBuilder._reconcile_python_version (AE-2827).""" + + 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 + + resolved = self._builder()._reconcile_python_version( + _make_resources_dict(gpu=None, cpu=None) + ) + assert resolved == DEFAULT_PYTHON_VERSION + + def test_single_declared_version_wins(self): + resolved = self._builder()._reconcile_python_version( + _make_resources_dict(gpu="3.11") + ) + assert resolved == "3.11" + + def test_multiple_resources_same_version(self): + resolved = self._builder()._reconcile_python_version( + _make_resources_dict(gpu="3.11", cpu="3.11", lb="3.11") + ) + assert resolved == "3.11" + + def test_conflicting_resource_versions_raises(self): + with pytest.raises(ValueError, match="one python_version across all resources"): + self._builder()._reconcile_python_version( + _make_resources_dict(gpu="3.11", cpu="3.12") + ) + + def test_override_wins_over_unset_resources(self): + resolved = self._builder("3.10")._reconcile_python_version( + _make_resources_dict(gpu=None, cpu=None) + ) + assert resolved == "3.10" + + def test_override_matching_resources_ok(self): + resolved = self._builder("3.11")._reconcile_python_version( + _make_resources_dict(gpu="3.11", cpu=None) + ) + assert resolved == "3.11" + + def test_override_conflicting_with_resource_raises(self): + with pytest.raises(ValueError, match="conflicts with resource declarations"): + self._builder("3.12")._reconcile_python_version( + _make_resources_dict(gpu="3.11") + ) + + def test_unsupported_override_raises(self): + with pytest.raises(ValueError, match="not supported"): + self._builder("3.8")._reconcile_python_version( + _make_resources_dict(gpu=None) + ) + + 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")) diff --git a/tests/unit/core/resources/test_constants.py b/tests/unit/core/resources/test_constants.py index 3e70540f..6384c6a4 100644 --- a/tests/unit/core/resources/test_constants.py +++ b/tests/unit/core/resources/test_constants.py @@ -22,10 +22,10 @@ def test_supported_versions(self): assert SUPPORTED_PYTHON_VERSIONS == ("3.10", "3.11", "3.12") def test_gpu_python_versions(self): - assert GPU_PYTHON_VERSIONS == ("3.12",) + assert GPU_PYTHON_VERSIONS == ("3.10", "3.11", "3.12") def test_cpu_python_versions(self): - assert CPU_PYTHON_VERSIONS == ("3.12",) + assert CPU_PYTHON_VERSIONS == ("3.10", "3.11", "3.12") def test_default_python_version_is_3_12(self): assert DEFAULT_PYTHON_VERSION == "3.12" @@ -40,51 +40,33 @@ def test_gpu_3_12(self): get_image_name("gpu", "3.12", tag="latest") == "runpod/flash:py3.12-latest" ) - def test_gpu_3_11_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - get_image_name("gpu", "3.11", tag="latest") - - def test_gpu_3_10_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - get_image_name("gpu", "3.10", tag="latest") - - def test_cpu_3_12(self): + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_gpu_all_supported_versions(self, version): assert ( - get_image_name("cpu", "3.12", tag="latest") - == "runpod/flash-cpu:py3.12-latest" + get_image_name("gpu", version, tag="latest") + == f"runpod/flash:py{version}-latest" ) - def test_cpu_3_11_raises(self): - with pytest.raises(ValueError, match="CPU endpoints require"): - get_image_name("cpu", "3.11", tag="latest") - - def test_cpu_3_10_raises(self): - with pytest.raises(ValueError, match="CPU endpoints require"): - get_image_name("cpu", "3.10", tag="latest") - - def test_lb_3_11_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - get_image_name("lb", "3.11", tag="latest") - - def test_lb_3_10_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - get_image_name("lb", "3.10", tag="latest") - - def test_lb_3_12(self): + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_cpu_all_supported_versions(self, version): assert ( - get_image_name("lb", "3.12", tag="latest") - == "runpod/flash-lb:py3.12-latest" + get_image_name("cpu", version, tag="latest") + == f"runpod/flash-cpu:py{version}-latest" ) - def test_lb_cpu_3_12(self): + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_lb_all_supported_versions(self, version): assert ( - get_image_name("lb-cpu", "3.12", tag="latest") - == "runpod/flash-lb-cpu:py3.12-latest" + get_image_name("lb", version, tag="latest") + == f"runpod/flash-lb:py{version}-latest" ) - def test_lb_cpu_3_10_raises(self): - with pytest.raises(ValueError, match="CPU endpoints require"): - get_image_name("lb-cpu", "3.10", tag="latest") + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_lb_cpu_all_supported_versions(self, version): + assert ( + get_image_name("lb-cpu", version, tag="latest") + == f"runpod/flash-lb-cpu:py{version}-latest" + ) def test_default_tag_reads_flash_image_tag_env(self): with patch.dict(os.environ, {"FLASH_IMAGE_TAG": "v1.0"}): diff --git a/tests/unit/resources/test_live_serverless.py b/tests/unit/resources/test_live_serverless.py index b329fd7d..4dfeb929 100644 --- a/tests/unit/resources/test_live_serverless.py +++ b/tests/unit/resources/test_live_serverless.py @@ -225,21 +225,19 @@ 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 - def test_gpu_explicit_python_311_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - LiveServerless(name="test", python_version="3.11") + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_gpu_explicit_supported_versions(self, version): + ls = LiveServerless(name="test", python_version=version) + assert f"py{version}" in ls.imageName - def test_gpu_explicit_python_310_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - LiveServerless(name="test", python_version="3.10") + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_cpu_explicit_supported_versions(self, version): + ls = CpuLiveServerless(name="test", python_version=version) + assert f"py{version}" in ls.imageName - def test_cpu_explicit_python_311_raises(self): - with pytest.raises(ValueError, match="CPU endpoints require"): - CpuLiveServerless(name="test", python_version="3.11") - - def test_cpu_explicit_python_310_raises(self): - with pytest.raises(ValueError, match="CPU endpoints require"): - CpuLiveServerless(name="test", python_version="3.10") + def test_gpu_unsupported_python_raises(self): + with pytest.raises(ValueError, match="not supported"): + LiveServerless(name="test", python_version="3.9") def test_cpu_default_uses_3_12(self): ls = CpuLiveServerless(name="test") @@ -255,17 +253,17 @@ def test_lb_default_image_uses_gpu_base_python(self): assert f"py{GPU_BASE_IMAGE_PYTHON_VERSION}" in lb.imageName assert "runpod/flash-lb:" in lb.imageName - def test_lb_explicit_python_311_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - LiveLoadBalancer(name="test", python_version="3.11") - - def test_lb_explicit_python_310_raises(self): - with pytest.raises(ValueError, match="GPU endpoints require"): - LiveLoadBalancer(name="test", python_version="3.10") + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + 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 - def test_cpu_lb_explicit_python_310_raises(self): - with pytest.raises(ValueError, match="CPU endpoints require"): - CpuLiveLoadBalancer(name="test", python_version="3.10") + @pytest.mark.parametrize("version", ["3.10", "3.11", "3.12"]) + def test_cpu_lb_explicit_supported_versions(self, version): + lb = CpuLiveLoadBalancer(name="test", python_version=version) + assert f"py{version}" in lb.imageName + assert "runpod/flash-lb-cpu:" in lb.imageName def test_cpu_lb_default_uses_3_12(self): lb = CpuLiveLoadBalancer(name="test") diff --git a/tests/unit/test_dotenv_loading.py b/tests/unit/test_dotenv_loading.py index 1a080b1f..a0536df4 100644 --- a/tests/unit/test_dotenv_loading.py +++ b/tests/unit/test_dotenv_loading.py @@ -9,6 +9,37 @@ from pathlib import Path from unittest.mock import patch +import pytest + + +@pytest.fixture +def preserve_runpod_flash_modules(): + """Save and restore ``runpod_flash.*`` entries in ``sys.modules``. + + Tests that force a fresh import of ``runpod_flash`` delete entries from + ``sys.modules``. Without this fixture, subsequent tests import freshly- + created module objects while earlier imports (in other test modules) still + hold references to the now-orphaned originals. The split breaks any code + that touches module-level singletons — e.g. the autouse conftest fixture + clears the new ``_SERIALIZED_CLASS_CACHE`` while test code holds a reference + to the old one. + """ + saved = { + name: module + for name, module in sys.modules.items() + if name == "runpod_flash" or name.startswith("runpod_flash.") + } + try: + yield + finally: + for name in [ + n + for n in sys.modules + if n == "runpod_flash" or n.startswith("runpod_flash.") + ]: + del sys.modules[name] + sys.modules.update(saved) + class TestDotenvLoading: """Test environment variable loading from .env files and shell environment.""" @@ -151,7 +182,7 @@ def test_shell_env_vars_override_file_vars(self): elif "TEST_OVERRIDE_VAR" in os.environ: del os.environ["TEST_OVERRIDE_VAR"] - def test_env_vars_available_after_flash_import(self): + def test_env_vars_available_after_flash_import(self, preserve_runpod_flash_modules): """Test that env vars are available when runpod_flash modules are imported.""" # Set up test environment variables @@ -270,7 +301,7 @@ def test_malformed_env_file_handling(self): if var in os.environ: del os.environ[var] - def test_env_vars_used_by_key_modules(self): + def test_env_vars_used_by_key_modules(self, preserve_runpod_flash_modules): """Test that key modules properly use environment variables loaded by dotenv.""" # Test environment variables - set before any imports