From 496179984740720671d74b2ebcbefd391318bb22 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 25 Aug 2026 10:40:10 -0400 Subject: [PATCH 1/3] fix(deploy): report client-mode endpoints honestly instead of claiming deployment An Endpoint declared in client mode (image=...) with no decorated functions produces a manifest with an empty resources map. flash deploy still uploaded the artifact, created app+environment, and printed 'deployed to production' even though nothing was provisioned. Detect the empty-resources case in _resolve_and_deploy after manifest validation and exit with an honest message: client-mode endpoints provision lazily on first run(). --- src/runpod_flash/cli/commands/deploy.py | 15 +++++- tests/unit/cli/test_deploy.py | 69 ++++++++++++++++++++++--- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/runpod_flash/cli/commands/deploy.py b/src/runpod_flash/cli/commands/deploy.py index a87db7117..601fb4903 100644 --- a/src/runpod_flash/cli/commands/deploy.py +++ b/src/runpod_flash/cli/commands/deploy.py @@ -217,10 +217,21 @@ async def _resolve_and_deploy( ) -> None: import time as _time - app, resolved_env_name = await _resolve_environment(app_name, env_name) - local_manifest = validate_local_manifest() + # Client-mode endpoints (Endpoint(image=...) with no decorated functions) + # leave the manifest's resources map empty. Nothing is provisioned here; + # those endpoints provision lazily on first run(), so don't claim a + # successful deployment. + if not local_manifest.get("resources"): + console.print( + "[yellow]no deployable resources found; " + "client-mode endpoints provision on first run()[/yellow]" + ) + return + + app, resolved_env_name = await _resolve_environment(app_name, env_name) + from rich.progress import ( BarColumn, DownloadColumn, diff --git a/tests/unit/cli/test_deploy.py b/tests/unit/cli/test_deploy.py index 3ea6f8c94..6338e6f6b 100644 --- a/tests/unit/cli/test_deploy.py +++ b/tests/unit/cli/test_deploy.py @@ -42,7 +42,7 @@ class TestDeployCommand: ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -92,7 +92,7 @@ def test_deploy_single_env_auto_selects( ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -190,7 +190,7 @@ def test_deploy_multiple_envs_no_flag_errors( ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch("runpod_flash.cli.commands.deploy.run_build") @patch("runpod_flash.cli.commands.deploy.discover_flash_project") @@ -265,7 +265,7 @@ def test_deploy_non_app_error_propagates( ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -315,7 +315,7 @@ def test_deploy_auto_creates_nonexistent_env( ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -363,7 +363,7 @@ def test_deploy_zero_envs_creates_production( ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -418,7 +418,7 @@ def test_deploy_shows_completion_panel( ) @patch( "runpod_flash.cli.commands.deploy.validate_local_manifest", - return_value={"resources": {}}, + return_value={"resources": {"my_resource": {}}}, ) @patch( "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock @@ -462,6 +462,61 @@ def test_deploy_uses_app_flag( mock_from_name.assert_awaited_once_with("custom-app") +class TestDeployCommandEmptyResources: + """flash deploy with an empty manifest resources map is not a deployment.""" + + @patch( + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value={"resources": {}}, + ) + @patch( + "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock + ) + @patch("runpod_flash.cli.commands.deploy.run_build") + @patch("runpod_flash.cli.commands.deploy.discover_flash_project") + def test_deploy_empty_resources_reports_no_deployment( + self, + mock_discover, + mock_build, + mock_from_name, + mock_validate, + mock_deploy, + runner, + mock_asyncio_run_coro, + patched_console, + ): + """Client-mode endpoints (no decorated functions) produce empty + resources; deploy must report that honestly instead of claiming success.""" + mock_discover.return_value = (Path("/tmp/project"), "my-app") + _archive = MagicMock(spec=Path) + _archive.stat.return_value.st_size = 1024 * 1024 + mock_build.return_value = _archive + + with ( + patch( + "runpod_flash.cli.commands.deploy.asyncio.run", + side_effect=mock_asyncio_run_coro, + ), + patch("runpod_flash.cli.commands.deploy.shutil"), + ): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + printed_output = " ".join( + str(call.args[0]) if call.args else "" + for call in patched_console.print.call_args_list + ) + assert "no deployable resources found" in printed_output + assert "deployed to" not in printed_output + # Nothing should be uploaded or provisioned + mock_from_name.assert_not_awaited() + mock_deploy.assert_not_awaited() + + class TestDisplayPostDeploymentGuidance: """Tests for _display_post_deployment_guidance output formatting.""" From 348f98a0147092a9a84033812710bf48f82aeb1e Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 25 Aug 2026 12:01:52 -0400 Subject: [PATCH 2/3] feat(deploy): provision client-mode endpoints during flash deploy Endpoint(image=...) objects with no decorated functions produced a manifest with an empty resources map, so flash deploy uploaded and 'deployed' nothing. The build now discovers top-level client-mode Endpoint instances during scanning and registers each as a function-less manifest entry marked client_mode: true, so the existing deploy provisioning path (create_resource_from_manifest + ResourceManager.get_or_deploy_resource) provisions them like any other resource. Handler generators and local preview skip client_mode entries; there is no worker code to generate for them. - scanner: collect Endpoint(image=...) instances (id= excluded); tolerate same-name re-exports across files, fail on conflicting images - manifest: register client endpoints with deployment config extracted from the Endpoint's resource config; fall back to resource_type 'Endpoint' (resolved via gpuIds) when the built config class is not one the provisioner can rebuild - deployment: skip source-fingerprint injection for client_mode entries so code-only deploys do not churn external-service endpoints - deploy: keep the honest 'no deployable resources' message only for projects with neither decorated resources nor client endpoints Fixes #365 --- .../commands/build_utils/handler_generator.py | 10 + .../cli/commands/build_utils/manifest.py | 123 +++++++++++ .../cli/commands/build_utils/scanner.py | 54 +++++ src/runpod_flash/cli/commands/deploy.py | 11 +- src/runpod_flash/cli/commands/preview.py | 4 + src/runpod_flash/cli/utils/deployment.py | 5 + .../runtime/resource_provisioner.py | 22 +- .../build_utils/test_manifest_endpoint.py | 200 +++++++++++++++++ .../build_utils/test_runtime_scanner.py | 107 +++++++++ tests/unit/cli/test_deploy.py | 90 +++++++- tests/unit/cli/utils/test_deployment.py | 206 ++++++++++++++++++ 11 files changed, 816 insertions(+), 16 deletions(-) diff --git a/src/runpod_flash/cli/commands/build_utils/handler_generator.py b/src/runpod_flash/cli/commands/build_utils/handler_generator.py index 5cf3d5f44..50a13b9e6 100644 --- a/src/runpod_flash/cli/commands/build_utils/handler_generator.py +++ b/src/runpod_flash/cli/commands/build_utils/handler_generator.py @@ -315,6 +315,16 @@ def generate_handlers(self) -> List[Path]: ) for resource_name, resource_data in resources.items(): + # Skip client-mode resources (image-based, external service): + # they have no functions, so there is no handler to generate. + client_mode = ( + resource_data.get("client_mode", False) + if isinstance(resource_data, dict) + else getattr(resource_data, "client_mode", False) + ) + if client_mode: + continue + # Skip load-balanced resources (handled by LBHandlerGenerator) # Use flag determined by isinstance() at scan time is_load_balanced = ( diff --git a/src/runpod_flash/cli/commands/build_utils/manifest.py b/src/runpod_flash/cli/commands/build_utils/manifest.py index 520792ec1..cfc0899d0 100644 --- a/src/runpod_flash/cli/commands/build_utils/manifest.py +++ b/src/runpod_flash/cli/commands/build_utils/manifest.py @@ -11,6 +11,7 @@ DEFAULT_PYTHON_VERSION, validate_python_version, ) +from runpod_flash.runtime.resource_provisioner import PROVISIONABLE_RESOURCE_TYPES from .scanner import ( RemoteFunctionMetadata, @@ -281,6 +282,126 @@ def _extract_config_properties(config: Dict[str, Any], resource_config) -> None: return config + def _add_client_endpoints( + self, resources_dict: Dict[str, Dict[str, Any]], project_root: Path + ) -> None: + """Register client-mode Endpoints (image=..., no decorated functions). + + The scanner collects top-level ``Endpoint(image=...)`` objects. They + have no decorated functions, but ``flash deploy`` should still + provision them, so each gets a function-less manifest entry that the + deploy provisioning path (create_resource_from_manifest) consumes. + The entry is marked ``client_mode: True`` so consumers that generate + worker code (handler generators, local preview) skip it. + """ + client_endpoints = ( + getattr(self.scanner, "client_endpoints", None) if self.scanner else None + ) + if not client_endpoints: + return + + for name in sorted(client_endpoints): + if name in resources_dict: + raise ValueError( + f"endpoint '{name}' is defined as both a client-mode " + f"Endpoint(image=...) and a decorated resource. " + f"each endpoint name must be unique across the project." + ) + + info = client_endpoints[name] + file_path = info.get("file_path") + + file_path_str = "" + local_path_prefix = "" + resource_module_path = info.get("module_path", "") + if file_path and file_path.exists(): + try: + file_path_str = str(file_path.relative_to(project_root)) + local_path_prefix = file_to_url_prefix(file_path, project_root) + resource_module_path = file_to_module_path(file_path, project_root) + except ValueError: + # File is outside project root — fall back to module_path + file_path_str = str(file_path) + local_path_prefix = "/" + resource_module_path.replace(".", "/") + elif file_path: + file_path_str = str(file_path) + local_path_prefix = "/" + resource_module_path.replace(".", "/") + + deployment_config, resource_type = self._extract_client_deployment_config( + name, info + ) + + resources_dict[name] = { + "resource_type": resource_type, + "file_path": file_path_str, + "local_path_prefix": local_path_prefix, + "module_path": resource_module_path, + "functions": [], + "is_load_balanced": False, + "is_live_resource": True, + "config_variable": info.get("var_name"), + "makes_remote_calls": False, + "client_mode": True, + **deployment_config, + } + + def _extract_client_deployment_config( + self, resource_name: str, info: Dict[str, Any] + ) -> tuple[Dict[str, Any], str]: + """Extract deployment config from a client-mode Endpoint instance. + + Returns (config, resource_type). resource_type is the concrete + resource class name (e.g. "LiveServerless") when + create_resource_from_manifest can rebuild it; otherwise it falls back + to "Endpoint", which the provisioner resolves via gpuIds. + """ + config: Dict[str, Any] = {} + resource_type = "Endpoint" + + file_path = info.get("file_path") + var_name = info.get("var_name") + + try: + module, cleanup = ( + self._import_module(file_path) + if file_path and file_path.exists() + else (None, None) + ) + if module is not None: + try: + ep = getattr(module, var_name, None) if var_name else None + if ep is not None: + mc = getattr(ep, "_max_concurrency", 1) + if mc > 1: + config["max_concurrency"] = mc + + resource_config = ep._build_resource_config() + self._extract_config_properties(config, resource_config) + type_name = type(resource_config).__name__ + # Record the concrete class only when the deploy-time + # provisioner can rebuild it (e.g. "LiveServerless"). + # Otherwise keep "Endpoint" and let the provisioner + # resolve GPU vs CPU from gpuIds instead of failing on + # an unsupported class name. + if type_name in PROVISIONABLE_RESOURCE_TYPES: + resource_type = type_name + finally: + cleanup() + except Exception as e: + logger.debug( + f"Failed to extract deployment config for client endpoint " + f"{resource_name}: {e}" + ) + + if "imageName" not in config and "templateId" not in config: + logger.warning( + "Failed to extract image/template for client endpoint " + f"'{resource_name}'; the deployed endpoint may not use the " + "expected image." + ) + + return config, resource_type + def _reconcile_python_version( self, resources_dict: Dict[str, Dict[str, Any]] ) -> str: @@ -509,6 +630,8 @@ def build(self) -> Dict[str, Any]: ) function_registry[f.function_name] = resource_name + self._add_client_endpoints(resources_dict, project_root) + # 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) diff --git a/src/runpod_flash/cli/commands/build_utils/scanner.py b/src/runpod_flash/cli/commands/build_utils/scanner.py index 488bca8ed..761fc6261 100644 --- a/src/runpod_flash/cli/commands/build_utils/scanner.py +++ b/src/runpod_flash/cli/commands/build_utils/scanner.py @@ -457,6 +457,9 @@ def __init__(self, project_dir: Path): self.resource_variables: Dict[str, str] = {} # populated after discover_remote_functions() runs self.import_errors: Dict[str, str] = {} + # client-mode Endpoints (image=..., id=None) keyed by endpoint name; + # each value carries var_name/file_path/module_path for the manifest. + self.client_endpoints: Dict[str, Dict[str, Any]] = {} def discover_remote_functions(self) -> List[RemoteFunctionMetadata]: """discover all @remote decorated functions and classes by importing modules.""" @@ -556,6 +559,8 @@ def _extract_from_module( """extract RemoteFunctionMetadata from a single imported module.""" results: List[RemoteFunctionMetadata] = [] + self._collect_client_endpoints(module, module_path, file_path) + remote_objects = _find_remote_decorated(module) endpoint_instances = _find_endpoint_instances(module) @@ -608,6 +613,55 @@ def _extract_from_module( return results + def _collect_client_endpoints( + self, module: Any, module_path: str, file_path: Path + ) -> None: + """record client-mode Endpoints (image=..., no id) for the manifest. + + client-mode endpoints declare an external service image; they have no + decorated functions, but `flash deploy` should still provision them. + id= endpoints are excluded: they attach to an already-existing + endpoint, so there is nothing to provision. + + Re-exports (``from defs import ep`` in a second file) surface as a + distinct object with the same name and image because each scanned file + is imported fresh; those keep the first record. The same name with a + *different* image is a genuine collision and fails the build. + """ + for member_name in dir(module): + try: + member = getattr(module, member_name) + except Exception: + continue + if ( + not isinstance(member, Endpoint) + or member.image is None + or member.id is not None + ): + continue + name = member.name or "" + existing = self.client_endpoints.get(name) + if existing is not None: + if existing["object_id"] == id(member): + continue # aliased under a second variable name + if existing["file_path"] != file_path: + if existing["image"] == member.image: + # re-exported from another file; keep the first record + continue + raise ValueError( + f"endpoint '{name}' is defined in multiple files: " + f"{existing['file_path']}, {file_path}. " + f"each endpoint name must be unique across the project." + ) + # two distinct objects with the same name in one file: last wins + self.client_endpoints[name] = { + "var_name": member_name, + "file_path": file_path, + "module_path": module_path, + "object_id": id(member), + "image": member.image, + } + def _populate_resource_dicts(self, functions: List[RemoteFunctionMetadata]) -> None: """populate resource tracking dicts for ManifestBuilder compatibility.""" for f in functions: diff --git a/src/runpod_flash/cli/commands/deploy.py b/src/runpod_flash/cli/commands/deploy.py index 601fb4903..f8dc201a6 100644 --- a/src/runpod_flash/cli/commands/deploy.py +++ b/src/runpod_flash/cli/commands/deploy.py @@ -219,14 +219,13 @@ async def _resolve_and_deploy( local_manifest = validate_local_manifest() - # Client-mode endpoints (Endpoint(image=...) with no decorated functions) - # leave the manifest's resources map empty. Nothing is provisioned here; - # those endpoints provision lazily on first run(), so don't claim a - # successful deployment. + # An empty resources map means the project declares no deployable + # endpoints at all (no @remote functions, no client-mode + # Endpoint(image=...) declarations -- the build registers those in the + # manifest). Nothing is provisioned here, so don't claim a deployment. if not local_manifest.get("resources"): console.print( - "[yellow]no deployable resources found; " - "client-mode endpoints provision on first run()[/yellow]" + "[yellow]no deployable resources found; nothing was deployed[/yellow]" ) return diff --git a/src/runpod_flash/cli/commands/preview.py b/src/runpod_flash/cli/commands/preview.py index 96d78290b..1f334df2d 100644 --- a/src/runpod_flash/cli/commands/preview.py +++ b/src/runpod_flash/cli/commands/preview.py @@ -173,6 +173,10 @@ def _parse_resources_from_manifest(manifest: dict) -> dict: # Parse resources from manifest manifest_resources = manifest.get("resources", {}) for resource_name, resource_data in manifest_resources.items(): + # Client-mode endpoints run a user-supplied image and are provisioned + # remotely by flash deploy; they are not launched as local containers. + if resource_data.get("client_mode", False): + continue resources[resource_name] = { "is_load_balanced": resource_data.get("is_load_balanced", False), "imageName": resource_data.get("imageName", FLASH_CPU_LB_IMAGE), diff --git a/src/runpod_flash/cli/utils/deployment.py b/src/runpod_flash/cli/utils/deployment.py index 3d7f1f844..1a53e80cc 100644 --- a/src/runpod_flash/cli/utils/deployment.py +++ b/src/runpod_flash/cli/utils/deployment.py @@ -122,9 +122,14 @@ async def reconcile_and_provision_resources( # changes (no resource config diff) still trigger a rolling release. # The fingerprint is computed during flash build from user source files. # Mutation intentional: persisted to state manifest via update_build_manifest below. + # Client-mode endpoints run a user-supplied image; local source changes do + # not affect them, so skip the fingerprint to avoid redeploying them on + # every code-only deploy. source_fingerprint = local_manifest.get("source_fingerprint") if source_fingerprint: for resource_config in local_manifest.get("resources", {}).values(): + if resource_config.get("client_mode"): + continue env = resource_config.setdefault("env", {}) env["_FLASH_SOURCE_FINGERPRINT"] = source_fingerprint diff --git a/src/runpod_flash/runtime/resource_provisioner.py b/src/runpod_flash/runtime/resource_provisioner.py index 985184d85..fcecbafff 100644 --- a/src/runpod_flash/runtime/resource_provisioner.py +++ b/src/runpod_flash/runtime/resource_provisioner.py @@ -6,6 +6,19 @@ logger = logging.getLogger(__name__) +# Resource classes that create_resource_from_manifest can construct from a +# manifest entry. "Endpoint" is accepted separately (resolved to one of these +# via gpuIds). ManifestBuilder gates client-mode resource_type names against +# this list so the manifest never names a class this module cannot rebuild. +PROVISIONABLE_RESOURCE_TYPES = ( + "ServerlessResource", + "LiveServerless", + "CpuLiveServerless", + "LoadBalancerSlsResource", + "LiveLoadBalancer", + "CpuLiveLoadBalancer", +) + def create_resource_from_manifest( resource_name: str, @@ -59,14 +72,7 @@ def create_resource_from_manifest( resource_type = "CpuLiveServerless" # Support both Serverless and LoadBalancer resource types - if resource_type not in [ - "ServerlessResource", - "LiveServerless", - "CpuLiveServerless", - "LoadBalancerSlsResource", - "LiveLoadBalancer", - "CpuLiveLoadBalancer", - ]: + if resource_type not in PROVISIONABLE_RESOURCE_TYPES: raise ValueError( f"Unsupported resource type for auto-provisioning: {resource_type}" ) diff --git a/tests/unit/cli/commands/build_utils/test_manifest_endpoint.py b/tests/unit/cli/commands/build_utils/test_manifest_endpoint.py index 2096c891b..88d451f54 100644 --- a/tests/unit/cli/commands/build_utils/test_manifest_endpoint.py +++ b/tests/unit/cli/commands/build_utils/test_manifest_endpoint.py @@ -6,6 +6,8 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest + from runpod_flash.cli.commands.build_utils.manifest import ManifestBuilder from runpod_flash.cli.commands.build_utils.scanner import RemoteFunctionMetadata @@ -246,3 +248,201 @@ def test_endpoint_sys_path_cleaned_up(self): path_before = sys.path.copy() builder._extract_deployment_config("test", "ep", "Endpoint") assert sys.path == path_before + + +class TestAddClientEndpoints: + """test manifest registration of client-mode Endpoint(image=...) objects.""" + + def _build_manifest(self, project_dir: Path, files: dict) -> dict: + for name, code in files.items(): + target = project_dir / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(code, encoding="utf-8") + + from runpod_flash.cli.commands.build_utils.scanner import RuntimeScanner + + scanner = RuntimeScanner(project_dir) + functions = scanner.discover_remote_functions() + builder = ManifestBuilder( + "test_app", functions, scanner=scanner, build_dir=project_dir + ) + return builder.build() + + def test_client_endpoint_registered_as_provisionable_resource(self): + """a client-only project produces a function-less manifest entry.""" + with tempfile.TemporaryDirectory() as tmpdir: + manifest = self._build_manifest( + Path(tmpdir), + { + "vllm_ep.py": ( + "from runpod_flash import Endpoint\n" + "from runpod_flash.core.resources.gpu import GpuGroup\n" + "\n" + "vllm = Endpoint(\n" + ' name="vllm-server",\n' + ' image="runpod/vllm-openai:stable",\n' + " gpu=GpuGroup.ADA_24,\n" + " workers=(0, 3),\n" + ' env={"MODEL_NAME": "m"},\n' + ")\n" + ), + }, + ) + + entry = manifest["resources"]["vllm-server"] + assert entry["client_mode"] is True + assert entry["functions"] == [] + assert entry["is_load_balanced"] is False + assert entry["makes_remote_calls"] is False + assert entry["imageName"] == "runpod/vllm-openai:stable" + assert entry["gpuIds"] == "ADA_24" + assert entry["workersMin"] == 0 + assert entry["workersMax"] == 3 + assert entry["env"] == {"MODEL_NAME": "m"} + assert entry["resource_type"] == "LiveServerless" + # client endpoints never get a generated worker handler + assert "handler_file" not in entry + # no decorated functions exist, so the registry is empty + assert manifest["function_registry"] == {} + + def test_cpu_client_endpoint_resource_type(self): + """cpu client endpoints resolve to CpuLiveServerless with instanceIds.""" + with tempfile.TemporaryDirectory() as tmpdir: + manifest = self._build_manifest( + Path(tmpdir), + { + "cpu_ep.py": ( + "from runpod_flash import Endpoint\n" + "from runpod_flash.core.resources.cpu import CpuInstanceType\n" + "\n" + "whisper = Endpoint(\n" + ' name="whisper",\n' + ' image="runpod/whisper:latest",\n' + " cpu=CpuInstanceType.CPU3G_2_8,\n" + ")\n" + ), + }, + ) + + entry = manifest["resources"]["whisper"] + assert entry["client_mode"] is True + assert entry["resource_type"] == "CpuLiveServerless" + assert entry["instanceIds"] == ["cpu3g-2-8"] + assert entry["imageName"] == "runpod/whisper:latest" + + def test_non_provisionable_class_name_falls_back_to_endpoint(self): + """when the built config class is not provisionable (deploy-mode env), + resource_type stays 'Endpoint' so the provisioner resolves it.""" + with tempfile.TemporaryDirectory() as tmpdir: + with patch.dict(os.environ, {"FLASH_IS_LIVE_PROVISIONING": "false"}): + manifest = self._build_manifest( + Path(tmpdir), + { + "vllm_ep.py": ( + "from runpod_flash import Endpoint\n" + "from runpod_flash.core.resources.gpu import GpuGroup\n" + "\n" + "vllm = Endpoint(\n" + ' name="vllm-server",\n' + ' image="runpod/vllm-openai:stable",\n' + " gpu=GpuGroup.ADA_24,\n" + ")\n" + ), + }, + ) + + entry = manifest["resources"]["vllm-server"] + assert entry["resource_type"] == "Endpoint" + # gpuIds still present so the provisioner can resolve GPU vs CPU + assert entry["gpuIds"] == "ADA_24" + assert entry["imageName"] == "runpod/vllm-openai:stable" + + def test_mixed_project_registers_both(self): + """decorated resources and client endpoints coexist in one manifest.""" + with tempfile.TemporaryDirectory() as tmpdir: + manifest = self._build_manifest( + Path(tmpdir), + { + "worker.py": ( + "from runpod_flash import remote, LiveServerless\n" + 'cfg = LiveServerless(name="worker")\n' + "\n" + "@remote(cfg)\n" + "def process(x):\n" + " return x\n" + ), + "vllm_ep.py": ( + "from runpod_flash import Endpoint\n" + "\n" + 'vllm = Endpoint(name="vllm-server", image="img:v1")\n' + ), + }, + ) + + assert set(manifest["resources"]) == {"worker", "vllm-server"} + assert manifest["resources"]["vllm-server"]["client_mode"] is True + assert manifest["resources"]["worker"]["functions"] + assert "client_mode" not in manifest["resources"]["worker"] + assert manifest["function_registry"] == {"process": "worker"} + + def test_name_collision_with_decorated_resource_raises(self): + """a client endpoint sharing a decorated resource's name is an error.""" + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(ValueError, match="both a client-mode"): + self._build_manifest( + Path(tmpdir), + { + "worker.py": ( + "from runpod_flash import remote, LiveServerless\n" + 'cfg = LiveServerless(name="vllm-server")\n' + "\n" + "@remote(cfg)\n" + "def process(x):\n" + " return x\n" + ), + "vllm_ep.py": ( + "from runpod_flash import Endpoint\n" + "\n" + 'vllm = Endpoint(name="vllm-server", image="img:v1")\n' + ), + }, + ) + + def test_import_failure_falls_back_to_endpoint_type(self, caplog): + """unimportable source module keeps the entry but with a bare + 'Endpoint' type and a warning about the missing image.""" + import logging + + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + bad_file = project_dir / "broken.py" + bad_file.write_text("def broken(:\n", encoding="utf-8") + + scanner = MagicMock() + scanner.client_endpoints = { + "broken-ep": { + "var_name": "ep", + "file_path": bad_file, + "module_path": "broken", + } + } + builder = ManifestBuilder("test_app", [], scanner=scanner) + + with caplog.at_level(logging.WARNING): + manifest = builder.build() + + entry = manifest["resources"]["broken-ep"] + assert entry["client_mode"] is True + assert entry["resource_type"] == "Endpoint" + assert "imageName" not in entry + assert any( + "Failed to extract image/template" in rec.message for rec in caplog.records + ) + + def test_no_client_endpoints_is_noop(self): + """a scanner with no client endpoints leaves resources untouched.""" + scanner = MagicMock() + scanner.client_endpoints = {} + builder = ManifestBuilder("test_app", [], scanner=scanner) + manifest = builder.build() + assert manifest["resources"] == {} diff --git a/tests/unit/cli/commands/build_utils/test_runtime_scanner.py b/tests/unit/cli/commands/build_utils/test_runtime_scanner.py index 64365676a..ec26fe7f3 100644 --- a/tests/unit/cli/commands/build_utils/test_runtime_scanner.py +++ b/tests/unit/cli/commands/build_utils/test_runtime_scanner.py @@ -807,3 +807,110 @@ async def predict(self, x): assert len(functions) == 1 assert functions[0].is_class is True assert functions[0].is_async is True + + +# --------------------------------------------------------------------------- +# client-mode Endpoint collection +# --------------------------------------------------------------------------- + + +class TestCollectClientEndpoints: + """RuntimeScanner.client_endpoints records top-level Endpoint(image=...).""" + + def test_collects_image_endpoint_without_functions(self, tmp_path): + """a client-mode endpoint is collected even with no decorated functions.""" + _write_worker( + tmp_path, + "vllm_ep.py", + """\ + from runpod_flash import Endpoint + from runpod_flash.core.resources.gpu import GpuGroup + + vllm = Endpoint( + name="vllm-server", + image="runpod/vllm-openai:stable", + gpu=GpuGroup.ADA_24, + ) + """, + ) + scanner = RuntimeScanner(tmp_path) + functions = scanner.discover_remote_functions() + assert functions == [] + assert set(scanner.client_endpoints) == {"vllm-server"} + info = scanner.client_endpoints["vllm-server"] + assert info["var_name"] == "vllm" + assert info["module_path"] == "vllm_ep" + assert info["file_path"] == tmp_path / "vllm_ep.py" + + def test_skips_id_endpoints(self, tmp_path): + """Endpoint(id=...) attaches to an existing endpoint; nothing to provision.""" + _write_worker( + tmp_path, + "attach.py", + """\ + from runpod_flash import Endpoint + + existing = Endpoint(name="existing", id="ep-123") + """, + ) + scanner = RuntimeScanner(tmp_path) + scanner.discover_remote_functions() + assert scanner.client_endpoints == {} + + def test_skips_decorator_mode_endpoints(self, tmp_path): + """Endpoint without image= is decorator-mode, not a client endpoint.""" + _write_remote_worker(tmp_path) + scanner = RuntimeScanner(tmp_path) + functions = scanner.discover_remote_functions() + assert len(functions) == 1 + assert scanner.client_endpoints == {} + + def test_dedupes_reexported_endpoint_across_files(self, tmp_path): + """an endpoint imported into a second module is recorded only once.""" + _write_worker( + tmp_path, + "a_defs.py", + """\ + from runpod_flash import Endpoint + + vllm = Endpoint(name="vllm-server", image="img:v1") + """, + ) + _write_worker( + tmp_path, + "b_reexport.py", + """\ + from a_defs import vllm + """, + ) + scanner = RuntimeScanner(tmp_path) + scanner.discover_remote_functions() + assert set(scanner.client_endpoints) == {"vllm-server"} + # recorded against the defining file (a_defs.py sorts first) + assert scanner.client_endpoints["vllm-server"]["file_path"] == ( + tmp_path / "a_defs.py" + ) + + def test_same_name_in_two_files_raises(self, tmp_path): + """two distinct endpoints sharing a name fail the scan loudly.""" + _write_worker( + tmp_path, + "a_defs.py", + """\ + from runpod_flash import Endpoint + + ep = Endpoint(name="dup", image="img:v1") + """, + ) + _write_worker( + tmp_path, + "b_defs.py", + """\ + from runpod_flash import Endpoint + + ep = Endpoint(name="dup", image="img:v2") + """, + ) + scanner = RuntimeScanner(tmp_path) + with pytest.raises(ValueError, match="defined in multiple files"): + scanner.discover_remote_functions() diff --git a/tests/unit/cli/test_deploy.py b/tests/unit/cli/test_deploy.py index 6338e6f6b..5ca8e7018 100644 --- a/tests/unit/cli/test_deploy.py +++ b/tests/unit/cli/test_deploy.py @@ -489,8 +489,9 @@ def test_deploy_empty_resources_reports_no_deployment( mock_asyncio_run_coro, patched_console, ): - """Client-mode endpoints (no decorated functions) produce empty - resources; deploy must report that honestly instead of claiming success.""" + """A project with neither decorated resources nor client-mode + endpoints produces an empty resources map; deploy must report that + honestly instead of claiming success.""" mock_discover.return_value = (Path("/tmp/project"), "my-app") _archive = MagicMock(spec=Path) _archive.stat.return_value.st_size = 1024 * 1024 @@ -517,6 +518,91 @@ def test_deploy_empty_resources_reports_no_deployment( mock_deploy.assert_not_awaited() +class TestDeployCommandClientModeEndpoints: + """Projects whose only endpoints are client-mode Endpoint(image=...) + declarations ARE deployable: the build registers them in the manifest + and the normal provisioning path creates the endpoints.""" + + _CLIENT_MANIFEST = { + "resources": { + "vllm-server": { + "resource_type": "LiveServerless", + "client_mode": True, + "imageName": "runpod/vllm-openai:stable", + "functions": [], + "is_load_balanced": False, + "makes_remote_calls": False, + } + } + } + + @patch( + "runpod_flash.cli.commands.deploy.deploy_from_uploaded_build", + new_callable=AsyncMock, + ) + @patch( + "runpod_flash.cli.commands.deploy.validate_local_manifest", + return_value=_CLIENT_MANIFEST, + ) + @patch( + "runpod_flash.cli.commands.deploy.FlashApp.from_name", new_callable=AsyncMock + ) + @patch("runpod_flash.cli.commands.deploy.run_build") + @patch("runpod_flash.cli.commands.deploy.discover_flash_project") + def test_deploy_provisions_client_mode_endpoints( + self, + mock_discover, + mock_build, + mock_from_name, + mock_validate, + mock_deploy, + runner, + mock_asyncio_run_coro, + patched_console, + ): + mock_discover.return_value = (Path("/tmp/project"), "my-app") + _archive = MagicMock(spec=Path) + _archive.stat.return_value.st_size = 1024 * 1024 + mock_build.return_value = _archive + mock_deploy.return_value = { + "success": True, + "resources_endpoints": {"vllm-server": "https://vllm-xyz.api.runpod.ai"}, + "local_manifest": self._CLIENT_MANIFEST, + } + + flash_app = _make_flash_app( + list_environments=AsyncMock( + return_value=[{"name": "production", "id": "env-1"}] + ), + ) + mock_from_name.return_value = flash_app + + with ( + patch( + "runpod_flash.cli.commands.deploy.asyncio.run", + side_effect=mock_asyncio_run_coro, + ), + patch("runpod_flash.cli.commands.deploy.shutil"), + ): + result = runner.invoke(app, ["deploy"]) + + assert result.exit_code == 0 + # the client-mode manifest flows into the provisioning path untouched + mock_deploy.assert_awaited_once() + passed_manifest = mock_deploy.call_args[0][3] + assert passed_manifest["resources"]["vllm-server"]["client_mode"] is True + + printed_output = " ".join( + str(call.args[0]) if call.args else "" + for call in patched_console.print.call_args_list + ) + assert "deployed to" in printed_output + assert "no deployable resources found" not in printed_output + # post-deploy guidance lists the provisioned endpoint + assert "vllm-server" in printed_output + assert "https://vllm-xyz.api.runpod.ai/runsync" in printed_output + + class TestDisplayPostDeploymentGuidance: """Tests for _display_post_deployment_guidance output formatting.""" diff --git a/tests/unit/cli/utils/test_deployment.py b/tests/unit/cli/utils/test_deployment.py index 9dab333d0..334a8e24e 100644 --- a/tests/unit/cli/utils/test_deployment.py +++ b/tests/unit/cli/utils/test_deployment.py @@ -636,3 +636,209 @@ async def test_missing_source_fingerprint_backward_compatible(tmp_path): local_manifest["resources_endpoints"]["worker"] == "https://worker.api.runpod.ai" ) + + +@pytest.mark.asyncio +async def test_client_mode_resource_provisioned_on_first_deploy(tmp_path): + """A client_mode manifest entry (Endpoint(image=...), no functions) is + provisioned through the same create_resource_from_manifest + + get_or_deploy_resource path as decorated resources.""" + import json + + flash_dir = tmp_path / ".flash" + flash_dir.mkdir() + + client_config = { + "resource_type": "LiveServerless", + "client_mode": True, + "imageName": "runpod/vllm-openai:stable", + "gpuIds": "ADA_24", + "functions": [], + "is_load_balanced": False, + "makes_remote_calls": False, + "env": {"MODEL_NAME": "m"}, + } + local_manifest = { + "source_fingerprint": "fingerprint_abc", + "resources": {"vllm-server": dict(client_config)}, + "resources_endpoints": {}, + } + (flash_dir / "flash_manifest.json").write_text(json.dumps(local_manifest)) + + app = AsyncMock() + app.name = "my-app" + app.get_build_manifest = AsyncMock(return_value={}) # first deploy + app.update_build_manifest = AsyncMock() + + deployed = MagicMock() + deployed.endpoint_url = "https://vllm.api.runpod.ai" + deployed.endpoint_id = "endpoint-xyz" + + with ( + patch("pathlib.Path.cwd", return_value=tmp_path), + patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls, + patch( + "runpod_flash.cli.utils.deployment.create_resource_from_manifest" + ) as mock_create_resource, + ): + mock_manager = MagicMock() + mock_manager.get_or_deploy_resource = AsyncMock(return_value=deployed) + mock_manager_cls.return_value = mock_manager + mock_create_resource.return_value = MagicMock() + + endpoints = await reconcile_and_provision_resources( + app, + "build-123", + "production", + local_manifest, + environment_id="env-1", + show_progress=False, + ) + + # the client entry is provisioned like any other resource + mock_create_resource.assert_called_once() + create_args, create_kwargs = mock_create_resource.call_args + assert create_args[0] == "vllm-server" + assert create_args[1]["imageName"] == "runpod/vllm-openai:stable" + assert create_args[1]["client_mode"] is True + mock_manager.get_or_deploy_resource.assert_awaited_once() + + # endpoint info lands in the manifest + returned endpoints map + assert endpoints == {"vllm-server": "https://vllm.api.runpod.ai"} + assert local_manifest["resources"]["vllm-server"]["endpoint_id"] == "endpoint-xyz" + + # state manifest persisted so a later deploy sees the resource + app.update_build_manifest.assert_awaited_once() + persisted = app.update_build_manifest.call_args[0][1] + assert ( + persisted["resources_endpoints"]["vllm-server"] == "https://vllm.api.runpod.ai" + ) + + # source fingerprint is NOT injected: code-only changes must not churn + # external-service endpoints + assert "_FLASH_SOURCE_FINGERPRINT" not in persisted["resources"]["vllm-server"].get( + "env", {} + ) + + +@pytest.mark.asyncio +async def test_client_mode_resource_reused_on_redeploy(tmp_path): + """Re-running deploy with unchanged config must not re-provision the + client-mode endpoint (no duplicate endpoints).""" + import json + + flash_dir = tmp_path / ".flash" + flash_dir.mkdir() + + client_config = { + "resource_type": "LiveServerless", + "client_mode": True, + "imageName": "runpod/vllm-openai:stable", + "gpuIds": "ADA_24", + "functions": [], + "env": {"MODEL_NAME": "m"}, + } + local_manifest = { + "resources": {"vllm-server": dict(client_config)}, + "resources_endpoints": {}, + } + (flash_dir / "flash_manifest.json").write_text(json.dumps(local_manifest)) + + state_manifest = { + "resources": { + "vllm-server": { + **client_config, + "endpoint_id": "endpoint-xyz", + } + }, + "resources_endpoints": { + "vllm-server": "https://vllm.api.runpod.ai", + }, + } + + app = AsyncMock() + app.get_build_manifest = AsyncMock(return_value=state_manifest) + app.update_build_manifest = AsyncMock() + + with ( + patch("pathlib.Path.cwd", return_value=tmp_path), + patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls, + patch( + "runpod_flash.cli.utils.deployment.create_resource_from_manifest" + ) as mock_create_resource, + ): + mock_manager = MagicMock() + mock_manager.get_or_deploy_resource = AsyncMock() + mock_manager_cls.return_value = mock_manager + + endpoints = await reconcile_and_provision_resources( + app, "build-456", "production", local_manifest, show_progress=False + ) + + # unchanged config + existing endpoint -> no provisioning, no duplicates + mock_create_resource.assert_not_called() + mock_manager.get_or_deploy_resource.assert_not_called() + + # endpoint info carried over from state + assert endpoints == {"vllm-server": "https://vllm.api.runpod.ai"} + assert local_manifest["resources"]["vllm-server"]["endpoint_id"] == "endpoint-xyz" + + +@pytest.mark.asyncio +async def test_source_fingerprint_skipped_for_client_mode_resources(tmp_path): + """Fingerprint injection drives rolling releases for code-running + resources; client-mode endpoints run a user image and are exempt.""" + import json + + flash_dir = tmp_path / ".flash" + flash_dir.mkdir() + + local_manifest = { + "source_fingerprint": "fingerprint_abc", + "resources": { + "worker": { + "resource_type": "LiveServerless", + "config": "same", + "env": {}, + }, + "vllm-server": { + "resource_type": "LiveServerless", + "client_mode": True, + "imageName": "runpod/vllm-openai:stable", + "config": "same", + }, + }, + "resources_endpoints": {}, + } + (flash_dir / "flash_manifest.json").write_text(json.dumps(local_manifest)) + + app = AsyncMock() + app.name = "my-app" + app.get_build_manifest = AsyncMock(return_value={}) + app.update_build_manifest = AsyncMock() + + deployed = MagicMock() + deployed.endpoint_url = "https://x.api.runpod.ai" + deployed.endpoint_id = "endpoint-id" + + with ( + patch("pathlib.Path.cwd", return_value=tmp_path), + patch("runpod_flash.cli.utils.deployment.ResourceManager") as mock_manager_cls, + patch( + "runpod_flash.cli.utils.deployment.create_resource_from_manifest" + ) as mock_create_resource, + ): + mock_manager = MagicMock() + mock_manager.get_or_deploy_resource = AsyncMock(return_value=deployed) + mock_manager_cls.return_value = mock_manager + mock_create_resource.return_value = MagicMock() + + await reconcile_and_provision_resources( + app, "build-123", "production", local_manifest, show_progress=False + ) + + worker_env = local_manifest["resources"]["worker"]["env"] + assert worker_env["_FLASH_SOURCE_FINGERPRINT"] == "fingerprint_abc" + + client_env = local_manifest["resources"]["vllm-server"].get("env", {}) + assert "_FLASH_SOURCE_FINGERPRINT" not in client_env From 5308d8ac05a501f0f6b5cfaa4bb57173d1e6dc09 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 25 Aug 2026 15:20:23 -0400 Subject: [PATCH 3/3] refactor: address code-quality review on client-mode deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest: extract _derive_path_fields; the file-path/url-prefix/module-path derivation (with outside-project-root and relative-path fallbacks) was duplicated between _add_client_endpoints and build() - scanner: flatten _collect_client_endpoints dedup to a sequential guard chain (no behavior change) and drop the dead `or ""` on member.name — Endpoint.__init__ guarantees a name when image= is set - scanner tests: pin the two explicitly-coded branches — a same-file alias of one object records once; two same-name endpoints in the same file resolve last-wins (unlike the cross-file raise) - handler_generator: read client_mode via the hasattr-first dual-shape pattern used by the adjacent is_load_balanced check - deploy: capitalize the empty-resources short-circuit message to match neighboring user-facing prints --- .../commands/build_utils/handler_generator.py | 6 +- .../cli/commands/build_utils/manifest.py | 76 ++++++++++--------- .../cli/commands/build_utils/scanner.py | 36 +++++---- src/runpod_flash/cli/commands/deploy.py | 2 +- .../build_utils/test_runtime_scanner.py | 41 ++++++++++ tests/unit/cli/test_deploy.py | 4 +- 6 files changed, 110 insertions(+), 55 deletions(-) diff --git a/src/runpod_flash/cli/commands/build_utils/handler_generator.py b/src/runpod_flash/cli/commands/build_utils/handler_generator.py index 50a13b9e6..8b65392f0 100644 --- a/src/runpod_flash/cli/commands/build_utils/handler_generator.py +++ b/src/runpod_flash/cli/commands/build_utils/handler_generator.py @@ -318,9 +318,9 @@ def generate_handlers(self) -> List[Path]: # Skip client-mode resources (image-based, external service): # they have no functions, so there is no handler to generate. client_mode = ( - resource_data.get("client_mode", False) - if isinstance(resource_data, dict) - else getattr(resource_data, "client_mode", False) + resource_data.client_mode + if hasattr(resource_data, "client_mode") + else resource_data.get("client_mode", False) ) if client_mode: continue diff --git a/src/runpod_flash/cli/commands/build_utils/manifest.py b/src/runpod_flash/cli/commands/build_utils/manifest.py index cfc0899d0..ae8598c10 100644 --- a/src/runpod_flash/cli/commands/build_utils/manifest.py +++ b/src/runpod_flash/cli/commands/build_utils/manifest.py @@ -282,6 +282,36 @@ def _extract_config_properties(config: Dict[str, Any], resource_config) -> None: return config + @staticmethod + def _derive_path_fields( + file_path: Optional[Path], project_root: Path, module_path: str + ) -> tuple[str, str, str]: + """Derive (file_path_str, local_path_prefix, resource_module_path). + + ``file_path`` is the source file a resource was declared in. When it + lives under ``project_root`` the fields describe the project-relative + location; otherwise (outside the root, or the path does not exist, + e.g. relative test paths) the raw path string is kept and the URL + prefix is derived from the module path instead. + """ + file_path_str = "" + local_path_prefix = "" + resource_module_path = module_path + if file_path and file_path.exists(): + try: + file_path_str = str(file_path.relative_to(project_root)) + local_path_prefix = file_to_url_prefix(file_path, project_root) + resource_module_path = file_to_module_path(file_path, project_root) + except ValueError: + # File is outside project root — fall back to module_path + file_path_str = str(file_path) + local_path_prefix = "/" + module_path.replace(".", "/") + elif file_path: + # File path may be relative (in test scenarios) + file_path_str = str(file_path) + local_path_prefix = "/" + module_path.replace(".", "/") + return file_path_str, local_path_prefix, resource_module_path + def _add_client_endpoints( self, resources_dict: Dict[str, Dict[str, Any]], project_root: Path ) -> None: @@ -309,23 +339,12 @@ def _add_client_endpoints( ) info = client_endpoints[name] - file_path = info.get("file_path") - file_path_str = "" - local_path_prefix = "" - resource_module_path = info.get("module_path", "") - if file_path and file_path.exists(): - try: - file_path_str = str(file_path.relative_to(project_root)) - local_path_prefix = file_to_url_prefix(file_path, project_root) - resource_module_path = file_to_module_path(file_path, project_root) - except ValueError: - # File is outside project root — fall back to module_path - file_path_str = str(file_path) - local_path_prefix = "/" + resource_module_path.replace(".", "/") - elif file_path: - file_path_str = str(file_path) - local_path_prefix = "/" + resource_module_path.replace(".", "/") + file_path_str, local_path_prefix, resource_module_path = ( + self._derive_path_fields( + info.get("file_path"), project_root, info.get("module_path", "") + ) + ) deployment_config, resource_type = self._extract_client_deployment_config( name, info @@ -508,24 +527,13 @@ def build(self) -> Dict[str, Any]: # Derive path fields from the first function's source file. # All functions in a resource share the same source file per convention. - first_file = functions[0].file_path if functions else None - file_path_str = "" - local_path_prefix = "" - resource_module_path = functions[0].module_path if functions else "" - - if first_file and first_file.exists(): - try: - file_path_str = str(first_file.relative_to(project_root)) - local_path_prefix = file_to_url_prefix(first_file, project_root) - resource_module_path = file_to_module_path(first_file, project_root) - except ValueError: - # File is outside project root — fall back to module_path - file_path_str = str(first_file) - local_path_prefix = "/" + functions[0].module_path.replace(".", "/") - elif first_file: - # File path may be relative (in test scenarios) - file_path_str = str(first_file) - local_path_prefix = "/" + functions[0].module_path.replace(".", "/") + file_path_str, local_path_prefix, resource_module_path = ( + self._derive_path_fields( + functions[0].file_path if functions else None, + project_root, + functions[0].module_path if functions else "", + ) + ) # Validate and collect routing for LB endpoints resource_routes = {} diff --git a/src/runpod_flash/cli/commands/build_utils/scanner.py b/src/runpod_flash/cli/commands/build_utils/scanner.py index 761fc6261..c9a383b7c 100644 --- a/src/runpod_flash/cli/commands/build_utils/scanner.py +++ b/src/runpod_flash/cli/commands/build_utils/scanner.py @@ -639,28 +639,34 @@ def _collect_client_endpoints( or member.id is not None ): continue - name = member.name or "" + # image-based endpoints always carry a name: Endpoint.__init__ + # raises when name is None and image is set. + name = member.name existing = self.client_endpoints.get(name) - if existing is not None: - if existing["object_id"] == id(member): - continue # aliased under a second variable name - if existing["file_path"] != file_path: - if existing["image"] == member.image: - # re-exported from another file; keep the first record - continue - raise ValueError( - f"endpoint '{name}' is defined in multiple files: " - f"{existing['file_path']}, {file_path}. " - f"each endpoint name must be unique across the project." - ) - # two distinct objects with the same name in one file: last wins - self.client_endpoints[name] = { + record = { "var_name": member_name, "file_path": file_path, "module_path": module_path, "object_id": id(member), "image": member.image, } + if existing is None: + self.client_endpoints[name] = record + continue + if existing["object_id"] == id(member): + continue # aliased under a second variable name + if existing["file_path"] == file_path: + # two distinct objects with the same name in one file: last wins + self.client_endpoints[name] = record + continue + if existing["image"] == member.image: + # re-exported from another file; keep the first record + continue + raise ValueError( + f"endpoint '{name}' is defined in multiple files: " + f"{existing['file_path']}, {file_path}. " + f"each endpoint name must be unique across the project." + ) def _populate_resource_dicts(self, functions: List[RemoteFunctionMetadata]) -> None: """populate resource tracking dicts for ManifestBuilder compatibility.""" diff --git a/src/runpod_flash/cli/commands/deploy.py b/src/runpod_flash/cli/commands/deploy.py index f8dc201a6..73939ae5a 100644 --- a/src/runpod_flash/cli/commands/deploy.py +++ b/src/runpod_flash/cli/commands/deploy.py @@ -225,7 +225,7 @@ async def _resolve_and_deploy( # manifest). Nothing is provisioned here, so don't claim a deployment. if not local_manifest.get("resources"): console.print( - "[yellow]no deployable resources found; nothing was deployed[/yellow]" + "[yellow]No deployable resources found; nothing was deployed[/yellow]" ) return diff --git a/tests/unit/cli/commands/build_utils/test_runtime_scanner.py b/tests/unit/cli/commands/build_utils/test_runtime_scanner.py index ec26fe7f3..10b7f3dab 100644 --- a/tests/unit/cli/commands/build_utils/test_runtime_scanner.py +++ b/tests/unit/cli/commands/build_utils/test_runtime_scanner.py @@ -914,3 +914,44 @@ def test_same_name_in_two_files_raises(self, tmp_path): scanner = RuntimeScanner(tmp_path) with pytest.raises(ValueError, match="defined in multiple files"): scanner.discover_remote_functions() + + def test_same_file_alias_records_once(self, tmp_path): + """``b = vllm`` exposes one object under two names; it is recorded once.""" + _write_worker( + tmp_path, + "aliases.py", + """\ + from runpod_flash import Endpoint + + vllm = Endpoint(name="vllm-server", image="img:v1") + b = vllm + """, + ) + scanner = RuntimeScanner(tmp_path) + scanner.discover_remote_functions() + assert len(scanner.client_endpoints) == 1 + assert scanner.client_endpoints["vllm-server"]["image"] == "img:v1" + + def test_same_name_same_file_last_wins(self, tmp_path): + """two distinct same-name endpoints in ONE file: last wins, no raise. + + Deliberately asymmetric with the cross-file raise: across files a + duplicate name is ambiguous, but within one module dir() iteration is + deterministic, so pinning the resolution is cheap and explicit. + """ + _write_worker( + tmp_path, + "dups.py", + """\ + from runpod_flash import Endpoint + + a = Endpoint(name="dup", image="img:v1") + z = Endpoint(name="dup", image="img:v2") + """, + ) + scanner = RuntimeScanner(tmp_path) + scanner.discover_remote_functions() + assert len(scanner.client_endpoints) == 1 + # dir() iterates alphabetically, so z is seen last and overwrites a + assert scanner.client_endpoints["dup"]["var_name"] == "z" + assert scanner.client_endpoints["dup"]["image"] == "img:v2" diff --git a/tests/unit/cli/test_deploy.py b/tests/unit/cli/test_deploy.py index 5ca8e7018..fd20a5211 100644 --- a/tests/unit/cli/test_deploy.py +++ b/tests/unit/cli/test_deploy.py @@ -511,7 +511,7 @@ def test_deploy_empty_resources_reports_no_deployment( str(call.args[0]) if call.args else "" for call in patched_console.print.call_args_list ) - assert "no deployable resources found" in printed_output + assert "No deployable resources found" in printed_output assert "deployed to" not in printed_output # Nothing should be uploaded or provisioned mock_from_name.assert_not_awaited() @@ -597,7 +597,7 @@ def test_deploy_provisions_client_mode_endpoints( for call in patched_console.print.call_args_list ) assert "deployed to" in printed_output - assert "no deployable resources found" not in printed_output + assert "No deployable resources found" not in printed_output # post-deploy guidance lists the provisioned endpoint assert "vllm-server" in printed_output assert "https://vllm-xyz.api.runpod.ai/runsync" in printed_output