diff --git a/src/runpod_flash/cli/main.py b/src/runpod_flash/cli/main.py index 4fdd1b8f..1c37b76f 100644 --- a/src/runpod_flash/cli/main.py +++ b/src/runpod_flash/cli/main.py @@ -17,7 +17,6 @@ update, ) from .update_checker import start_background_check -from ..core.cli_context import mark_cli_invocation def get_version() -> str: @@ -79,11 +78,6 @@ def main( version: bool = typer.Option(False, "--version", "-v", help="Show version"), ): """Runpod Flash CLI - Distributed inference and serving framework.""" - # Mark this process as a CLI invocation so resource lifecycle operations - # (deploy/undeploy/app/env management) are permitted. Direct SDK calls - # outside the CLI raise FlashUsageError. See core.cli_context. - mark_cli_invocation() - if version: console.print(f"Runpod Flash CLI v{get_version()}") raise typer.Exit() diff --git a/src/runpod_flash/core/cli_context.py b/src/runpod_flash/core/cli_context.py deleted file mode 100644 index df61bd4a..00000000 --- a/src/runpod_flash/core/cli_context.py +++ /dev/null @@ -1,97 +0,0 @@ -"""CLI-invocation context for guarding lifecycle operations. - -Endpoint/app lifecycle operations (deploy, undeploy, update, app/environment -management) are managed by the flash CLI, which orchestrates the build/manifest -pipeline and records local state. Calling those methods directly from the SDK -skips that orchestration and leaves state inconsistent. - -This module provides a process-scoped flag, set once at the CLI entry point, and -a `cli_only` decorator that raises :class:`FlashUsageError` when a guarded method -is called outside that context. `allow_lifecycle_operations` is the sanctioned -escape hatch for first-party code (e.g. tests) that must drive lifecycle without -going through the CLI process. - -Why a ContextVar and not an env var: it propagates automatically into the -asyncio task spawned by the CLI command (`asyncio.run` copies the current -context), needs no cleanup in a one-shot CLI process, and is trivially scoped in -tests via `allow_lifecycle_operations`. -""" - -import functools -from collections.abc import Awaitable, Callable -from contextlib import contextmanager -from contextvars import ContextVar -from typing import TypeVar - -from .exceptions import FlashUsageError - -_invoked_by_cli: ContextVar[bool] = ContextVar("flash_invoked_by_cli", default=False) - -_T = TypeVar("_T") - - -def mark_cli_invocation() -> None: - """Mark the current context as a flash CLI invocation. - - Called once from the CLI entry point. Set-and-leave: a Typer callback returns - before the command runs, so a context-manager scope cannot wrap the command. - A plain set is correct for a one-shot CLI process and propagates into the - command's ``asyncio.run()`` context. - """ - _invoked_by_cli.set(True) - - -def is_cli_invocation() -> bool: - """Return whether lifecycle operations are currently permitted.""" - return _invoked_by_cli.get() - - -@contextmanager -def allow_lifecycle_operations(): - """Permit guarded lifecycle operations within this block. - - Sanctioned escape hatch for first-party code that must drive resource - lifecycle without going through the CLI process (notably tests). Restores the - previous state on exit. - - Example: - with allow_lifecycle_operations(): - await endpoint.deploy() - """ - token = _invoked_by_cli.set(True) - try: - yield - finally: - _invoked_by_cli.reset(token) - - -def cli_only( - cli_command: str, -) -> Callable[[Callable[..., Awaitable[_T]]], Callable[..., Awaitable[_T]]]: - """Restrict an async lifecycle method to flash CLI invocation. - - Args: - cli_command: The equivalent flash CLI command, surfaced in the error - (e.g. ``"flash deploy"``). - - Raises: - FlashUsageError: When the decorated method is called outside a CLI - context or an :func:`allow_lifecycle_operations` block. - """ - - def decorator(func: Callable[..., Awaitable[_T]]) -> Callable[..., Awaitable[_T]]: - @functools.wraps(func) - async def wrapper(*args: object, **kwargs: object) -> _T: - if not _invoked_by_cli.get(): - raise FlashUsageError( - f"{func.__qualname__}() is a CLI-managed operation and cannot " - f"be called directly from the SDK.\n\n" - f" Use: {cli_command}\n\n" - "Direct SDK calls bypass flash's build/manifest pipeline and " - "local state tracking, leaving deployments inconsistent." - ) - return await func(*args, **kwargs) - - return wrapper - - return decorator diff --git a/src/runpod_flash/core/exceptions.py b/src/runpod_flash/core/exceptions.py index e17b1707..2de78944 100644 --- a/src/runpod_flash/core/exceptions.py +++ b/src/runpod_flash/core/exceptions.py @@ -4,24 +4,7 @@ """ -class FlashError(Exception): - """Base class for all runpod_flash domain errors. - - Catch this to handle any flash-raised error; catch a subclass for a - specific failure mode. - """ - - -class FlashUsageError(FlashError): - """Raised when the SDK is used in a way that is not supported. - - Currently raised when a CLI-managed lifecycle operation (deploy, undeploy, - app/environment management) is invoked directly from the SDK instead of - through the flash CLI. The message names the equivalent CLI command. - """ - - -class RunpodAPIKeyError(FlashError): +class RunpodAPIKeyError(Exception): """Raised when RUNPOD_API_KEY environment variable is missing or invalid. This exception provides helpful guidance on how to obtain and configure diff --git a/src/runpod_flash/core/resources/app.py b/src/runpod_flash/core/resources/app.py index 646b9614..1a69a342 100644 --- a/src/runpod_flash/core/resources/app.py +++ b/src/runpod_flash/core/resources/app.py @@ -5,7 +5,6 @@ import logging from ..api.runpod import RunpodGraphQLClient -from ..cli_context import cli_only from .constants import ( TARBALL_CONTENT_TYPE, @@ -326,7 +325,6 @@ async def _hydrate(self) -> None: self._hydrated = True return - @cli_only("flash env create") async def create_environment(self, environment_name: str) -> Dict[str, Any]: """Create an environment within an app. @@ -364,7 +362,6 @@ async def _get_tarball_upload_url(self, tarball_size: int) -> Dict[str, str]: {"flashAppId": self.id, "tarballSize": tarball_size} ) - @cli_only("flash deploy") async def deploy_build_to_environment( self, build_id: str, @@ -497,14 +494,12 @@ async def from_name(cls, app_name: str) -> "FlashApp": return cls(app_name, id=result["id"], eager_hydrate=False) @classmethod - @cli_only("flash app create") async def create(cls, app_name: str) -> "FlashApp": async with RunpodGraphQLClient() as client: result = await client.create_flash_app({"name": app_name}) return cls(app_name, id=result["id"], eager_hydrate=False) @classmethod - @cli_only("flash app create") async def get_or_create(cls, app_name: str) -> "FlashApp": try: return await cls.from_name(app_name) @@ -514,7 +509,6 @@ async def get_or_create(cls, app_name: str) -> "FlashApp": return cls(app_name, id=result["id"], eager_hydrate=False) @classmethod - @cli_only("flash app create") async def create_environment_and_app( cls, app_name: str, environment_name: str ) -> Tuple["FlashApp", Dict]: @@ -528,7 +522,6 @@ async def list(cls): return await client.list_flash_apps() @classmethod - @cli_only("flash app delete") async def delete( cls, app_name: Optional[str] = None, app_id: Optional[str] = None ) -> bool: @@ -547,7 +540,6 @@ async def delete( result = await client.delete_flash_app(app_id) return result.get("success", False) - @cli_only("flash env delete") async def delete_environment(self, environment_name: str) -> bool: """Delete an environment from this flash app. diff --git a/src/runpod_flash/core/resources/load_balancer_sls_resource.py b/src/runpod_flash/core/resources/load_balancer_sls_resource.py index 8a6feb65..aa23604c 100644 --- a/src/runpod_flash/core/resources/load_balancer_sls_resource.py +++ b/src/runpod_flash/core/resources/load_balancer_sls_resource.py @@ -45,8 +45,7 @@ class LoadBalancerSlsResource(ServerlessResource): workersMin=1, workersMax=3, ) - - Deploy via the CLI (`flash deploy`); SDK lifecycle calls are CLI-only. + await lb.deploy() """ # Override default type to LB @@ -210,8 +209,7 @@ class CpuLoadBalancerSlsResource(CpuEndpointMixin, LoadBalancerSlsResource): workersMin=1, workersMax=3, ) - - Deploy via the CLI (`flash deploy`); SDK lifecycle calls are CLI-only. + await cpu_lb.deploy() """ instanceIds: Optional[List[CpuInstanceType]] = [CpuInstanceType.CPU3G_2_8] diff --git a/src/runpod_flash/core/resources/network_volume.py b/src/runpod_flash/core/resources/network_volume.py index c062a858..6b994cd2 100644 --- a/src/runpod_flash/core/resources/network_volume.py +++ b/src/runpod_flash/core/resources/network_volume.py @@ -15,7 +15,6 @@ from ..urls import RUNPOD_CONSOLE_URL from .base import DeployableResource from .resource_manager import ResourceManager -from ..cli_context import cli_only log = logging.getLogger(__name__) @@ -215,7 +214,6 @@ async def _do_deploy(self) -> "DeployableResource": log.error(f"{self} failed to deploy: {e}") raise - @cli_only("flash deploy") async def deploy(self) -> "DeployableResource": resource_manager = ResourceManager() resource = await resource_manager.get_or_deploy_resource(self) diff --git a/src/runpod_flash/core/resources/serverless.py b/src/runpod_flash/core/resources/serverless.py index 1de40c62..3dd46e01 100644 --- a/src/runpod_flash/core/resources/serverless.py +++ b/src/runpod_flash/core/resources/serverless.py @@ -20,7 +20,6 @@ from runpod.endpoint.runner import Job from ..api.runpod import RunpodGraphQLClient -from ..cli_context import cli_only from ..exceptions import RunpodAPIKeyError from ..utils.backoff import get_backoff_delay from .base import DeployableResource @@ -1115,7 +1114,6 @@ async def _do_deploy(self) -> "DeployableResource": log.error(f"{self} failed to deploy: {e}") raise - @cli_only("flash deploy") async def update(self, new_config: "ServerlessResource") -> "ServerlessResource": """Update existing endpoint with new configuration. @@ -1319,7 +1317,6 @@ def _has_structural_changes(self, new_config: "ServerlessResource") -> bool: return False - @cli_only("flash deploy") async def deploy(self) -> "DeployableResource": resource_manager = ResourceManager() resource = await resource_manager.get_or_deploy_resource(self) @@ -1372,7 +1369,6 @@ async def _do_undeploy(self) -> bool: return False - @cli_only("flash undeploy") async def undeploy(self) -> Dict[str, Any]: resource_manager = ResourceManager() result = await resource_manager.undeploy_resource(self.resource_id) diff --git a/tests/conftest.py b/tests/conftest.py index c0327b96..84387629 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,6 @@ import pytest -from runpod_flash.core.cli_context import _invoked_by_cli, allow_lifecycle_operations from runpod_flash.core.resources.resource_manager import ResourceManager from runpod_flash.core.utils.singleton import SingletonMixin @@ -30,36 +29,6 @@ def pytest_configure(config): """ # This hook is called early in pytest initialization # xdist will check for this during test distribution - config.addinivalue_line( - "markers", - "no_cli_context: run with the flash CLI context disabled so guarded " - "lifecycle methods raise FlashUsageError (used by guard tests).", - ) - - -@pytest.fixture(autouse=True) -def _flash_cli_context(request): - """Permit CLI-managed lifecycle operations during tests. - - Lifecycle methods (deploy/undeploy/app/env management) are restricted to - flash CLI invocation. Most tests drive them directly, so enable the context - by default. Guard tests opt out with @pytest.mark.no_cli_context. - - The opt-out branch actively forces the context to False (rather than merely - skipping the enable) and restores it afterwards. ``mark_cli_invocation()`` is - set-and-leave, so a CLI test that ran earlier in the same worker could leave - the ContextVar True; forcing False here makes guard tests independent of test - order instead of relying on each guard module to reset the var itself. - """ - if request.node.get_closest_marker("no_cli_context"): - token = _invoked_by_cli.set(False) - try: - yield - finally: - _invoked_by_cli.reset(token) - else: - with allow_lifecycle_operations(): - yield def pytest_collection_modifyitems(config, items): diff --git a/tests/unit/core/test_cli_context.py b/tests/unit/core/test_cli_context.py deleted file mode 100644 index f93e8165..00000000 --- a/tests/unit/core/test_cli_context.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for the CLI-invocation guard on lifecycle operations. - -These tests run with the flash CLI context DISABLED (@pytest.mark.no_cli_context) -so that guarded methods raise FlashUsageError, mirroring direct SDK use outside -the CLI. The autouse fixture in conftest enables the context for all other tests. -""" - -import pytest - -from runpod_flash.core.cli_context import ( - allow_lifecycle_operations, - cli_only, - is_cli_invocation, - mark_cli_invocation, -) -from runpod_flash.core.exceptions import FlashError, FlashUsageError - -# The conftest ``_flash_cli_context`` fixture forces the CLI context to False for -# every no_cli_context test and restores it afterwards, so these tests start from -# a clean default regardless of order. -pytestmark = pytest.mark.no_cli_context - - -@cli_only("flash deploy") -async def _guarded(value: int) -> int: - return value * 2 - - -class TestCliOnlyDecorator: - async def test_raises_outside_cli_context(self): - with pytest.raises(FlashUsageError): - await _guarded(21) - - async def test_runs_inside_allow_block(self): - with allow_lifecycle_operations(): - assert await _guarded(21) == 42 - - async def test_runs_after_mark_cli_invocation(self): - mark_cli_invocation() - assert await _guarded(21) == 42 - - async def test_message_names_method_and_cli_command(self): - with pytest.raises(FlashUsageError) as exc: - await _guarded(1) - message = str(exc.value) - assert "_guarded" in message - assert "flash deploy" in message - - async def test_flash_usage_error_is_flash_error(self): - assert issubclass(FlashUsageError, FlashError) - - -class TestRealMethodsGuarded: - """The guard runs before the method body, so a dummy ``self`` is enough to - prove each real lifecycle method is decorated without constructing resources. - """ - - async def test_serverless_lifecycle_methods_guarded(self): - from runpod_flash.core.resources.serverless import ServerlessResource - - with pytest.raises(FlashUsageError): - await ServerlessResource.deploy(object()) - with pytest.raises(FlashUsageError): - await ServerlessResource.undeploy(object()) - with pytest.raises(FlashUsageError): - await ServerlessResource.update(object(), object()) - - async def test_network_volume_deploy_guarded(self): - from runpod_flash.core.resources.network_volume import NetworkVolume - - with pytest.raises(FlashUsageError): - await NetworkVolume.deploy(object()) - - async def test_flash_app_classmethods_guarded(self): - from runpod_flash.core.resources.app import FlashApp - - with pytest.raises(FlashUsageError): - await FlashApp.create("app") - with pytest.raises(FlashUsageError): - await FlashApp.get_or_create("app") - with pytest.raises(FlashUsageError): - await FlashApp.create_environment_and_app("app", "env") - with pytest.raises(FlashUsageError): - await FlashApp.delete(app_name="app") - - async def test_flash_app_instance_methods_guarded(self): - from runpod_flash.core.resources.app import FlashApp - - with pytest.raises(FlashUsageError): - await FlashApp.create_environment(object(), "env") - with pytest.raises(FlashUsageError): - await FlashApp.delete_environment(object(), "env") - with pytest.raises(FlashUsageError): - await FlashApp.deploy_build_to_environment(object(), "build-id") - - -class TestContextHelpers: - def test_default_is_not_cli(self): - assert is_cli_invocation() is False - - def test_allow_block_toggles_and_restores(self): - assert is_cli_invocation() is False - with allow_lifecycle_operations(): - assert is_cli_invocation() is True - assert is_cli_invocation() is False - - def test_nested_allow_blocks_restore_correctly(self): - with allow_lifecycle_operations(): - with allow_lifecycle_operations(): - assert is_cli_invocation() is True - assert is_cli_invocation() is True - assert is_cli_invocation() is False