diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..0dcfa1ac
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+# keep the runtime image build context to what pip install needs:
+# package source, metadata, and the files setup.py/MANIFEST.in read
+.git
+.github
+.venv
+build
+benchmark_results
+docs
+examples
+job_files
+scripts
+tests
+uv.lock
+**/__pycache__
+**/*.pyc
+*.egg-info
diff --git a/.github/workflows/runtimes.yml b/.github/workflows/runtimes.yml
new file mode 100644
index 00000000..64deccd5
--- /dev/null
+++ b/.github/workflows/runtimes.yml
@@ -0,0 +1,162 @@
+name: Runtimes
+
+# runtime images embed the runpod package, so they are published when a
+# release ships (release-please merge -> github release), not per pr.
+# the feature branch is a prerelease channel while the apps sdk is in
+# development; runtime tests for prs run in ci via the full test suite.
+on:
+ release:
+ types: [published]
+ push:
+ branches: [feat/apps-sdk]
+ paths:
+ - "runpod/runtimes/**"
+ - ".github/workflows/runtimes.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: runtimes-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ REGISTRY: docker.io
+ TAG_SUFFIX: latest
+
+jobs:
+ changes:
+ runs-on: ubuntu-latest
+ outputs:
+ gpu-base: ${{ steps.filter.outputs.gpu-base || 'true' }}
+ steps:
+ - uses: actions/checkout@v7
+
+ # branch pushes rebuild the multi-gb torch base only when its
+ # definition changed; releases and manual runs always rebuild so
+ # published images never lag the release content
+ - uses: dorny/paths-filter@v3
+ if: github.event_name == 'push'
+ id: filter
+ with:
+ filters: |
+ gpu-base:
+ - 'runpod/runtimes/gpu-base/**'
+ - '.github/workflows/runtimes.yml'
+
+ test-runner:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install test deps
+ run: |
+ pip install -e .
+ pip install pytest pytest-asyncio cloudpickle
+
+ - name: Run runtime tests
+ run: pytest tests/test_apps/test_tasks.py tests/test_apps/test_task_runner_http.py tests/test_apps/test_bootstrap.py -q -o addopts="" -p no:unraisableexception
+
+ build-gpu-base:
+ runs-on: ubuntu-latest
+ needs: [changes, test-runner]
+ if: needs.changes.outputs.gpu-base == 'true'
+ strategy:
+ # a transient registry/mirror failure on one leg must not cancel
+ # the other python versions
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Clear space
+ run: |
+ sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
+ docker system prune -af
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Build and push gpu base image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: runpod/runtimes/gpu-base/Dockerfile
+ platforms: linux/amd64
+ build-args: |
+ PYTHON_VERSION=${{ matrix.python-version }}
+ push: true
+ tags: |
+ runpod/gpu-base:py${{ matrix.python-version }}-${{ env.TAG_SUFFIX }}
+ cache-from: type=gha,scope=gpu-base-py${{ matrix.python-version }}
+ cache-to: type=gha,mode=max,scope=gpu-base-py${{ matrix.python-version }}
+
+ build-runtimes:
+ runs-on: ubuntu-latest
+ # gpu legs build FROM the registry's gpu-base tag, so a skipped or
+ # failed gpu-base rebuild (e.g. torch mirror outage) must not block
+ # runtime images: the previously published base keeps working
+ needs: [test-runner, build-gpu-base]
+ if: always() && needs.test-runner.result == 'success'
+ strategy:
+ fail-fast: false
+ matrix:
+ kind: [task, queue, api]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ accelerator: [cpu, gpu]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Clear space
+ if: matrix.accelerator == 'gpu'
+ run: |
+ sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/share/boost "$AGENT_TOOLSDIRECTORY"
+ docker system prune -af
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Compute image names
+ id: names
+ run: |
+ if [ "${{ matrix.accelerator }}" = "gpu" ]; then
+ echo "base=runpod/gpu-base:py${{ matrix.python-version }}-${{ env.TAG_SUFFIX }}" >> "$GITHUB_OUTPUT"
+ repo="runpod/${{ matrix.kind }}-gpu"
+ else
+ echo "base=python:${{ matrix.python-version }}-slim" >> "$GITHUB_OUTPUT"
+ repo="runpod/${{ matrix.kind }}"
+ fi
+ tags="$repo:py${{ matrix.python-version }}-${{ env.TAG_SUFFIX }}"
+ # releases also pin a version tag for rollback
+ if [ "${{ github.event_name }}" = "release" ]; then
+ tags="$tags,$repo:py${{ matrix.python-version }}-${{ github.event.release.tag_name }}"
+ fi
+ echo "tags=$tags" >> "$GITHUB_OUTPUT"
+
+ - name: Build and push runtime image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: runpod/runtimes/${{ matrix.kind }}/Dockerfile
+ platforms: linux/amd64
+ build-args: |
+ BASE_IMAGE=${{ steps.names.outputs.base }}
+ push: true
+ tags: ${{ steps.names.outputs.tags }}
+ cache-from: type=gha,scope=${{ matrix.kind }}-${{ matrix.accelerator }}-py${{ matrix.python-version }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.kind }}-${{ matrix.accelerator }}-py${{ matrix.python-version }}
diff --git a/README.md b/README.md
index b34014b1..47efd626 100644
--- a/README.md
+++ b/README.md
@@ -1,315 +1,89 @@
-
Runpod | Python Library
-[](https://badge.fury.io/py/runpod)
-
-[](https://pepy.tech/project/runpod)
+Runpod Python
-[](https://github.com/runpod/runpod-python/actions/workflows/CI-e2e.yml)
+**Define GPU functions in Python. Run them in the cloud with one decorator.**
+[](https://badge.fury.io/py/runpod)
+[](https://pepy.tech/project/runpod)
[](https://github.com/runpod/runpod-python/actions/workflows/CI-pytests.yml)
-
-[](https://github.com/runpod/runpod-python/actions/workflows/CI-codeql.yml)
-
-
-
-Welcome to the official Python library for Runpod API & SDK.
-
-## Table of Contents
-
-- [Table of Contents](#table-of-contents)
-- [💻 | Installation](#--installation)
-- [⚡ | Serverless Worker (SDK)](#--serverless-worker-sdk)
- - [Quick Start](#quick-start)
- - [Local Test Worker](#local-test-worker)
-- [📚 | API Language Library (GraphQL Wrapper)](#--api-language-library-graphql-wrapper)
- - [Endpoints](#endpoints)
- - [GPU Cloud (Pods)](#gpu-cloud-pods)
-- [📁 | Directory](#--directory)
-- [🤝 | Community and Contributing](#--community-and-contributing)
-
-## 💻 | Installation
-
-### Install from PyPI (Stable Release)
-
-```bash
-# Install with pip
-pip install runpod
-
-# Install with uv (faster alternative)
-uv add runpod
-```
-
-### Install from GitHub (Latest Changes)
-
-To get the latest changes that haven't been released to PyPI yet:
-
-```bash
-# Install latest development version from main branch with pip
-pip install git+https://github.com/runpod/runpod-python.git
-
-# Install with uv
-uv add git+https://github.com/runpod/runpod-python.git
-
-# Install a specific branch
-pip install git+https://github.com/runpod/runpod-python.git@branch-name
-
-# Install a specific tag/release
-pip install git+https://github.com/runpod/runpod-python.git@v1.0.0
-
-# Install in editable mode for development
-git clone https://github.com/runpod/runpod-python.git
-cd runpod-python
-pip install -e .
-```
-
-*Python 3.10 or higher is required to use the latest version of this package.*
-
-## ⚡ | Serverless Worker (SDK)
-
-This python package can also be used to create a serverless worker that can be deployed to Runpod as a custom endpoint API.
+[](LICENSE)
-### Quick Start
+[Documentation](https://docs.runpod.io) • [Examples](examples/apps) • [Discord](https://discord.gg/pJ3P2DbUUq)
-Create a python script in your project that contains your model definition and the Runpod worker start code. Run this python code as your default container start command:
-
-```python
-# my_worker.py
-
-import runpod
-
-def is_even(job):
-
- job_input = job["input"]
- the_number = job_input["number"]
-
- if not isinstance(the_number, int):
- return {"error": "Silly human, you need to pass an integer."}
-
- if the_number % 2 == 0:
- return True
-
- return False
-
-runpod.serverless.start({"handler": is_even})
-```
-
-Make sure that this file is ran when your container starts. This can be accomplished by calling it in the docker command when you set up a template at [console.runpod.io/serverless/user/templates](https://console.runpod.io/serverless/user/templates) or by setting it as the default command in your Dockerfile.
-
-See our [blog post](https://www.runpod.io/blog/build-basic-serverless-api) for creating a basic Serverless API, or view the [details docs](https://docs.runpod.io/serverless-ai/custom-apis) for more information.
-
-### Local Test Worker
+
-You can also test your worker locally before deploying it to Runpod. This is useful for debugging and testing.
+## Installation
```bash
-python my_worker.py --rp_serve_api
+pip install runpod # or: uv add runpod
+rp login # authenticate once
```
-### Worker Fitness Checks
+Requires Python 3.10+. Installing the package also installs the `rp` CLI.
-Fitness checks allow you to validate your worker environment at startup before processing jobs. If any check fails, the worker exits immediately, allowing your orchestrator to restart it.
+## Example
```python
-# my_worker.py
-
import runpod
-import torch
-
-# Register fitness checks using the decorator
-@runpod.serverless.register_fitness_check
-def check_gpu_available():
- """Verify GPU is available."""
- if not torch.cuda.is_available():
- raise RuntimeError("GPU not available")
-
-@runpod.serverless.register_fitness_check
-def check_disk_space():
- """Verify sufficient disk space."""
- import shutil
- stat = shutil.disk_usage("/")
- free_gb = stat.free / (1024**3)
- if free_gb < 10:
- raise RuntimeError(f"Insufficient disk space: {free_gb:.2f}GB free")
-
-def handler(job):
- job_input = job["input"]
- # Your handler code here
- return {"output": "success"}
-
-# Fitness checks run before handler initialization (production only)
-runpod.serverless.start({"handler": handler})
-```
+from runpod import App, Model, Secret, Volume
-**Key Features:**
-- Supports both synchronous and asynchronous check functions
-- Checks run only once at worker startup (production mode)
-- Runs before handler initialization and job processing begins
-- Any check failure exits with code 1 (worker marked unhealthy)
+app = App("inference")
-See [Worker Fitness Checks](https://github.com/runpod/runpod-python/blob/main/docs/serverless/worker_fitness_checks.md) documentation for more examples and best practices.
+models = Volume("models", size=100)
+llama = Model("meta-llama/Llama-3.1-8B-Instruct")
-## 📚 | API Language Library (GraphQL Wrapper)
-
-When interacting with the Runpod API you can use this library to make requests to the API.
-
-```python
-import runpod
-
-runpod.api_key = "your_runpod_api_key_found_under_settings"
-```
-### Endpoints
-
-You can interact with Runpod endpoints via a `run` or `run_sync` method.
-
-#### Basic Usage
-
-```python
-endpoint = runpod.Endpoint("ENDPOINT_ID")
-
-run_request = endpoint.run(
- {"your_model_input_key": "your_model_input_value"}
+# an autoscaling job queue on cloud H100s: weights pre-cached,
+# dependencies vendored at deploy time, scale-to-zero when idle
+@app.queue(
+ gpu="H100",
+ workers=(0, 3),
+ dependencies=["vllm"],
+ volume=models,
+ model=llama,
+ env={"HF_TOKEN": Secret("hf-token")},
)
+def chat(prompt: str):
+ import vllm
-# Check the status of the endpoint run request
-print(run_request.status())
+ llm = vllm.LLM(model=str(llama.path)) # weights already on disk
+ return llm.generate(prompt)
-# Get the output of the endpoint run request, blocking until the endpoint run is complete.
-print(run_request.output())
-```
-```python
-endpoint = runpod.Endpoint("ENDPOINT_ID")
+# one ephemeral pod per call: provisions, runs to completion, terminates
+@app.task(gpu="H100", gpu_count=2, volume=models)
+def finetune(steps: int = 1000):
+ ...
+ return {"loss": final_loss}
-run_request = endpoint.run_sync(
- {"your_model_input_key": "your_model_input_value"}
-)
-# Returns the job results if completed within 90 seconds, otherwise, returns the job status.
-print(run_request )
+@runpod.local_entrypoint
+def main():
+ print(chat.remote("why is the sky blue?")) # blocks for the result
+ job = finetune.spawn(steps=500) # fire and forget -> Job
```
-#### API Key Management
-
-The SDK supports multiple ways to set API keys:
-
-**1. Global API Key** (Default)
-```python
-import runpod
-
-# Set global API key
-runpod.api_key = "your_runpod_api_key"
-
-# All endpoints will use this key by default
-endpoint = runpod.Endpoint("ENDPOINT_ID")
-result = endpoint.run_sync({"input": "data"})
-```
-
-**2. Endpoint-Specific API Key**
-```python
-# Create endpoint with its own API key
-endpoint = runpod.Endpoint("ENDPOINT_ID", api_key="specific_api_key")
-
-# This endpoint will always use the provided API key
-result = endpoint.run_sync({"input": "data"})
-```
-
-#### API Key Precedence
-
-The SDK uses this precedence order (highest to lowest):
-1. Endpoint instance API key (if provided to `Endpoint()`)
-2. Global API key (set via `runpod.api_key`)
-
-```python
-import runpod
-
-# Example showing precedence
-runpod.api_key = "GLOBAL_KEY"
-
-# This endpoint uses GLOBAL_KEY
-endpoint1 = runpod.Endpoint("ENDPOINT_ID")
-
-# This endpoint uses ENDPOINT_KEY (overrides global)
-endpoint2 = runpod.Endpoint("ENDPOINT_ID", api_key="ENDPOINT_KEY")
-
-# All requests from endpoint2 will use ENDPOINT_KEY
-result = endpoint2.run_sync({"input": "data"})
-```
-
-#### Thread-Safe Operations
-
-Each `Endpoint` instance maintains its own API key, making concurrent operations safe:
-
-```python
-import threading
-import runpod
-
-def process_request(api_key, endpoint_id, input_data):
- # Each thread gets its own Endpoint instance
- endpoint = runpod.Endpoint(endpoint_id, api_key=api_key)
- return endpoint.run_sync(input_data)
-
-# Safe concurrent usage with different API keys
-threads = []
-for customer in customers:
- t = threading.Thread(
- target=process_request,
- args=(customer["api_key"], customer["endpoint_id"], customer["input"])
- )
- threads.append(t)
- t.start()
+```bash
+rp dev main.py # live dev session: edit, re-run, logs stream back
+rp deploy # deploy production endpoints
```
-### GPU Cloud (Pods)
-
-```python
-import runpod
-
-runpod.api_key = "your_runpod_api_key_found_under_settings"
-
-# Get all my pods
-pods = runpod.get_pods()
-
-# Get a specific pod
-pod = runpod.get_pod(pod.id)
-
-# Create a pod with GPU
-pod = runpod.create_pod("test", "runpod/stack", "NVIDIA GeForce RTX 3070")
+Functions keep their Python identity: `chat.remote(...)` runs in the cloud, `await chat.remote.aio(...)` is the async form, `chat.local(...)` runs in-process. See [`examples/apps`](examples/apps) for runnable examples and [docs.runpod.io](https://docs.runpod.io) for the full guide.
-# Create a pod with CPU
-pod = runpod.create_pod("test", "runpod/stack", instance_id="cpu3c-2-4")
+## Contributing
-# Stop the pod
-runpod.stop_pod(pod.id)
-
-# Resume the pod
-runpod.resume_pod(pod.id)
-
-# Terminate the pod
-runpod.terminate_pod(pod.id)
-```
+Pull requests and issues are welcome — see the [contributing guide](CONTRIBUTING.md) to get started.
-## 📁 | Directory
-
-```BASH
-.
-├── docs # Documentation
-├── examples # Examples
-├── runpod # Package source code
-│ ├── api_wrapper # Language library - API (GraphQL)
-│ ├── cli # Command Line Interface Functions
-│ ├── endpoint # Language library - Endpoints
-│ └── serverless # SDK - Serverless Worker
-└── tests # Package tests
+```bash
+git clone https://github.com/runpod/runpod-python.git
+cd runpod-python
+make setup
+make test
```
-## 🤝 | Community and Contributing
-
-We welcome both pull requests and issues on [GitHub](https://github.com/runpod/runpod-python). Bug fixes and new features are encouraged, but please read our [contributing guide](CONTRIBUTING.md) first.
-
diff --git a/docs/cli/references/command_line_interface.md b/docs/cli/references/command_line_interface.md
index d72567ac..e3b5232e 100644
--- a/docs/cli/references/command_line_interface.md
+++ b/docs/cli/references/command_line_interface.md
@@ -4,42 +4,30 @@ Note: This CLI is not the same as runpodctl and provides a different set of feat
```bash
# Auth
-runpod config
+rp login
-runpod ssh list-keys
-runpod ssh add-key
+# SSH
+rp ssh add # add a key to your account
+rp ssh list # list account keys
+rp ssh POD_ID # open a terminal on a pod
-runpod pod list
-runpod pod create
-runpod pod connect
-
-runpod exec python file.py
+# Pods
+rp pod list
+rp pod create
+rp pod connect POD_ID
```
## Overview
```bash
-runpod --help
-```
-
-### Configure
-
-```bash
-$ runpod config
-Profile [default]:
-Runpod API Key [None]: YOUR_RUNPOD_API_KEY
+rp --help
```
-### Launch Pod
+### Authenticate
```bash
-runpod launch --help
-runpod launch pod --template-file template.yaml
+rp login # browser approval
+rp login --api-key YOUR_KEY # store a key directly
```
-### Launch Endpoint
-
-```bash
-runpod launch endpoint --help
-runpod launch endpoint --template-file template.yaml
-```
+Credentials are stored in `~/.runpod/config.toml`.
diff --git a/docs/cli/start_here.md b/docs/cli/start_here.md
index 5ee16c91..99902938 100644
--- a/docs/cli/start_here.md
+++ b/docs/cli/start_here.md
@@ -1,23 +1,21 @@
-# [BETA] | Runpod Python CLI Reference
+# Runpod CLI Reference
Note: This CLI is not the same as runpodctl and provides a different set of features.
## Getting Started
-
+
-### Configure
+### Authenticate
-
+Store your Runpod API key by running `rp login` (browser approval) or `rp login --api-key YOUR_KEY`. Credentials are stored in `~/.runpod/config.toml`.
-Store your Runpod API key by running `runpod config`. Optionally you can also call the command with your API key `runpod config YOUR_API_KEY` or include the `--profile` to save multiple keys (stored under "default" profile is not specified) Credentials are stored in `~/.runpod/config.toml`.
+### SSH
-
+Add an SSH key to your account by running `rp ssh add`. To use an existing key pass `--key` or `--key-file`. Keys are stored in `~/.runpod/ssh/`. If no key is specified a new one is generated and stored.
-Add a SSH key to you account by running `runpod ssh add-key`. To specify and existing key pass in `--key` or `--key-file` to use a file. Keys are stored in `~/.runpod/ssh/`. If no key is specified a new one will be generated and stored.
+Once a key is added, open a terminal on any pod with `rp ssh ` (or the equivalent `rp pod connect `).
-## Runpod Project
+## Apps
-A "project" is the start of a serverless worker. To get started call `runpod project new`, you will be asked a few questions about the project you are creating, a project folder will be created. You can now navigate into your repo and run `runpod project start`.
-
-Once you are finished developing you can run `runpod project deploy` to deploy your project and create an endpoint.
+An app is a collection of Python functions that run on Runpod. Scaffold one with `rp init`, iterate on it live with `rp dev main.py`, and ship it with `rp deploy`. See the [README](../../README.md) for the full workflow.
diff --git a/examples/apps/README.md b/examples/apps/README.md
new file mode 100644
index 00000000..b8a38130
--- /dev/null
+++ b/examples/apps/README.md
@@ -0,0 +1,20 @@
+# app examples
+
+each file is a self-contained app showing one way to use the sdk.
+run any of them live with `rp dev`:
+
+```bash
+rp dev examples/apps/hello_world.py
+```
+
+| example | shows |
+|---|---|
+| `hello_world` | one queue function, one `.remote()` call |
+| `streaming` | generator functions, `.stream()` chunks, `job.stream()` |
+| `gpu_inference` | gpu selection, dependencies, a cuda benchmark |
+| `web_service` | `@app.api` class with routes and per-worker state |
+| `train_and_eval` | gpu tasks sharing checkpoints through a volume |
+
+edit a file while the session is running, then press enter to re-run —
+workers pick up the new code automatically. the exhaustive
+feature-by-feature suite lives in [`tests/e2e/examples`](../../tests/e2e/examples).
diff --git a/examples/apps/gpu_inference.py b/examples/apps/gpu_inference.py
new file mode 100644
index 00000000..5e5bacb5
--- /dev/null
+++ b/examples/apps/gpu_inference.py
@@ -0,0 +1,44 @@
+"""Run a function on a GPU.
+
+Ask for hardware by name — "4090", "H100", "B200" all work, as do
+pool ids like GpuGroup.ADA_24 and exact device names. Dependencies
+listed on the decorator are ready before your function runs, so the
+worker needs no custom image.
+
+ rp dev examples/apps/gpu_inference.py
+"""
+
+import runpod
+from runpod import App
+
+app = App("gpu-demo")
+
+
+@app.queue(gpu="4090", dependencies=["numpy"])
+def matmul_benchmark(size: int = 4096):
+ import time
+
+ import torch
+
+ device = torch.cuda.get_device_name(0)
+ print(f"running on {device}")
+
+ a = torch.randn(size, size, device="cuda")
+ b = torch.randn(size, size, device="cuda")
+
+ torch.cuda.synchronize()
+ start = time.time()
+ for _ in range(10):
+ a @ b
+ torch.cuda.synchronize()
+ elapsed = time.time() - start
+
+ tflops = 10 * 2 * size**3 / elapsed / 1e12
+ print(f"{size}x{size} matmul: {tflops:.1f} TFLOPS")
+ return {"device": device, "tflops": round(tflops, 1)}
+
+
+@runpod.local_entrypoint
+def main():
+ result = matmul_benchmark.remote()
+ print("benchmark:", result)
diff --git a/examples/apps/hello_world.py b/examples/apps/hello_world.py
new file mode 100644
index 00000000..0bee9c5c
--- /dev/null
+++ b/examples/apps/hello_world.py
@@ -0,0 +1,32 @@
+"""Your first app: a Python function that runs in the cloud.
+
+An App is a named collection of functions. Decorating a function with
+@app.queue turns it into a remote resource — calling .remote() sends
+the call to a cloud worker and blocks until the result comes back.
+
+Try it:
+
+ rp dev examples/apps/hello_world.py
+
+Edit the greeting while the session is running, then press enter to
+re-run — the worker picks up your new code automatically.
+"""
+
+import runpod
+from runpod import App
+
+app = App("hello")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def hello(name: str):
+ # this print shows up in your terminal, streamed from the worker
+ print(f"running in the cloud, greeting {name}")
+ return f"hello {name}!"
+
+
+@runpod.local_entrypoint
+def main():
+ # runs on your machine; hello() runs on a cloud worker
+ greeting = hello.remote("world")
+ print(greeting)
diff --git a/examples/apps/streaming.py b/examples/apps/streaming.py
new file mode 100644
index 00000000..35267bcf
--- /dev/null
+++ b/examples/apps/streaming.py
@@ -0,0 +1,42 @@
+"""streaming: yield partial results from a queue function.
+
+A generator function decorated with @app.queue streams its output.
+Calling .stream() yields each chunk as the worker produces it;
+calling .remote() returns the full list of chunks at once. A spawned
+job can be streamed later with job.stream().
+
+Try it:
+
+ rp dev examples/apps/streaming.py
+"""
+
+import time
+
+import runpod
+from runpod import App
+
+app = App("streaming")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def tokens(prompt: str):
+ # stand-in for token-by-token llm output
+ for word in f"you said: {prompt}".split():
+ time.sleep(0.2)
+ yield word
+
+
+@runpod.local_entrypoint
+def main():
+ # chunks arrive as the worker yields them
+ for chunk in tokens.stream("hello streaming world"):
+ print(chunk, end=" ", flush=True)
+ print()
+
+ # .remote() on a generator returns every chunk at once
+ all_chunks = tokens.remote("all at once")
+ print(all_chunks)
+
+ # spawn now, stream later
+ job = tokens.spawn("stream me later")
+ print(list(job.stream()))
diff --git a/examples/apps/train_and_eval.py b/examples/apps/train_and_eval.py
new file mode 100644
index 00000000..db7b4c44
--- /dev/null
+++ b/examples/apps/train_and_eval.py
@@ -0,0 +1,71 @@
+"""A training workflow: tasks, volumes, and functions working together.
+
+@app.task gives each call its own dedicated pod that terminates when
+the function returns — right for work that runs minutes to hours.
+A Volume persists files between calls and across resources: train()
+writes a checkpoint, evaluate() reads it from a different pod. The
+volume is created on first use, and both tasks are automatically
+placed in its datacenter.
+
+ rp dev examples/apps/train_and_eval.py
+"""
+
+import runpod
+from runpod import App, Volume
+
+app = App("trainer")
+
+checkpoints = Volume("checkpoints", size=10)
+
+
+@app.task(gpu="4090", volume=checkpoints)
+def train(steps: int = 200):
+ import torch
+ import torch.nn as nn
+
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ print(f"training on {device}")
+
+ # learn y = 3x + 1 from noisy samples
+ x = torch.randn(2048, 1, device=device)
+ y = 3 * x + 1 + torch.randn_like(x) * 0.05
+
+ model = nn.Linear(1, 1).to(device)
+ opt = torch.optim.SGD(model.parameters(), lr=0.05)
+ for step in range(1, steps + 1):
+ loss = ((model(x) - y) ** 2).mean()
+ opt.zero_grad()
+ loss.backward()
+ opt.step()
+ if step % 50 == 0:
+ print(f"step {step}/{steps} loss={loss.item():.5f}")
+
+ # checkpoints.path is where the volume is mounted on this pod
+ run_dir = checkpoints.path / "linear"
+ run_dir.mkdir(parents=True, exist_ok=True)
+ torch.save(model.state_dict(), run_dir / "model.pt")
+ print(f"saved checkpoint to {run_dir}")
+ return {"checkpoint": "linear", "final_loss": round(loss.item(), 5)}
+
+
+@app.task(gpu="4090", volume=checkpoints)
+def evaluate(checkpoint: str):
+ import torch
+ import torch.nn as nn
+
+ model = nn.Linear(1, 1)
+ model.load_state_dict(
+ torch.load(checkpoints.path / checkpoint / "model.pt")
+ )
+ weight, bias = model.weight.item(), model.bias.item()
+ print(f"learned: y = {weight:.3f}x + {bias:.3f}")
+ return {"weight": round(weight, 2), "bias": round(bias, 2)}
+
+
+@runpod.local_entrypoint
+def main():
+ out = train.remote()
+ print("train finished:", out)
+
+ fit = evaluate.remote(out["checkpoint"])
+ print("evaluation:", fit)
diff --git a/examples/apps/web_service.py b/examples/apps/web_service.py
new file mode 100644
index 00000000..fe6f6baa
--- /dev/null
+++ b/examples/apps/web_service.py
@@ -0,0 +1,42 @@
+"""A load-balanced HTTP service from a plain class.
+
+@app.api turns a class into a web service: mark methods with @get and
+@post to expose routes, and @init to run setup once per worker before
+it takes traffic. Workers keep state between requests — this counter
+lives in memory on the worker.
+
+ rp dev examples/apps/web_service.py
+
+Deployed services get a stable URL; in dev you call routes through the
+class itself, as shown in the entrypoint.
+"""
+
+import runpod
+from runpod import App, get, init, post
+
+app = App("web")
+
+
+@app.api(cpu="cpu3c-1-2")
+class Counter:
+ @init
+ def setup(self):
+ # runs once when a worker starts, before any request
+ self.count = 0
+ print("worker ready, counter at 0")
+
+ @post("/bump")
+ async def bump(self, body: dict):
+ self.count += body.get("by", 1)
+ return {"count": self.count}
+
+ @get("/value")
+ async def value(self):
+ return {"count": self.count}
+
+
+@runpod.local_entrypoint
+def main():
+ print(Counter.post("/bump", {"by": 3})) # {'count': 3}
+ print(Counter.post("/bump", {"by": 2})) # {'count': 5}
+ print(Counter.get("/value")) # {'count': 5}
diff --git a/pyproject.toml b/pyproject.toml
index d4639a15..4a8c870c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,8 @@ include-package-data = true
runpod = [
"serverless/binaries/gpu_test",
"serverless/binaries/README.md",
+ "runtimes/**/*.py",
+ "runtimes/**/Dockerfile*",
]
[tool.setuptools_scm]
@@ -60,7 +62,8 @@ local_scheme = "no-local-version"
[project.scripts]
-runpod = "runpod.cli.entry:runpod_cli"
+runpod = "runpod.rp_cli.main:run"
+rp = "runpod.rp_cli.main:run"
[dependency-groups]
diff --git a/pytest.ini b/pytest.ini
index 1c2c135e..3e3bb1d6 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -7,6 +7,9 @@ filterwarnings =
# only warn. Don't fail the suite on them until the deps update.
ignore:'asyncio\.get_event_loop_policy' is deprecated:DeprecationWarning
ignore:'asyncio\.iscoroutinefunction' is deprecated:DeprecationWarning
+ # fastapi's bundled TestClient imports starlette.testclient, which warns
+ # about httpx until fastapi migrates to httpx2
+ ignore:Using `httpx` with `starlette.testclient` is deprecated.*
python_files = tests.py test_*.py *_test.py
norecursedirs = venv *.egg-info .git build tests/e2e
asyncio_mode = auto
diff --git a/requirements.txt b/requirements.txt
index 2ed35782..0cdd4a60 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,6 +4,7 @@ aiohttp-retry >= 2.9.1
backoff >= 2.2.1
boto3 >= 1.43.40
click >= 8.1.7
+cloudpickle >= 3.0.0
colorama >= 0.4.6, < 0.4.7
cryptography >= 48.0.1
fastapi[all] >= 0.138.1
@@ -13,8 +14,8 @@ psutil >= 5.9.0
py-cpuinfo >= 9.0.0
inquirerpy == 0.3.4
requests >= 2.34.2
+rich >= 13.0.0
tomli >= 2.4.1
tomlkit >= 0.15.0
tqdm-loggable >= 0.4.1
urllib3 >= 1.26.6
-watchdog >= 6.0.0
diff --git a/runpod/__init__.py b/runpod/__init__.py
index 6d24180a..ddbfa21e 100644
--- a/runpod/__init__.py
+++ b/runpod/__init__.py
@@ -1,88 +1,116 @@
-""" Allows runpod to be imported as a module. """
+"""Allows runpod to be imported as a module."""
+import importlib
import logging
import os
-from . import serverless
-from .api.ctl_commands import (
- create_container_registry_auth,
- create_endpoint,
- create_pod,
- create_template,
- delete_container_registry_auth,
- get_endpoints,
- get_gpu,
- get_gpus,
- get_pod,
- get_pods,
- get_user,
- resume_pod,
- stop_pod,
- terminate_pod,
- update_container_registry_auth,
- update_endpoint_template,
- update_user_settings,
-)
-from .cli.groups.config.functions import (
- check_credentials,
- get_credentials,
- set_credentials,
-)
-from .endpoint import AsyncioEndpoint, AsyncioJob, Endpoint
-from .serverless.modules.rp_logger import RunPodLogger
from .version import __version__
+# public names resolved on first access (PEP 562). keeping the top-level
+# import light means `import runpod` (and the deployed worker that does
+# it) pulls only what it actually touches, not the whole sdk/cli closure.
+_LAZY_ATTRS = {
+ # api control-plane commands
+ "create_container_registry_auth": "runpod.api.ctl_commands",
+ "create_endpoint": "runpod.api.ctl_commands",
+ "create_pod": "runpod.api.ctl_commands",
+ "create_template": "runpod.api.ctl_commands",
+ "delete_container_registry_auth": "runpod.api.ctl_commands",
+ "get_endpoints": "runpod.api.ctl_commands",
+ "get_gpu": "runpod.api.ctl_commands",
+ "get_gpus": "runpod.api.ctl_commands",
+ "get_pod": "runpod.api.ctl_commands",
+ "get_pods": "runpod.api.ctl_commands",
+ "get_user": "runpod.api.ctl_commands",
+ "resume_pod": "runpod.api.ctl_commands",
+ "stop_pod": "runpod.api.ctl_commands",
+ "terminate_pod": "runpod.api.ctl_commands",
+ "update_container_registry_auth": "runpod.api.ctl_commands",
+ "update_endpoint_template": "runpod.api.ctl_commands",
+ "update_user_settings": "runpod.api.ctl_commands",
+ # config helpers
+ "check_credentials": "runpod.cli.groups.config.functions",
+ "get_credentials": "runpod.cli.groups.config.functions",
+ "set_credentials": "runpod.cli.groups.config.functions",
+ # endpoint clients
+ "AsyncioEndpoint": "runpod.endpoint",
+ "AsyncioJob": "runpod.endpoint",
+ "Endpoint": "runpod.endpoint",
+ # apps surface
+ "Api": "runpod.apps",
+ "App": "runpod.apps",
+ "DataCenter": "runpod.apps",
+ "EndpointNotFound": "runpod.apps",
+ "Job": "runpod.apps",
+ "Model": "runpod.apps",
+ "Queue": "runpod.apps",
+ "Secret": "runpod.apps",
+ "Volume": "runpod.apps",
+ "is_local": "runpod.apps",
+ "local_entrypoint": "runpod.apps",
+ "schedule": "runpod.apps",
+ "CpuInstanceType": "runpod.apps.gpu",
+ "GpuGroup": "runpod.apps.gpu",
+ "GpuType": "runpod.apps.gpu",
+ "delete": "runpod.apps.markers",
+ "get": "runpod.apps.markers",
+ "init": "runpod.apps.markers",
+ "patch": "runpod.apps.markers",
+ "post": "runpod.apps.markers",
+ "put": "runpod.apps.markers",
+ # logger
+ "RunPodLogger": "runpod.serverless.modules.rp_logger",
+ # submodule
+ "serverless": "runpod.serverless",
+}
+
+# lazy names resolve through __getattr__, so __all__ derives from the
+# lazy table plus the eagerly-defined module attributes
__all__ = [
- # API functions
- "create_container_registry_auth",
- "create_endpoint",
- "create_pod",
- "create_template",
- "delete_container_registry_auth",
- "get_endpoints",
- "get_gpu",
- "get_gpus",
- "get_pod",
- "get_pods",
- "get_user",
- "resume_pod",
- "stop_pod",
- "terminate_pod",
- "update_container_registry_auth",
- "update_endpoint_template",
- "update_user_settings",
- # Config functions
- "check_credentials",
- "get_credentials",
- "set_credentials",
- # Endpoint classes
- "AsyncioEndpoint",
- "AsyncioJob",
- "Endpoint",
- # Serverless module
- "serverless",
- # Logger class
- "RunPodLogger",
- # Version
+ *_LAZY_ATTRS,
"__version__",
- # Module variables
"SSH_KEY_PATH",
"profile",
- "api_key",
- "endpoint_url_base"
+ "api_key",
+ "endpoint_url_base",
]
+
+def _load_api_key():
+ """resolve the api key from the environment, then stored credentials.
+
+ the credential lookup is deferred (and tolerant of a missing cli
+ dependency closure) so a bare `import runpod` never drags in the
+ config/cli modules; the deployed worker gets its key from the env.
+ """
+ key = os.environ.get("RUNPOD_API_KEY")
+ if key:
+ return key
+ try:
+ creds = importlib.import_module(
+ "runpod.cli.groups.config.functions"
+ ).get_credentials(profile)
+ except Exception: # noqa: BLE001 - no stored key is a valid state
+ return None
+ return creds["api_key"] if creds else None
+
+
+def __getattr__(name):
+ module_path = _LAZY_ATTRS.get(name)
+ if module_path is None:
+ raise AttributeError(f"module 'runpod' has no attribute {name!r}")
+ module = importlib.import_module(module_path)
+ value = module if module_path == f"runpod.{name}" else getattr(module, name)
+ globals()[name] = value
+ return value
+
+
# ------------------------------- Config Paths ------------------------------- #
SSH_KEY_PATH = os.path.expanduser("~/.runpod/ssh")
-
profile = "default" # pylint: disable=invalid-name
-_credentials = get_credentials(profile)
-if _credentials is not None:
- api_key = _credentials["api_key"] # pylint: disable=invalid-name
-else:
- api_key = None # pylint: disable=invalid-name
+api_key = _load_api_key() # pylint: disable=invalid-name
endpoint_url_base = os.environ.get(
"RUNPOD_ENDPOINT_BASE_URL", "https://api.runpod.ai/v2"
diff --git a/runpod/api/graphql.py b/runpod/api/graphql.py
index c7c3ccc3..36787425 100644
--- a/runpod/api/graphql.py
+++ b/runpod/api/graphql.py
@@ -14,40 +14,109 @@
HTTP_STATUS_UNAUTHORIZED = 401
-def run_graphql_query(query: str, api_key: Optional[str] = None) -> Dict[str, Any]:
- """
- Run a GraphQL query with optional API key override.
-
- Args:
- query: The GraphQL query to execute.
- api_key: Optional API key to use for this query.
- """
+def _graphql_url() -> str:
+ api_url_base = os.environ.get("RUNPOD_API_BASE_URL", "https://api.runpod.io")
+ return f"{api_url_base}/graphql"
+
+
+def _resolve_api_key(api_key: Optional[str]) -> str:
from runpod import api_key as global_api_key # pylint: disable=import-outside-toplevel, cyclic-import
-
- # Use provided API key or fall back to global
+
effective_api_key = api_key or global_api_key
-
if not effective_api_key:
raise error.AuthenticationError("No API key provided")
+ return effective_api_key
- api_url_base = os.environ.get("RUNPOD_API_BASE_URL", "https://api.runpod.io")
- url = f"{api_url_base}/graphql"
+def _build_headers(api_key: Optional[str]) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
- "Authorization": f"Bearer {effective_api_key}",
}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ return headers
+
+
+def _build_payload(query: str, variables: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ payload: Dict[str, Any] = {"query": query}
+ if variables:
+ payload["variables"] = variables
+ return payload
+
+
+def _check_response(response_json: Dict[str, Any], query: str) -> Dict[str, Any]:
+ if "errors" in response_json:
+ raise error.QueryError(response_json["errors"][0]["message"], query)
+ return response_json
+
+
+def run_graphql_query(
+ query: str,
+ api_key: Optional[str] = None,
+ variables: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """
+ Run a GraphQL query with optional API key override.
- data = json.dumps({"query": query})
- response = requests.post(url, headers=headers, data=data, timeout=30)
+ Args:
+ query: The GraphQL query to execute.
+ api_key: Optional API key to use for this query.
+ variables: Optional GraphQL variables to send with the query.
+ """
+ effective_api_key = _resolve_api_key(api_key)
+
+ response = requests.post(
+ _graphql_url(),
+ headers=_build_headers(effective_api_key),
+ data=json.dumps(_build_payload(query, variables)),
+ timeout=30,
+ )
if response.status_code == HTTP_STATUS_UNAUTHORIZED:
raise error.AuthenticationError(
"Unauthorized request, please check your API key."
)
- if "errors" in response.json():
- raise error.QueryError(response.json()["errors"][0]["message"], query)
+ return _check_response(response.json(), query)
+
+
+async def run_graphql_query_async(
+ query: str,
+ api_key: Optional[str] = None,
+ variables: Optional[Dict[str, Any]] = None,
+ timeout: float = 60.0,
+ anonymous: bool = False,
+) -> Dict[str, Any]:
+ """
+ Async variant of run_graphql_query, sharing the same url, headers,
+ auth resolution, and error handling.
+
+ Args:
+ query: The GraphQL query to execute.
+ api_key: Optional API key to use for this query.
+ variables: Optional GraphQL variables to send with the query.
+ timeout: Total request timeout in seconds.
+ anonymous: Send the request without credentials even when a key
+ is available. The browser-login flow requires this: the
+ server only releases the granted key to guest requests, so
+ a stale stored key would make the poll spin forever.
+ """
+ import aiohttp # pylint: disable=import-outside-toplevel
+
+ effective_api_key = None if anonymous else _resolve_api_key(api_key)
+
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.post(
+ _graphql_url(),
+ headers=_build_headers(effective_api_key),
+ json=_build_payload(query, variables),
+ ) as response:
+ if response.status == HTTP_STATUS_UNAUTHORIZED:
+ raise error.AuthenticationError(
+ "Unauthorized request, please check your API key."
+ )
+ response_json = await response.json()
- return response.json()
+ return _check_response(response_json, query)
diff --git a/runpod/api/mutations/apps.py b/runpod/api/mutations/apps.py
new file mode 100644
index 00000000..ff7287f5
--- /dev/null
+++ b/runpod/api/mutations/apps.py
@@ -0,0 +1,132 @@
+"""graphql mutations for the apps control-plane client."""
+
+MUTATION_SAVE_ENDPOINT = """
+mutation saveEndpoint($input: EndpointInput!) {
+ saveEndpoint(input: $input) {
+ id
+ name
+ templateId
+ gpuIds
+ instanceIds
+ workersMin
+ workersMax
+ idleTimeout
+ aiKey
+ }
+}
+"""
+
+MUTATION_DELETE_ENDPOINT = """
+mutation deleteEndpoint($id: String!) {
+ deleteEndpoint(id: $id)
+}
+"""
+
+MUTATION_DEPLOY_CPU_POD = """
+mutation deployCpuPod($input: deployCpuPodInput!) {
+ deployCpuPod(input: $input) { id desiredStatus }
+}
+"""
+
+MUTATION_DEPLOY_POD = """
+mutation deployPod($input: PodFindAndDeployOnDemandInput) {
+ podFindAndDeployOnDemand(input: $input) { id desiredStatus }
+}
+"""
+
+MUTATION_TERMINATE_POD = """
+mutation terminatePod($input: PodTerminateInput!) {
+ podTerminate(input: $input)
+}
+"""
+
+MUTATION_CREATE_FLASH_APP = """
+mutation createFlashApp($input: CreateFlashAppInput!) {
+ createFlashApp(input: $input) { id name }
+}
+"""
+
+MUTATION_CREATE_FLASH_ENVIRONMENT = """
+mutation createFlashEnvironment($input: CreateFlashEnvironmentInput!) {
+ createFlashEnvironment(input: $input) { id name }
+}
+"""
+
+MUTATION_CREATE_NETWORK_VOLUME = """
+mutation createNetworkVolume($input: CreateNetworkVolumeInput!) {
+ createNetworkVolume(input: $input) {
+ id
+ name
+ size
+ dataCenterId
+ }
+}
+"""
+
+MUTATION_SAVE_REGISTRY_AUTH = """
+mutation SaveRegistryAuth($input: SaveRegistryAuthInput!) {
+ saveRegistryAuth(input: $input) { id name }
+}
+"""
+
+MUTATION_DELETE_REGISTRY_AUTH = """
+mutation DeleteRegistryAuth($registryAuthId: String!) {
+ deleteRegistryAuth(registryAuthId: $registryAuthId)
+}
+"""
+
+MUTATION_CREATE_SECRET = """
+mutation secretCreate($input: SecretCreateInput!) {
+ secretCreate(input: $input) { id name }
+}
+"""
+
+MUTATION_DELETE_SECRET = """
+mutation secretDelete($id: ID!) {
+ secretDelete(id: $id)
+}
+"""
+
+MUTATION_DELETE_FLASH_APP = """
+mutation deleteFlashApp($flashAppId: String!) {
+ deleteFlashApp(flashAppId: $flashAppId)
+}
+"""
+
+MUTATION_DELETE_FLASH_ENVIRONMENT = """
+mutation deleteFlashEnvironment($flashEnvironmentId: String!) {
+ deleteFlashEnvironment(flashEnvironmentId: $flashEnvironmentId)
+}
+"""
+
+MUTATION_CREATE_FLASH_AUTH_REQUEST = """
+mutation createFlashAuthRequest {
+ createFlashAuthRequest {
+ id
+ status
+ expiresAt
+ }
+}
+"""
+
+MUTATION_PREPARE_ARTIFACT_UPLOAD = """
+mutation PrepareArtifactUpload($input: PrepareFlashArtifactUploadInput!) {
+ prepareFlashArtifactUpload(input: $input) {
+ uploadUrl
+ objectKey
+ expiresAt
+ }
+}
+"""
+
+MUTATION_FINALIZE_ARTIFACT_UPLOAD = """
+mutation FinalizeArtifactUpload($input: FinalizeFlashArtifactUploadInput!) {
+ finalizeFlashArtifactUpload(input: $input) { id manifest }
+}
+"""
+
+MUTATION_DEPLOY_BUILD = """
+mutation deployBuildToEnvironment($input: DeployBuildToEnvironmentInput!) {
+ deployBuildToEnvironment(input: $input) { id name }
+}
+"""
diff --git a/runpod/api/queries/apps.py b/runpod/api/queries/apps.py
new file mode 100644
index 00000000..8089aa25
--- /dev/null
+++ b/runpod/api/queries/apps.py
@@ -0,0 +1,116 @@
+"""graphql queries for the apps control-plane client."""
+
+QUERY_MY_ENDPOINTS = """
+query myEndpoints {
+ myself {
+ endpoints {
+ id
+ name
+ templateId
+ workersMin
+ workersMax
+ template { env { key value } }
+ }
+ }
+}
+"""
+
+QUERY_FLASH_APP_BY_NAME = """
+query getFlashAppByName($flashAppName: String!) {
+ flashAppByName(flashAppName: $flashAppName) {
+ id
+ name
+ flashEnvironments {
+ id
+ name
+ state
+ activeBuildId
+ endpoints { id name }
+ }
+ }
+}
+"""
+
+QUERY_GPU_STOCK = """
+query GpuStock($gpuTypesInput: GpuTypeFilter, $lowestPriceInput: GpuLowestPriceInput) {
+ gpuTypes(input: $gpuTypesInput) {
+ lowestPrice(input: $lowestPriceInput) { stockStatus }
+ }
+}
+"""
+
+QUERY_CPU_STOCK = """
+query CpuStock($cpuFlavorInput: CpuFlavorInput, $specificsInput: SpecificsInput) {
+ cpuFlavors(input: $cpuFlavorInput) {
+ specifics(input: $specificsInput) { stockStatus }
+ }
+}
+"""
+
+QUERY_NETWORK_VOLUMES = """
+query myVolumes {
+ myself {
+ networkVolumes { id name size dataCenterId }
+ }
+}
+"""
+
+QUERY_REGISTRY_AUTHS = """
+query myRegistryCreds {
+ myself {
+ containerRegistryCreds { id name }
+ }
+}
+"""
+
+QUERY_SECRETS = """
+query mySecrets {
+ myself {
+ secrets { id name description createdAt }
+ }
+}
+"""
+
+QUERY_FLASH_APPS = """
+query getFlashApps {
+ myself {
+ flashApps {
+ id
+ name
+ flashEnvironments {
+ id
+ name
+ state
+ createdAt
+ activeBuildId
+ }
+ flashBuilds { id createdAt }
+ }
+ }
+}
+"""
+
+QUERY_FLASH_ENVIRONMENT_BY_NAME = """
+query getFlashEnvironmentByName($input: FlashEnvironmentByNameInput!) {
+ flashEnvironmentByName(input: $input) {
+ id
+ name
+ state
+ activeBuildId
+ createdAt
+ endpoints { id name }
+ networkVolumes { id name }
+ }
+}
+"""
+
+QUERY_FLASH_AUTH_REQUEST_STATUS = """
+query flashAuthRequestStatus($flashAuthRequestId: String!) {
+ flashAuthRequestStatus(flashAuthRequestId: $flashAuthRequestId) {
+ id
+ status
+ expiresAt
+ apiKey
+ }
+}
+"""
diff --git a/runpod/apps/__init__.py b/runpod/apps/__init__.py
new file mode 100644
index 00000000..3651f574
--- /dev/null
+++ b/runpod/apps/__init__.py
@@ -0,0 +1,53 @@
+"""app-centric sdk surface: App, decorated handles, and execution primitives."""
+
+from .app import App, get_registered_apps
+from .context import Context, current_context, is_local
+from .datacenter import DataCenter
+from .entrypoint import local_entrypoint
+from .errors import (
+ AppError,
+ EndpointNotFound,
+ RemoteExecutionError,
+ ScheduleNotSupported,
+)
+from .handles import ApiHandle, FunctionHandle
+from .job import Job
+from .markers import delete, get, init, patch, post, put
+from .schedule import schedule
+from .spec import ResourceKind, ResourceSpec, RouteSpec
+from .stubs import Api, Queue
+from .model import Model
+from .secret import Secret
+from .volume import Volume
+
+__all__ = [
+ "Api",
+ "ApiHandle",
+ "App",
+ "AppError",
+ "Context",
+ "DataCenter",
+ "EndpointNotFound",
+ "FunctionHandle",
+ "Job",
+ "Model",
+ "Queue",
+ "Secret",
+ "Volume",
+ "RemoteExecutionError",
+ "ResourceKind",
+ "ResourceSpec",
+ "RouteSpec",
+ "ScheduleNotSupported",
+ "current_context",
+ "delete",
+ "get",
+ "get_registered_apps",
+ "init",
+ "is_local",
+ "local_entrypoint",
+ "patch",
+ "post",
+ "put",
+ "schedule",
+]
diff --git a/runpod/apps/api.py b/runpod/apps/api.py
new file mode 100644
index 00000000..e69bfc78
--- /dev/null
+++ b/runpod/apps/api.py
@@ -0,0 +1,380 @@
+"""control-plane calls for app provisioning.
+
+a thin wrapper over runpod.api.graphql's shared async transport,
+scoped to what the apps surface needs: endpoint save/delete for dev
+sessions, and the app / build / environment lifecycle for deploys.
+management verbs for the wider sdk stay in runpod.api.ctl_commands.
+"""
+
+from typing import Any, Dict, List, Optional
+
+import aiohttp
+
+from ..api.graphql import run_graphql_query_async
+from ..api.mutations import apps as app_mutations
+from ..api.queries import apps as app_queries
+from ..error import QueryError
+
+
+_TRANSPORT_RETRIES = 4
+
+
+class AppsApiClient:
+ """async control-plane client scoped to apps provisioning."""
+
+ def __init__(self, api_key: Optional[str] = None):
+ self._api_key = api_key
+
+ async def _execute(
+ self,
+ query: str,
+ variables: Optional[Dict[str, Any]] = None,
+ *,
+ anonymous: bool = False,
+ ) -> Dict[str, Any]:
+ import asyncio
+
+ last_exc: Optional[Exception] = None
+ for attempt in range(_TRANSPORT_RETRIES):
+ if attempt:
+ await asyncio.sleep(2 ** (attempt - 1))
+ try:
+ response = await run_graphql_query_async(
+ query,
+ api_key=self._api_key,
+ variables=variables,
+ anonymous=anonymous,
+ )
+ return response["data"]
+ except (aiohttp.ClientError, OSError, asyncio.TimeoutError) as exc:
+ # transport-level failures (ssl hiccups, resets, dns)
+ # are transient; graphql/auth errors propagate untouched
+ last_exc = exc
+ if last_exc is None: # pragma: no cover - loop always runs once
+ raise RuntimeError("graphql transport retry loop exited cleanly")
+ raise last_exc
+
+ async def save_endpoint(self, endpoint_input: Dict[str, Any]) -> Dict[str, Any]:
+ """create or update a serverless endpoint. include id to update."""
+ mutation = app_mutations.MUTATION_SAVE_ENDPOINT
+ data = await self._execute(mutation, {"input": endpoint_input})
+ return data["saveEndpoint"]
+
+ async def delete_endpoint(self, endpoint_id: str) -> bool:
+ mutation = app_mutations.MUTATION_DELETE_ENDPOINT
+ data = await self._execute(mutation, {"id": endpoint_id})
+ return bool(data.get("deleteEndpoint"))
+
+ async def list_my_endpoints(self) -> List[Dict[str, Any]]:
+ query = app_queries.QUERY_MY_ENDPOINTS
+ data = await self._execute(query)
+ return data["myself"]["endpoints"]
+
+ async def deploy_task_pod(
+ self, pod_input: Dict[str, Any], *, is_cpu: bool
+ ) -> Dict[str, Any]:
+ """deploy an on-demand pod for a task run."""
+ pod_input = dict(pod_input)
+ if is_cpu:
+ # deployCpuPod takes a single instanceId
+ instance_ids = pod_input.pop("instanceIds", None)
+ if instance_ids:
+ pod_input["instanceId"] = instance_ids[0]
+ mutation = app_mutations.MUTATION_DEPLOY_CPU_POD
+ data = await self._execute(mutation, {"input": pod_input})
+ return data["deployCpuPod"]
+
+ mutation = app_mutations.MUTATION_DEPLOY_POD
+ data = await self._execute(mutation, {"input": pod_input})
+ return data["podFindAndDeployOnDemand"]
+
+ async def terminate_pod(self, pod_id: str) -> None:
+ mutation = app_mutations.MUTATION_TERMINATE_POD
+ await self._execute(mutation, {"input": {"podId": pod_id}})
+
+ async def get_app_by_name(self, name: str) -> Optional[Dict[str, Any]]:
+ query = app_queries.QUERY_FLASH_APP_BY_NAME
+ try:
+ data = await self._execute(query, {"flashAppName": name})
+ except QueryError as exc:
+ if "not found" in str(exc).lower():
+ return None
+ raise
+ return data["flashAppByName"]
+
+ async def create_app(self, name: str) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_CREATE_FLASH_APP
+ data = await self._execute(mutation, {"input": {"name": name}})
+ return data["createFlashApp"]
+
+ async def create_environment(self, app_id: str, name: str) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_CREATE_FLASH_ENVIRONMENT
+ data = await self._execute(
+ mutation, {"input": {"flashAppId": app_id, "name": name}}
+ )
+ return data["createFlashEnvironment"]
+
+ async def gpu_stock_status(
+ self,
+ gpu_id: str,
+ data_center_id: str,
+ gpu_count: int = 1,
+ pods: bool = False,
+ ) -> Optional[str]:
+ """stock signal for a gpu device in one datacenter.
+
+ the serverless plane (includeAiApi) and the pod plane have
+ different availability; pods=True queries pod stock (tasks).
+ """
+ query = app_queries.QUERY_GPU_STOCK
+ data = await self._execute(
+ query,
+ {
+ "gpuTypesInput": {"ids": [gpu_id]},
+ "lowestPriceInput": {
+ "dataCenterId": data_center_id,
+ "gpuCount": gpu_count,
+ "secureCloud": True,
+ "includeAiApi": not pods,
+ },
+ },
+ )
+ gpu_types = data.get("gpuTypes") or []
+ first = gpu_types[0] if gpu_types else {}
+ price = first.get("lowestPrice") if isinstance(first, dict) else None
+ return (price or {}).get("stockStatus")
+
+ async def cpu_stock_status(
+ self, instance_id: str, data_center_id: str
+ ) -> Optional[str]:
+ """stock signal for a cpu flavor in one datacenter."""
+ flavor = instance_id.split("-", 1)[0]
+ query = app_queries.QUERY_CPU_STOCK
+ data = await self._execute(
+ query,
+ {
+ "cpuFlavorInput": {"id": flavor},
+ "specificsInput": {
+ "dataCenterId": data_center_id,
+ "instanceId": instance_id,
+ },
+ },
+ )
+ flavors = data.get("cpuFlavors") or []
+ first = flavors[0] if flavors else {}
+ specifics = first.get("specifics") if isinstance(first, dict) else None
+ return (specifics or {}).get("stockStatus")
+
+ async def list_network_volumes(self) -> List[Dict[str, Any]]:
+ query = app_queries.QUERY_NETWORK_VOLUMES
+ data = await self._execute(query)
+ return data["myself"].get("networkVolumes") or []
+
+ async def create_network_volume(
+ self, name: str, size: int, data_center_id: str
+ ) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_CREATE_NETWORK_VOLUME
+ data = await self._execute(
+ mutation,
+ {
+ "input": {
+ "name": name,
+ "size": size,
+ "dataCenterId": data_center_id,
+ }
+ },
+ )
+ return data["createNetworkVolume"]
+
+ async def list_registry_auths(self) -> List[Dict[str, Any]]:
+ query = app_queries.QUERY_REGISTRY_AUTHS
+ data = await self._execute(query)
+ return data["myself"].get("containerRegistryCreds") or []
+
+ async def create_registry_auth(
+ self, name: str, username: str, password: str
+ ) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_SAVE_REGISTRY_AUTH
+ data = await self._execute(
+ mutation,
+ {
+ "input": {
+ "name": name,
+ "username": username,
+ "password": password,
+ }
+ },
+ )
+ return data["saveRegistryAuth"]
+
+ async def delete_registry_auth(self, auth_id: str) -> bool:
+ mutation = app_mutations.MUTATION_DELETE_REGISTRY_AUTH
+ data = await self._execute(mutation, {"registryAuthId": auth_id})
+ return bool(data.get("deleteRegistryAuth"))
+
+ async def list_secrets(self) -> List[Dict[str, Any]]:
+ query = app_queries.QUERY_SECRETS
+ data = await self._execute(query)
+ return data["myself"].get("secrets") or []
+
+ async def create_secret(
+ self, name: str, value: str, description: str = ""
+ ) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_CREATE_SECRET
+ data = await self._execute(
+ mutation,
+ {
+ "input": {
+ "name": name,
+ "value": value,
+ "description": description,
+ }
+ },
+ )
+ return data["secretCreate"]
+
+ async def delete_secret(self, secret_id: str) -> bool:
+ mutation = app_mutations.MUTATION_DELETE_SECRET
+ data = await self._execute(mutation, {"id": secret_id})
+ return bool(data.get("secretDelete"))
+
+ async def list_apps(self) -> List[Dict[str, Any]]:
+ """all flash apps with their environments and builds."""
+ query = app_queries.QUERY_FLASH_APPS
+ data = await self._execute(query)
+ return data["myself"].get("flashApps") or []
+
+ async def get_environment_by_name(
+ self, app_name: str, env_name: str
+ ) -> Optional[Dict[str, Any]]:
+ """an environment with its attached endpoints and volumes."""
+ app = await self.get_app_by_name(app_name)
+ if app is None:
+ return None
+ query = app_queries.QUERY_FLASH_ENVIRONMENT_BY_NAME
+ try:
+ data = await self._execute(
+ query,
+ {"input": {"flashAppId": app["id"], "name": env_name}},
+ )
+ except QueryError as exc:
+ if "not found" in str(exc).lower():
+ return None
+ raise
+ return data["flashEnvironmentByName"]
+
+ async def delete_app(self, app_id: str) -> bool:
+ mutation = app_mutations.MUTATION_DELETE_FLASH_APP
+ data = await self._execute(mutation, {"flashAppId": app_id})
+ return bool(data.get("deleteFlashApp"))
+
+ async def delete_environment(self, environment_id: str) -> bool:
+ mutation = app_mutations.MUTATION_DELETE_FLASH_ENVIRONMENT
+ data = await self._execute(mutation, {"flashEnvironmentId": environment_id})
+ return bool(data.get("deleteFlashEnvironment"))
+
+ async def create_auth_request(self) -> Dict[str, Any]:
+ """open a browser-approval auth request; no credentials needed."""
+ mutation = app_mutations.MUTATION_CREATE_FLASH_AUTH_REQUEST
+ data = await self._execute(mutation, anonymous=True)
+ return data.get("createFlashAuthRequest") or {}
+
+ async def get_auth_request_status(self, request_id: str) -> Dict[str, Any]:
+ query = app_queries.QUERY_FLASH_AUTH_REQUEST_STATUS
+ data = await self._execute(
+ query,
+ {"flashAuthRequestId": request_id},
+ anonymous=True,
+ )
+ return data.get("flashAuthRequestStatus") or {}
+
+ async def prepare_artifact_upload(
+ self, app_id: str, tarball_size: int
+ ) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_PREPARE_ARTIFACT_UPLOAD
+ data = await self._execute(
+ mutation, {"input": {"flashAppId": app_id, "tarballSize": tarball_size}}
+ )
+ return data["prepareFlashArtifactUpload"]
+
+ async def finalize_artifact_upload(
+ self, app_id: str, object_key: str, manifest: Dict[str, Any]
+ ) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_FINALIZE_ARTIFACT_UPLOAD
+ data = await self._execute(
+ mutation,
+ {
+ "input": {
+ "flashAppId": app_id,
+ "objectKey": object_key,
+ "manifest": manifest,
+ }
+ },
+ )
+ return data["finalizeFlashArtifactUpload"]
+
+ async def deploy_build(self, environment_id: str, build_id: str) -> Dict[str, Any]:
+ mutation = app_mutations.MUTATION_DEPLOY_BUILD
+ data = await self._execute(
+ mutation,
+ {
+ "input": {
+ "flashEnvironmentId": environment_id,
+ "flashBuildId": build_id,
+ }
+ },
+ )
+ return data["deployBuildToEnvironment"]
+
+ async def upload_tarball(
+ self,
+ upload_url: str,
+ tar_path: str,
+ progress: Optional[Any] = None,
+ ) -> None:
+ """put the build tarball to the presigned url.
+
+ progress, when given, is called with (bytes_sent, total_bytes)
+ as chunks go out. transient resets (broken pipe, connection
+ reset, 5xx) are retried with backoff; the payload is re-read
+ from disk on each attempt.
+ """
+ import asyncio
+ import os
+
+ timeout = aiohttp.ClientTimeout(total=600)
+ total = os.path.getsize(tar_path)
+
+ async def _reader():
+ sent = 0
+ with open(tar_path, "rb") as f:
+ while True:
+ chunk = f.read(1024 * 1024)
+ if not chunk:
+ break
+ sent += len(chunk)
+ if progress is not None:
+ progress(sent, total)
+ yield chunk
+
+ attempts = 4
+ for attempt in range(attempts):
+ try:
+ await self._put_tarball(upload_url, _reader(), total, timeout)
+ return
+ except (aiohttp.ClientError, OSError) as exc:
+ if attempt == attempts - 1:
+ raise
+ await asyncio.sleep(2**attempt)
+
+ async def _put_tarball(self, upload_url, reader, total, timeout) -> None:
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ async with session.put(
+ upload_url,
+ data=reader,
+ headers={
+ "Content-Type": "application/gzip",
+ "Content-Length": str(total),
+ },
+ ) as resp:
+ resp.raise_for_status()
diff --git a/runpod/apps/app.py b/runpod/apps/app.py
new file mode 100644
index 00000000..b62027b5
--- /dev/null
+++ b/runpod/apps/app.py
@@ -0,0 +1,328 @@
+"""the App registry: where a project declares its resources.
+
+ import runpod
+ from runpod import App, GpuType
+
+ app = App("my-app")
+
+ @app.queue(name="transcribe", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090)
+ async def transcribe(audio_url: str): ...
+
+`rp deploy` imports project modules, collects App instances from the
+module-level registry, and ships each app's resources. resolution of
+deployed resources is fully server-side (sentinel headers), so apps hold
+no persistent local state.
+"""
+
+import os
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+
+from .context import Context, current_context
+from .errors import EndpointNotFound, InvalidResourceError
+from .gpu import GpuLike
+from .handles import ApiHandle, FunctionHandle
+from .spec import (
+ DEFAULT_SCALER_VALUE,
+ ResourceKind,
+ ResourceSpec,
+ normalize_cpu,
+ normalize_cuda_version,
+ normalize_gpu,
+ normalize_scaler_type,
+ normalize_workers,
+)
+from .targets import InvocationTarget, PodTarget, SentinelTarget
+
+DEFAULT_ENV = "default"
+
+_REGISTRY: List["App"] = []
+
+
+def get_registered_apps() -> List["App"]:
+ """all App instances created in this process."""
+ return list(_REGISTRY)
+
+
+def _clear_registry() -> None:
+ """testing only."""
+ _REGISTRY.clear()
+
+
+class App:
+ """a named collection of deployable resources."""
+
+ def __init__(self, name: str, *, env: str = DEFAULT_ENV):
+ if not name or not isinstance(name, str):
+ raise InvalidResourceError("App name must be a non-empty string")
+ self.name = name
+ self.env = env
+ self._resources: Dict[str, Union[FunctionHandle, ApiHandle]] = {}
+ self._dev_events: Optional[object] = None
+ self._dev_targets: Dict[str, InvocationTarget] = {}
+ _REGISTRY.append(self)
+
+ @property
+ def resources(self) -> Dict[str, Union[FunctionHandle, ApiHandle]]:
+ return dict(self._resources)
+
+ def _register(self, name: str, handle: Union[FunctionHandle, ApiHandle]) -> None:
+ if name in self._resources:
+ raise InvalidResourceError(
+ f"duplicate resource name '{name}' in app '{self.name}'"
+ )
+ self._resources[name] = handle
+
+ def queue(
+ self,
+ *,
+ name: Optional[str] = None,
+ gpu: Optional[Union[GpuLike, List[GpuLike]]] = None,
+ cpu: Optional[Union[str, List[str]]] = None,
+ gpu_count: int = 1,
+ workers: Union[int, Tuple[int, int], None] = None,
+ idle_timeout: int = 60,
+ dependencies: Optional[List[str]] = None,
+ system_dependencies: Optional[List[str]] = None,
+ volume: Optional[Any] = None,
+ env: Optional[Dict[str, Any]] = None,
+ datacenter: Optional[Union[str, List[str]]] = None,
+ image: Optional[str] = None,
+ registry_auth: Optional[str] = None,
+ model: Optional[Any] = None,
+ max_concurrency: int = 1,
+ execution_timeout_ms: int = 0,
+ flashboot: bool = True,
+ scaler_type: Optional[str] = None,
+ scaler_value: int = DEFAULT_SCALER_VALUE,
+ min_cuda_version: Optional[str] = None,
+ accelerate_downloads: bool = True,
+ container_disk_gb: Optional[int] = None,
+ ) -> Callable[[Callable], FunctionHandle]:
+ """declare a queue-based serverless endpoint from a function."""
+
+ def decorator(fn: Callable) -> FunctionHandle:
+ spec = ResourceSpec(
+ kind=ResourceKind.QUEUE,
+ name=name or fn.__name__,
+ gpu=normalize_gpu(gpu),
+ cpu=normalize_cpu(cpu),
+ gpu_count=gpu_count,
+ workers=normalize_workers(workers),
+ idle_timeout=idle_timeout,
+ dependencies=dependencies,
+ system_dependencies=system_dependencies,
+ volume=_volume_ref(volume),
+ env=env,
+ datacenter=_datacenter_list(datacenter),
+ image=image,
+ registry_auth=registry_auth,
+ model=model,
+ max_concurrency=max_concurrency,
+ execution_timeout_ms=execution_timeout_ms,
+ flashboot=flashboot,
+ scaler_type=normalize_scaler_type(scaler_type),
+ scaler_value=scaler_value,
+ min_cuda_version=normalize_cuda_version(min_cuda_version),
+ accelerate_downloads=accelerate_downloads,
+ container_disk_gb=container_disk_gb,
+ )
+ handle = FunctionHandle(self, fn, spec)
+ self._register(spec.name, handle)
+ return handle
+
+ return decorator
+
+ def task(
+ self,
+ *,
+ name: Optional[str] = None,
+ gpu: Optional[Union[GpuLike, List[GpuLike]]] = None,
+ cpu: Optional[Union[str, List[str]]] = None,
+ gpu_count: int = 1,
+ dependencies: Optional[List[str]] = None,
+ system_dependencies: Optional[List[str]] = None,
+ volume: Optional[Any] = None,
+ env: Optional[Dict[str, Any]] = None,
+ image: Optional[str] = None,
+ registry_auth: Optional[str] = None,
+ datacenter: Optional[Union[str, List[str]]] = None,
+ min_cuda_version: Optional[str] = None,
+ accelerate_downloads: bool = True,
+ container_disk_gb: Optional[int] = None,
+ ) -> Callable[[Callable], FunctionHandle]:
+ """declare ephemeral pod compute from a function."""
+
+ def decorator(fn: Callable) -> FunctionHandle:
+ spec = ResourceSpec(
+ kind=ResourceKind.TASK,
+ name=name or fn.__name__,
+ gpu=normalize_gpu(gpu),
+ cpu=normalize_cpu(cpu),
+ gpu_count=gpu_count,
+ dependencies=dependencies,
+ system_dependencies=system_dependencies,
+ volume=_volume_ref(volume),
+ env=env,
+ image=image,
+ registry_auth=registry_auth,
+ datacenter=_datacenter_list(datacenter),
+ min_cuda_version=normalize_cuda_version(min_cuda_version),
+ accelerate_downloads=accelerate_downloads,
+ container_disk_gb=container_disk_gb,
+ )
+ handle = FunctionHandle(self, fn, spec)
+ self._register(spec.name, handle)
+ return handle
+
+ return decorator
+
+ def api(
+ self,
+ *,
+ name: Optional[str] = None,
+ gpu: Optional[Union[GpuLike, List[GpuLike]]] = None,
+ cpu: Optional[Union[str, List[str]]] = None,
+ gpu_count: int = 1,
+ workers: Union[int, Tuple[int, int], None] = None,
+ idle_timeout: int = 60,
+ dependencies: Optional[List[str]] = None,
+ system_dependencies: Optional[List[str]] = None,
+ volume: Optional[Any] = None,
+ env: Optional[Dict[str, Any]] = None,
+ datacenter: Optional[Union[str, List[str]]] = None,
+ image: Optional[str] = None,
+ registry_auth: Optional[str] = None,
+ model: Optional[Any] = None,
+ execution_timeout_ms: int = 0,
+ flashboot: bool = True,
+ scaler_type: Optional[str] = None,
+ scaler_value: int = DEFAULT_SCALER_VALUE,
+ min_cuda_version: Optional[str] = None,
+ accelerate_downloads: bool = True,
+ container_disk_gb: Optional[int] = None,
+ ) -> Callable[[Any], ApiHandle]:
+ """declare a load-balanced serverless endpoint.
+
+ decorate a class with route markers for the native experience, or
+ a zero-argument function returning an asgi app to serve an
+ existing fastapi/starlette application:
+
+ @app.api(name="inference", gpu=GpuType.NVIDIA_L4)
+ class Inference:
+ @init
+ def setup(self): self.model = load()
+
+ @post("/generate")
+ async def generate(self, body: dict): ...
+
+ @app.api(name="fast", cpu="cpu5c-2-4")
+ def web():
+ from fastapi import FastAPI
+ server = FastAPI()
+ ...
+ return server
+ """
+
+ def decorator(target: Any) -> ApiHandle:
+ spec = ResourceSpec(
+ kind=ResourceKind.API,
+ name=name or target.__name__,
+ gpu=normalize_gpu(gpu),
+ cpu=normalize_cpu(cpu),
+ gpu_count=gpu_count,
+ workers=normalize_workers(workers),
+ idle_timeout=idle_timeout,
+ dependencies=dependencies,
+ system_dependencies=system_dependencies,
+ volume=_volume_ref(volume),
+ env=env,
+ datacenter=_datacenter_list(datacenter),
+ image=image,
+ registry_auth=registry_auth,
+ model=model,
+ execution_timeout_ms=execution_timeout_ms,
+ flashboot=flashboot,
+ scaler_type=normalize_scaler_type(scaler_type),
+ scaler_value=scaler_value,
+ min_cuda_version=normalize_cuda_version(min_cuda_version),
+ accelerate_downloads=accelerate_downloads,
+ container_disk_gb=container_disk_gb,
+ )
+ handle = ApiHandle(self, target, spec)
+ self._register(spec.name, handle)
+ return handle
+
+ return decorator
+
+ async def _resolve(self, spec: ResourceSpec) -> InvocationTarget:
+ """resolve a resource spec to an invocation target."""
+ if spec.kind is ResourceKind.TASK:
+ handle = self._resources.get(spec.name)
+ fn = getattr(handle, "_fn", None)
+ return PodTarget(spec, fn, events=self._dev_events)
+
+ ctx = current_context()
+
+ if ctx is Context.DEV:
+ target = self._dev_targets.get(spec.name)
+ if target is not None:
+ return target
+ raise EndpointNotFound(self.name, spec.name)
+
+ if ctx is Context.WORKER and os.getenv("RUNPOD_DEV_APP") == self.name:
+ return await self._resolve_dev_sibling(spec)
+
+ env_name = os.getenv("FLASH_ENVIRONMENT") or self.env
+ return SentinelTarget(self.name, env_name, spec.name)
+
+ async def _resolve_dev_sibling(self, spec: ResourceSpec) -> InvocationTarget:
+ """find the dev endpoint for a sibling resource by name."""
+ from .targets import LiveTarget
+
+ target = self._dev_targets.get(spec.name)
+ if target is not None:
+ return target
+
+ from .api import AppsApiClient
+ from .dev import dev_endpoint_name
+
+ wanted = dev_endpoint_name(self.name, spec.name)
+ endpoints = await AppsApiClient().list_my_endpoints()
+ for endpoint in endpoints:
+ if endpoint["name"] == wanted:
+ target = LiveTarget(endpoint["id"], spec.name)
+ self._dev_targets[spec.name] = target
+ return target
+ raise EndpointNotFound(self.name, spec.name)
+
+ def __repr__(self) -> str:
+ return f""
+
+
+def _datacenter_list(
+ datacenter: Optional[Union[str, List[str]]],
+) -> Optional[List[str]]:
+ """normalize datacenter input to a list of location strings."""
+ if datacenter is None:
+ return None
+ if isinstance(datacenter, str):
+ return [datacenter]
+ return [str(d) for d in datacenter]
+
+
+def _volume_ref(volume: Any) -> Optional[Any]:
+ """validate a volume argument: a Volume, or a name/id string.
+
+ Volume objects pass through whole so creation config (size,
+ datacenter) survives to provision time.
+ """
+ if volume is None:
+ return None
+ from .volume import Volume
+
+ if isinstance(volume, (str, Volume)):
+ return volume
+ raise InvalidResourceError(
+ f"volume must be a runpod.Volume or a name/id string, "
+ f"got {type(volume).__name__}"
+ )
diff --git a/runpod/apps/auth.py b/runpod/apps/auth.py
new file mode 100644
index 00000000..5bca96d2
--- /dev/null
+++ b/runpod/apps/auth.py
@@ -0,0 +1,77 @@
+"""browser-approval login flow."""
+
+import asyncio
+import datetime as dt
+import os
+from typing import Any, Callable, Dict, Optional
+
+from .api import AppsApiClient
+from .errors import AppError
+
+CONSOLE_URL = os.environ.get("RUNPOD_CONSOLE_URL", "https://console.runpod.io")
+
+POLL_INTERVAL_SECONDS = 2.0
+DEFAULT_TIMEOUT_SECONDS = 600.0
+
+_APPROVED_STATUSES = frozenset({"APPROVED", "CONSUMED"})
+_FAILED_STATUSES = frozenset({"DENIED", "EXPIRED"})
+
+
+class LoginError(AppError):
+ pass
+
+
+def _parse_expires_at(value: Optional[str]) -> Optional[dt.datetime]:
+ if not value:
+ return None
+ try:
+ return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+
+
+def auth_url(request_id: str) -> str:
+ return f"{CONSOLE_URL}/flash/login?request={request_id}"
+
+
+async def browser_login(
+ *,
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
+ on_url: Optional[Callable[[str], None]] = None,
+ api: Optional[AppsApiClient] = None,
+) -> str:
+ """run the browser-approval flow and return the granted api key.
+
+ on_url is called with the approval url as soon as the request is
+ created (the caller renders/opens it).
+ """
+ client = api or AppsApiClient()
+ request: Dict[str, Any] = await client.create_auth_request()
+ request_id = request.get("id")
+
+ if not request_id:
+ raise LoginError("auth request failed to initialize")
+
+ if on_url is not None:
+ on_url(auth_url(request_id))
+
+ deadline = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=timeout_seconds)
+ expires_at = _parse_expires_at(request.get("expiresAt"))
+ if expires_at and expires_at < deadline:
+ deadline = expires_at
+
+ while True:
+ status_payload = await client.get_auth_request_status(request_id)
+ status = status_payload.get("status")
+ api_key = status_payload.get("apiKey")
+
+ if api_key and status in _APPROVED_STATUSES:
+ return api_key
+ if status in _FAILED_STATUSES:
+ raise LoginError(f"login {status.lower()}")
+ if status == "CONSUMED":
+ raise LoginError("login request was already used")
+ if dt.datetime.now(dt.timezone.utc) >= deadline:
+ raise LoginError("login timed out")
+
+ await asyncio.sleep(POLL_INTERVAL_SECONDS)
diff --git a/runpod/apps/build.py b/runpod/apps/build.py
new file mode 100644
index 00000000..53744903
--- /dev/null
+++ b/runpod/apps/build.py
@@ -0,0 +1,390 @@
+"""deploy-time environment build: vendor dependencies into the artifact."""
+
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from .errors import AppError
+from .images import DEFAULT_PYTHON_VERSION, SUPPORTED_PYTHON_VERSIONS
+
+log = logging.getLogger(__name__)
+
+# manylinux tags matching runpod worker glibc, newest first
+MANYLINUX_PLATFORMS = (
+ "manylinux_2_28_x86_64",
+ "manylinux_2_17_x86_64",
+ "manylinux2014_x86_64",
+)
+
+# CUDA-scale packages provided by the gpu worker images. excluded by
+# size, not by image contents: do not add packages just because an
+# image happens to ship them.
+SIZE_PROHIBITIVE_PACKAGES = frozenset(
+ {
+ "torch",
+ "torchvision",
+ "torchaudio",
+ "triton",
+ }
+)
+
+# the worker runtime's actual dependency closure, a small subset of the
+# runpod package's full dependency list. the runpod package is vendored
+# without its dependencies (sdk/cli-only packages like boto3, paramiko,
+# and cryptography never load in a worker), so these must be vendored
+# explicitly to keep a dependency-free worker small.
+RUNTIME_REQUIREMENTS = (
+ "aiohttp[speedups]",
+ "aiohttp-retry",
+ "backoff",
+ "cloudpickle",
+ "py-cpuinfo",
+ "requests",
+ "tomli",
+ "tomlkit",
+ "tqdm-loggable",
+)
+
+# api workers additionally serve over fastapi/uvicorn
+API_RUNTIME_REQUIREMENTS = ("fastapi[standard]", "uvicorn>=0.30")
+
+# runpod's serverless limit for build artifacts
+MAX_ARTIFACT_MB = 1500
+
+_REQ_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
+
+
+class BuildError(AppError):
+ """environment vendoring failed."""
+
+
+@dataclass
+class BuildResult:
+ """output of build_environment."""
+
+ env_dir: Path
+ requirements: List[str] = field(default_factory=list)
+ excluded: List[str] = field(default_factory=list)
+
+
+def requirement_name(requirement: str) -> str:
+ """distribution name from a requirement string (drops extras/pins)."""
+ match = _REQ_NAME_RE.match(requirement)
+ return (match.group(1) if match else requirement).lower().replace("_", "-")
+
+
+def collect_requirements(project_root: Path, app) -> List[str]:
+ """project requirements.txt plus per-resource dependencies, deduped."""
+ requirements: List[str] = []
+
+ req_file = project_root / "requirements.txt"
+ if req_file.exists():
+ for line in req_file.read_text().splitlines():
+ line = line.strip()
+ if line and not line.startswith("#") and not line.startswith("-"):
+ requirements.append(line)
+
+ for handle in app.resources.values():
+ for dep in handle.spec.dependencies or []:
+ requirements.append(dep)
+
+ seen = set()
+ unique = []
+ for req in requirements:
+ key = requirement_name(req)
+ if key not in seen:
+ seen.add(key)
+ unique.append(req)
+ return unique
+
+
+def split_exclusions(
+ requirements: List[str],
+ extra_excludes: Optional[List[str]] = None,
+ *,
+ auto_exclude: bool = True,
+) -> Tuple[List[str], List[str]]:
+ """(vendored, excluded-names). excluded packages resolve from the
+ worker image at runtime instead of the artifact.
+
+ auto_exclude applies the size-prohibitive set; it is only sound
+ when the workers run on builtin gpu images that preinstall those
+ packages. custom images vendor everything unless the user excludes
+ explicitly.
+ """
+ excluded_names = {requirement_name(e) for e in (extra_excludes or [])}
+ if auto_exclude:
+ excluded_names |= SIZE_PROHIBITIVE_PACKAGES
+ kept: List[str] = []
+ excluded: List[str] = []
+ for req in requirements:
+ name = requirement_name(req)
+ if name in excluded_names:
+ excluded.append(name)
+ else:
+ kept.append(req)
+ # size-prohibitive packages are expected from the image whether or
+ # not the user listed them, so the manifest always records the auto
+ # set that user requirements referenced
+ return kept, sorted(set(excluded))
+
+
+def _is_pinnable_name(spec: str) -> bool:
+ """true if the spec is a plain requirement (name, optional pin), as
+ opposed to a url, git ref, or filesystem path."""
+ return not any(token in spec for token in ("/", "\\", ":", "@")) or bool(
+ _REQ_NAME_RE.match(spec)
+ and re.match(r"^[A-Za-z0-9._-]+(\[[^\]]+\])?([<>=!~;].*)?$", spec)
+ )
+
+
+def runtime_requirement(scratch_dir: Path) -> str:
+ """the runpod runtime spec to vendor into the artifact.
+
+ RUNPOD_PACKAGE_SPEC overrides the published package (version pins,
+ git refs, tarball urls, local checkouts). non-index specs are
+ built into a wheel first so the platform-targeted vendoring
+ install can stay binary-only.
+
+ without an override the published package is vendored for its
+ dependency closure, then sync_running_package overlays the
+ client's own package tree so the worker always runs exactly the
+ client's code regardless of what pypi has.
+ """
+ spec = os.environ.get("RUNPOD_PACKAGE_SPEC", "runpod")
+ if _is_pinnable_name(spec):
+ return spec
+ return str(_build_wheel(spec, scratch_dir))
+
+
+def sync_running_package(env_dir: Path) -> None:
+ """overlay the running runpod package onto the vendored env.
+
+ the package is pure python (py3-none-any), so the client's
+ installed tree is valid on the worker platform. this pins the
+ vendored runtime to the exact client version: wire protocols,
+ manifest handling, and worker code can never drift, and prerelease
+ clients work before their version reaches pypi.
+ """
+ import runpod as _runpod
+
+ src = Path(_runpod.__file__).resolve().parent
+ dest = env_dir / "runpod"
+ shutil.copytree(
+ src,
+ dest,
+ ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
+ dirs_exist_ok=True,
+ )
+
+
+def _ensure_pip() -> None:
+ """make `python -m pip` work in venvs created without pip (uv)."""
+ probe = subprocess.run(
+ [sys.executable, "-m", "pip", "--version"],
+ capture_output=True,
+ )
+ if probe.returncode == 0:
+ return
+ bootstrap = subprocess.run(
+ [sys.executable, "-m", "ensurepip", "--upgrade"],
+ capture_output=True,
+ text=True,
+ )
+ if bootstrap.returncode != 0:
+ raise BuildError(
+ f"pip is not available in this environment and ensurepip "
+ f"failed: {bootstrap.stderr[-500:]}"
+ )
+
+
+def _build_wheel(spec: str, scratch_dir: Path) -> Path:
+ """build a wheel from a url/path spec for binary-only vendoring."""
+ _ensure_pip()
+ wheel_dir = scratch_dir / "wheels"
+ wheel_dir.mkdir(parents=True, exist_ok=True)
+ env = dict(os.environ)
+ # tarball specs (e.g. github archive urls) have no .git for
+ # setuptools-scm version inference; pretend a version so they build
+ env.setdefault("SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD", "0.0.0.dev0")
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "pip",
+ "wheel",
+ "--no-deps",
+ "-q",
+ "-w",
+ str(wheel_dir),
+ spec,
+ ],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if result.returncode != 0:
+ raise BuildError(
+ f"failed to build wheel from RUNPOD_PACKAGE_SPEC={spec!r}: "
+ f"{result.stderr[-2000:]}"
+ )
+ wheels = sorted(wheel_dir.glob("runpod-*.whl"))
+ if not wheels:
+ raise BuildError(
+ f"pip wheel produced no runpod wheel from {spec!r} "
+ f"(found: {[w.name for w in wheel_dir.glob('*.whl')]})"
+ )
+ return wheels[-1]
+
+
+def vendor(
+ env_dir: Path,
+ requirements: List[str],
+ python_version: str,
+ *,
+ no_deps: bool = False,
+ progress: Optional[callable] = None,
+) -> None:
+ """resolve requirements into env_dir for the worker platform.
+
+ progress, if given, is called as progress(count, package) each time
+ pip starts resolving another distribution.
+ """
+ if not requirements:
+ return
+ cmd = [
+ sys.executable,
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "--no-color",
+ "--target",
+ str(env_dir),
+ "--python-version",
+ python_version,
+ "--implementation",
+ "cp",
+ "--only-binary",
+ ":all:",
+ ]
+ for platform in MANYLINUX_PLATFORMS:
+ cmd.extend(["--platform", platform])
+ if no_deps:
+ cmd.append("--no-deps")
+ cmd.extend(requirements)
+
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+ count = 0
+ for line in proc.stdout:
+ line = line.strip()
+ # pip emits one "Collecting " per resolved distribution
+ if line.startswith("Collecting "):
+ count += 1
+ if progress is not None:
+ name = re.split(r"[><=!~\[ (]", line[11:], maxsplit=1)[0]
+ progress(count, name)
+ stderr = proc.stderr.read()
+ proc.wait()
+ if proc.returncode != 0:
+ raise BuildError(
+ f"dependency vendoring failed: {stderr[-3000:]}\n"
+ f"packages without linux wheels cannot be vendored; "
+ f"exclude them (they must then come from the image)"
+ )
+
+
+def _verify_runtime(env_dir: Path) -> None:
+ """the vendored runpod must include the worker runtimes."""
+ if not (env_dir / "runpod" / "runtimes").is_dir():
+ raise BuildError(
+ "the vendored runpod package has no runtimes modules; the "
+ "resolved version predates them. set RUNPOD_PACKAGE_SPEC to a "
+ "version that includes runpod.runtimes"
+ )
+
+
+def build_environment(
+ app,
+ project_root: Path,
+ *,
+ python_version: str = DEFAULT_PYTHON_VERSION,
+ exclude: Optional[List[str]] = None,
+ scratch_dir: Optional[Path] = None,
+ events: Optional[object] = None,
+) -> BuildResult:
+ """vendor the app's full runtime environment into a directory tree."""
+ if python_version not in SUPPORTED_PYTHON_VERSIONS:
+ raise BuildError(
+ f"python {python_version} is not supported "
+ f"(supported: {', '.join(SUPPORTED_PYTHON_VERSIONS)})"
+ )
+ scratch = scratch_dir or Path(tempfile.mkdtemp(prefix="rp-build-"))
+ env_dir = scratch / "env"
+ env_dir.mkdir(parents=True, exist_ok=True)
+
+ user_requirements = collect_requirements(project_root, app)
+ # size-prohibitive packages come preinstalled only on the builtin
+ # gpu images. custom images carry no guarantee, and builtin cpu
+ # images ship without them, so every resource must be gpu-on-builtin
+ # for auto-exclusion to be sound (the artifact is shared app-wide)
+ auto_exclude = all(
+ h.spec.gpu and not h.spec.image for h in app.resources.values()
+ )
+ vendored, excluded = split_exclusions(
+ user_requirements, exclude, auto_exclude=auto_exclude
+ )
+
+ from .spec import ResourceKind
+
+ runtime_deps = list(RUNTIME_REQUIREMENTS)
+ if any(h.spec.kind is ResourceKind.API for h in app.resources.values()):
+ runtime_deps.extend(API_RUNTIME_REQUIREMENTS)
+
+ package_spec = os.environ.get("RUNPOD_PACKAGE_SPEC")
+ progress = getattr(events, "vendor_progress", None)
+
+ # the runpod package ships without its own dependency closure; the
+ # worker runtime pulls only runtime_deps. with an override the pinned
+ # version is installed no-deps; otherwise the client's own package
+ # tree is overlaid below, so no index install of runpod is needed.
+ if package_spec:
+ vendor(
+ env_dir,
+ [runtime_requirement(scratch)],
+ python_version,
+ no_deps=True,
+ progress=progress,
+ )
+
+ all_requirements = runtime_deps + vendored
+ log.info(
+ "vendoring %d packages for python %s (%d excluded: %s)",
+ len(all_requirements),
+ python_version,
+ len(excluded),
+ ", ".join(excluded) or "none",
+ )
+ vendor(env_dir, all_requirements, python_version, progress=progress)
+
+ if not package_spec:
+ sync_running_package(env_dir)
+ _verify_runtime(env_dir)
+
+ return BuildResult(
+ env_dir=env_dir,
+ requirements=all_requirements,
+ excluded=excluded,
+ )
diff --git a/runpod/apps/context.py b/runpod/apps/context.py
new file mode 100644
index 00000000..198df12e
--- /dev/null
+++ b/runpod/apps/context.py
@@ -0,0 +1,86 @@
+"""execution context detection and the sync/async bridge."""
+
+import asyncio
+import os
+import threading
+from enum import Enum
+from typing import Any, Coroutine
+
+
+class Context(Enum):
+ """where the current process is running."""
+
+ LOCAL = "local"
+ """dev machine, plain `python main.py`."""
+
+ DEV = "dev"
+ """inside an `rp dev` session (ephemeral live provisioning)."""
+
+ WORKER = "worker"
+ """inside a runpod serverless endpoint or pod."""
+
+
+def current_context() -> Context:
+ """detect the execution context from the environment."""
+ if os.getenv("RUNPOD_ENDPOINT_ID") or os.getenv("RUNPOD_POD_ID"):
+ return Context.WORKER
+ if os.getenv("RUNPOD_DEV_SESSION"):
+ return Context.DEV
+ return Context.LOCAL
+
+
+def is_local() -> bool:
+ """true when not running inside a runpod container.
+
+ usable as a module-level guard so code only runs on the dev machine:
+
+ if runpod.is_local():
+ print("running or imported locally")
+ """
+ return current_context() is not Context.WORKER
+
+
+class _LoopThread:
+ """a dedicated background event loop for driving async engine code
+ from synchronous callers.
+
+ this makes `.remote()` safe to call both from plain sync code and from
+ inside an already-running event loop (where `asyncio.run` would raise).
+ """
+
+ _loop: "asyncio.AbstractEventLoop | None" = None
+ _lock = threading.Lock()
+
+ @classmethod
+ def _ensure_loop(cls) -> asyncio.AbstractEventLoop:
+ with cls._lock:
+ if cls._loop is None or cls._loop.is_closed():
+ loop = asyncio.new_event_loop()
+ thread = threading.Thread(
+ target=loop.run_forever,
+ name="runpod-apps-loop",
+ daemon=True,
+ )
+ thread.start()
+ cls._loop = loop
+ return cls._loop
+
+ @classmethod
+ def run(cls, coro: Coroutine[Any, Any, Any]) -> Any:
+ loop = cls._ensure_loop()
+ future = asyncio.run_coroutine_threadsafe(coro, loop)
+ try:
+ while True:
+ try:
+ return future.result(timeout=0.2)
+ except TimeoutError:
+ if future.done():
+ raise
+ except BaseException:
+ future.cancel()
+ raise
+
+
+def block(coro: Coroutine[Any, Any, Any]) -> Any:
+ """run a coroutine to completion from a synchronous caller."""
+ return _LoopThread.run(coro)
diff --git a/runpod/apps/datacenter.py b/runpod/apps/datacenter.py
new file mode 100644
index 00000000..d98236a9
--- /dev/null
+++ b/runpod/apps/datacenter.py
@@ -0,0 +1,58 @@
+"""datacenter selection for app resources.
+
+only datacenters with storage support and S3 API support are listed"""
+
+from enum import Enum
+from typing import List
+
+
+class DataCenter(str, Enum):
+
+ # north america
+ US_CA_2 = "US-CA-2"
+ US_IL_1 = "US-IL-1"
+ US_KS_2 = "US-KS-2"
+ US_MO_1 = "US-MO-1"
+ US_MO_2 = "US-MO-2"
+ US_NC_2 = "US-NC-2"
+ US_NE_1 = "US-NE-1"
+ US_WA_1 = "US-WA-1"
+
+ # europe
+ EU_CZ_1 = "EU-CZ-1"
+ EU_RO_1 = "EU-RO-1"
+ EUR_NO_1 = "EUR-NO-1"
+
+ @classmethod
+ def from_string(cls, value: str) -> "DataCenter":
+ """parse a datacenter id, accepting lowercase and underscores."""
+ normalized = value.strip().upper().replace("_", "-")
+
+ try:
+ return cls(normalized)
+ except ValueError:
+ valid = ", ".join(dc.value for dc in cls)
+ raise ValueError(
+ f"unknown datacenter '{value}'. valid datacenters: {valid}"
+ )
+
+ @classmethod
+ def all(cls) -> List["DataCenter"]:
+ return list(cls)
+
+
+# datacenters with high cpu serverless stock, restricted to the
+# storage+S3 set above. cpu5c/cpu5g are only stocked in EU-RO-1.
+CPU3_DATACENTERS: List[DataCenter] = [
+ DataCenter.EU_CZ_1,
+ DataCenter.EU_RO_1,
+ DataCenter.US_CA_2,
+ DataCenter.US_IL_1,
+ DataCenter.US_KS_2,
+ DataCenter.US_MO_1,
+ DataCenter.US_MO_2,
+ DataCenter.US_NC_2,
+ DataCenter.US_NE_1,
+ DataCenter.US_WA_1,
+]
+CPU5_DATACENTERS: List[DataCenter] = [DataCenter.EU_RO_1]
diff --git a/runpod/apps/deploy.py b/runpod/apps/deploy.py
new file mode 100644
index 00000000..461ff254
--- /dev/null
+++ b/runpod/apps/deploy.py
@@ -0,0 +1,518 @@
+"""deploy pipeline: discovered apps -> manifest -> artifact -> activated build."""
+
+import base64
+import fnmatch
+import json
+import logging
+import os
+import tarfile
+import tempfile
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import runpod
+
+from .api import AppsApiClient
+from .app import App
+from .build import (
+ DEFAULT_PYTHON_VERSION,
+ MAX_ARTIFACT_MB,
+ BuildError,
+ BuildResult,
+ build_environment,
+)
+from .errors import InvalidResourceError, ScheduleNotSupported
+from .schedule import SCHEDULES_ENABLED
+from .spec import ResourceKind
+from .utils.events import emit
+
+log = logging.getLogger(__name__)
+
+MANIFEST_VERSION = 1
+
+DEFAULT_IGNORES = [
+ ".git",
+ ".venv",
+ "venv",
+ "__pycache__",
+ "*.pyc",
+ ".runpod",
+ ".flash",
+ "node_modules",
+ ".DS_Store",
+ "*.tar.gz",
+]
+
+
+def _module_path_for(fn, project_root: Path) -> str:
+ """dotted import path for fn's file relative to the project root."""
+ import inspect
+
+ module = getattr(fn, "__module__", "") or ""
+ try:
+ source = Path(inspect.getfile(fn)).resolve()
+ rel = source.relative_to(project_root.resolve())
+ except (TypeError, ValueError, OSError):
+ return module
+ parts = list(rel.with_suffix("").parts)
+ if parts[-1] == "__init__":
+ parts = parts[:-1]
+ return ".".join(parts) or module
+
+
+@dataclass
+class DeployResult:
+ app_name: str
+ build_id: str
+ environment_id: str
+ resources: List[str]
+ endpoints: Dict[str, str] = field(default_factory=dict)
+
+
+def build_manifest(
+ app: App,
+ project_root: Path,
+ *,
+ python_version: str = DEFAULT_PYTHON_VERSION,
+ excluded_packages: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+ """serialize an app's resources for the coordinator and workers."""
+ resources = []
+
+ for handle in app.resources.values():
+ spec = handle.spec
+ if spec.schedule and not SCHEDULES_ENABLED:
+ raise ScheduleNotSupported()
+
+ if spec.kind in (ResourceKind.QUEUE, ResourceKind.API) and len(spec.name) < 3:
+ raise InvalidResourceError(
+ f"resource name '{spec.name}' is too short to deploy; "
+ f"queue and api names must be at least 3 characters"
+ )
+
+ entry = spec.to_manifest()
+
+ fn = (
+ getattr(handle, "_fn", None)
+ or getattr(handle, "_cls", None)
+ or getattr(handle, "__wrapped__", None)
+ )
+
+ if fn is not None:
+ entry["module"] = _module_path_for(fn, project_root)
+ entry["qualname"] = getattr(fn, "__qualname__", fn.__name__)
+ resources.append(entry)
+
+ manifest: Dict[str, Any] = {
+ "version": MANIFEST_VERSION,
+ "app": app.name,
+ "pythonVersion": python_version,
+ "resources": resources,
+ }
+
+ if excluded_packages:
+ manifest["excludedPackages"] = excluded_packages
+
+ return manifest
+
+
+def _load_ignores(project_root: Path) -> List[str]:
+ patterns = list(DEFAULT_IGNORES)
+ ignore_file = project_root / ".runpodignore"
+ if ignore_file.exists():
+ for line in ignore_file.read_text().splitlines():
+ line = line.strip()
+ if line and not line.startswith("#"):
+ patterns.append(line)
+ return patterns
+
+
+def _is_ignored(rel_path: str, patterns: List[str]) -> bool:
+ parts = rel_path.split("/")
+ for pattern in patterns:
+ if fnmatch.fnmatch(rel_path, pattern):
+ return True
+ if any(fnmatch.fnmatch(part, pattern) for part in parts):
+ return True
+ return False
+
+
+ENV_DIR_NAME = "env"
+
+
+def package_project(
+ project_root: Path,
+ manifest: Dict[str, Any],
+ output: Optional[Path] = None,
+ env_dir: Optional[Path] = None,
+) -> Path:
+ """tar source + vendored env + manifest into a build artifact.
+
+ layout inside the tarball:
+ {source files} project code, .runpodignore honored
+ env/ vendored site-packages tree
+ runpod_manifest.json
+ """
+ if output is None:
+ output = Path(tempfile.mkdtemp()) / "artifact.tar.gz"
+
+ patterns = _load_ignores(project_root)
+ env_resolved = env_dir.resolve() if env_dir is not None else None
+
+ with tarfile.open(output, "w:gz") as tar:
+ for path in sorted(project_root.rglob("*")):
+ if not path.is_file():
+ continue
+ if env_resolved is not None and env_resolved in path.resolve().parents:
+ continue
+ rel = path.relative_to(project_root).as_posix()
+ if _is_ignored(rel, patterns):
+ continue
+ if rel == ENV_DIR_NAME or rel.startswith(f"{ENV_DIR_NAME}/"):
+ continue
+ tar.add(path, arcname=rel)
+
+ if env_dir is not None and env_dir.is_dir():
+ for path in sorted(env_dir.rglob("*")):
+ if not path.is_file():
+ continue
+ rel = path.relative_to(env_dir).as_posix()
+ tar.add(path, arcname=f"{ENV_DIR_NAME}/{rel}")
+
+ manifest_bytes = json.dumps(manifest, indent=2).encode()
+ info = tarfile.TarInfo(name="runpod_manifest.json")
+ info.size = len(manifest_bytes)
+ import io
+
+ tar.addfile(info, io.BytesIO(manifest_bytes))
+
+ return output
+
+
+# gpu images have some hefty dependencies pre-installed
+CPU_CONTAINER_DISK_GB = 10
+GPU_CONTAINER_DISK_GB = 30
+
+
+def _container_disk_gb(spec) -> int:
+ if spec.container_disk_gb:
+ return spec.container_disk_gb
+ return CPU_CONTAINER_DISK_GB if spec.is_cpu else GPU_CONTAINER_DISK_GB
+
+
+def _deployed_endpoint_input(
+ app: App,
+ spec,
+ environment_id: str,
+ build_id: str,
+ python_version: str,
+) -> Dict[str, Any]:
+ """saveEndpoint payload for one deployed queue/api resource.
+
+ the endpoint name must equal the resource name: sentinel
+ resolution matches X-Flash-Endpoint against endpoint.name within
+ the environment. binding via flashEnvironmentId is also what makes
+ hosts deliver the build artifact to this endpoint's workers.
+ """
+ from .datacenter import CPU3_DATACENTERS, CPU5_DATACENTERS, DataCenter
+ from .images import image_for_spec
+
+ from .secret import render_env
+
+ template_env = {
+ "FLASH_RESOURCE_NAME": spec.name,
+ # version-triggering: a new build recreates all workers
+ "RUNPOD_BUILD_ID": build_id,
+ **render_env(spec.env),
+ }
+
+ # cross-resource calls from inside a worker go through the sentinel,
+ # which authenticates with the account api key; without it any
+ # .remote() from worker code would fail
+ api_key = runpod.api_key
+ if api_key and "RUNPOD_API_KEY" not in template_env:
+ template_env["RUNPOD_API_KEY"] = api_key
+
+ # workers that spawn tasks must select runtime images from the same
+ # channel the deploy used (the vendored env supplies the code, so
+ # only the image channel needs to travel)
+ tag = os.environ.get("RUNPOD_RUNTIME_TAG")
+ if tag and "RUNPOD_RUNTIME_TAG" not in template_env:
+ template_env["RUNPOD_RUNTIME_TAG"] = tag
+
+ if spec.max_concurrency > 1:
+ template_env["RUNPOD_MAX_CONCURRENCY"] = str(spec.max_concurrency)
+
+ payload: Dict[str, Any] = {
+ "name": spec.name,
+ "flashEnvironmentId": environment_id,
+ "workersMin": spec.workers[0],
+ "workersMax": spec.workers[1],
+ "idleTimeout": spec.idle_timeout,
+ "scalerType": spec.effective_scaler_type,
+ "scalerValue": spec.scaler_value,
+ "executionTimeoutMs": spec.execution_timeout_ms,
+ "template": {
+ "name": f"{app.name}-{spec.name}-template",
+ "imageName": image_for_spec(spec, python_version=python_version),
+ "containerDiskInGb": _container_disk_gb(spec),
+ "dockerArgs": "",
+ "env": [{"key": k, "value": v} for k, v in template_env.items()],
+ },
+ }
+
+ if spec.flashboot:
+ payload["flashBootType"] = "FLASHBOOT"
+
+ if spec.image:
+ # custom image: inject the bootstrap; the vendored env in the
+ # artifact provides the runtime once the bootstrap unpacks it
+ from .dev import _bootstrap_docker_args, _bootstrap_source
+
+ payload["template"]["env"].extend(
+ [
+ {
+ "key": "RUNPOD_BOOTSTRAP_B64",
+ "value": base64.b64encode(_bootstrap_source().encode()).decode(),
+ },
+ {"key": "RUNPOD_RUNTIME_KIND", "value": spec.kind.value},
+ ]
+ )
+ payload["template"]["dockerArgs"] = _bootstrap_docker_args()
+
+ if spec.kind is ResourceKind.API:
+ payload["type"] = "LB"
+ if spec.datacenter:
+ payload["locations"] = ",".join(spec.datacenter)
+ if spec.is_cpu:
+ payload["instanceIds"] = spec.cpu
+ if not spec.datacenter:
+ if any(i.startswith("cpu5") for i in spec.cpu or []):
+ payload["locations"] = ",".join(dc.value for dc in CPU5_DATACENTERS)
+ else:
+ payload["locations"] = ",".join(dc.value for dc in CPU3_DATACENTERS)
+ else:
+ from .gpu import gpu_ids_value
+
+ payload["gpuIds"] = gpu_ids_value(spec.gpu)
+ payload["gpuCount"] = spec.gpu_count
+ if spec.min_cuda_version:
+ payload["minCudaVersion"] = spec.min_cuda_version
+ if not spec.datacenter:
+ # artifact delivery rides the flash volume (network
+ # storage); machines outside storage-cluster regions fail
+ # with "requires network storage" and recycle forever
+ payload["locations"] = ",".join(dc.value for dc in DataCenter)
+ return payload
+
+
+async def reconcile_endpoints(
+ client: AppsApiClient,
+ app: App,
+ environment: Dict[str, Any],
+ build_id: str,
+ python_version: str,
+) -> Dict[str, str]:
+ """converge the environment's endpoints to the app's resources.
+
+ resource name -> endpoint id for everything provisioned. existing
+ endpoints (matched by name) are updated in place; endpoints whose
+ resources disappeared are deleted.
+ """
+ existing = {e["name"]: e["id"] for e in environment.get("endpoints") or []}
+ provisionable = {
+ h.spec.name: h
+ for h in app.resources.values()
+ if h.spec.kind in (ResourceKind.QUEUE, ResourceKind.API)
+ }
+
+ from .registry import resolve_registry_auth
+ from .volume import VolumeResolver
+
+ resolver = VolumeResolver(client)
+ endpoints: Dict[str, str] = {}
+ for name, handle in sorted(provisionable.items()):
+ payload = _deployed_endpoint_input(
+ app, handle.spec, environment["id"], build_id, python_version
+ )
+ await attach_endpoint_volumes(payload, handle.spec, resolver, app)
+ auth_id = await resolve_registry_auth(handle.spec.registry_auth, api=client)
+ if auth_id:
+ payload["template"]["containerRegistryAuthId"] = auth_id
+ if handle.spec.model:
+ from .model import model_reference
+
+ payload["modelReferences"] = [model_reference(handle.spec.model)]
+ if name in existing:
+ payload["id"] = existing[name]
+ result = await client.save_endpoint(payload)
+ endpoints[name] = result["id"]
+ log.info(
+ "%s endpoint %s (%s)",
+ "updated" if name in existing else "provisioned",
+ name,
+ result["id"],
+ )
+
+ for name, endpoint_id in existing.items():
+ if name not in provisionable:
+ await client.delete_endpoint(endpoint_id)
+ log.info("deleted removed endpoint %s (%s)", name, endpoint_id)
+
+ return endpoints
+
+
+async def attach_endpoint_volumes(payload: Dict[str, Any], spec, resolver, app) -> None:
+ """resolve a resource's volumes onto an endpoint payload.
+
+ endpoints may span regions: one volume per datacenter, locations
+ derived from the resolved volumes so lists cannot disagree.
+ """
+ from .volume import specs_sharing_volume, volume_list
+
+ volumes = volume_list(spec.volume)
+ if not volumes:
+ return
+ resolved = []
+ for volume in volumes:
+ sharing = specs_sharing_volume([app], volume.name) or [spec]
+ resolved.append(await resolver.resolve(volume, sharing))
+ payload["networkVolumeIds"] = [{"networkVolumeId": r["id"]} for r in resolved]
+ payload["locations"] = ",".join(dict.fromkeys(r["dataCenterId"] for r in resolved))
+
+
+def _phase(events, name: str, detail: str = "") -> None:
+ emit(events, "phase", name, detail)
+
+
+def build_artifact(
+ app: App,
+ project_root: Path,
+ *,
+ python_version: str = DEFAULT_PYTHON_VERSION,
+ exclude: Optional[List[str]] = None,
+ events: Optional[object] = None,
+ output: Optional[Path] = None,
+) -> Path:
+ """vendor, build the manifest, and package one app's artifact."""
+ _phase(events, "vendor", f"python {python_version}")
+ build: BuildResult = build_environment(
+ app,
+ project_root,
+ python_version=python_version,
+ exclude=exclude,
+ events=events,
+ )
+ manifest = build_manifest(
+ app,
+ project_root,
+ python_version=python_version,
+ excluded_packages=build.excluded,
+ )
+ _phase(events, "package")
+ tar_path = package_project(
+ project_root, manifest, output=output, env_dir=build.env_dir
+ )
+ size_mb = tar_path.stat().st_size / (1024 * 1024)
+ if size_mb > MAX_ARTIFACT_MB:
+ raise BuildError(
+ f"artifact is {size_mb:.0f} MB (limit {MAX_ARTIFACT_MB} MB). "
+ f"exclude large packages with --exclude (they must then come "
+ f"from the worker image) or trim project files via .runpodignore"
+ )
+ log.info("packaged %s (%.1f MB)", app.name, size_mb)
+ return tar_path
+
+
+async def deploy_app(
+ app: App,
+ project_root: Path,
+ *,
+ env_name: Optional[str] = None,
+ api: Optional[AppsApiClient] = None,
+ python_version: str = DEFAULT_PYTHON_VERSION,
+ exclude: Optional[List[str]] = None,
+ events: Optional[object] = None,
+) -> DeployResult:
+ """run the full deploy pipeline for one app."""
+ client = api or AppsApiClient()
+ env_name = env_name or app.env
+
+ # fail fast on unresolvable secret references (workers would boot
+ # with the literal template string otherwise)
+ from .secret import secret_names, validate_secrets
+
+ referenced = [
+ name
+ for handle in app.resources.values()
+ for name in secret_names(handle.spec.env)
+ ]
+ await validate_secrets(referenced, api=client)
+
+ _phase(events, "vendor", f"python {python_version}")
+ build: BuildResult = build_environment(
+ app,
+ project_root,
+ python_version=python_version,
+ exclude=exclude,
+ events=events,
+ )
+ manifest = build_manifest(
+ app,
+ project_root,
+ python_version=python_version,
+ excluded_packages=build.excluded,
+ )
+ _phase(events, "package")
+ tar_path = package_project(project_root, manifest, env_dir=build.env_dir)
+ tar_size = tar_path.stat().st_size
+ size_mb = tar_size / (1024 * 1024)
+ if size_mb > MAX_ARTIFACT_MB:
+ raise BuildError(
+ f"artifact is {size_mb:.0f} MB (limit {MAX_ARTIFACT_MB} MB). "
+ f"exclude large packages with --exclude (they must then come "
+ f"from the worker image) or trim project files via .runpodignore"
+ )
+ log.info("packaged %s (%.1f MB)", app.name, size_mb)
+
+ _phase(events, "upload", f"{size_mb:.1f} MB")
+ upload_progress = getattr(events, "upload_progress", None)
+ remote_app = await client.get_app_by_name(app.name)
+ if remote_app is None:
+ remote_app = await client.create_app(app.name)
+ log.info("created app %s (%s)", app.name, remote_app["id"])
+ app_id = remote_app["id"]
+
+ environments = {e["name"]: e for e in remote_app.get("flashEnvironments") or []}
+ environment = environments.get(env_name)
+ if environment is None:
+ environment = await client.create_environment(app_id, env_name)
+ log.info("created environment %s (%s)", env_name, environment["id"])
+
+ upload = await client.prepare_artifact_upload(app_id, tar_size)
+ await client.upload_tarball(
+ upload["uploadUrl"], str(tar_path), progress=upload_progress
+ )
+ build = await client.finalize_artifact_upload(app_id, upload["objectKey"], manifest)
+ log.info("uploaded build %s", build["id"])
+
+ await client.deploy_build(environment["id"], build["id"])
+ log.info("activated build %s on %s/%s", build["id"], app.name, env_name)
+
+ _phase(events, "endpoints")
+ endpoints = await reconcile_endpoints(
+ client, app, environment, build["id"], python_version
+ )
+ endpoint_ready = getattr(events, "endpoint_ready", None)
+ if endpoint_ready is not None:
+ for name, endpoint_id in sorted(endpoints.items()):
+ endpoint_ready(name, endpoint_id)
+
+ return DeployResult(
+ app_name=app.name,
+ build_id=build["id"],
+ environment_id=environment["id"],
+ resources=[r["name"] for r in manifest["resources"]],
+ endpoints=endpoints,
+ )
diff --git a/runpod/apps/dev.py b/runpod/apps/dev.py
new file mode 100644
index 00000000..c1480c80
--- /dev/null
+++ b/runpod/apps/dev.py
@@ -0,0 +1,455 @@
+"""dev session: ephemeral live endpoints for `rp dev`.
+
+lifecycle: get-or-create endpoints named dev-{app}-{resource} on the
+generic worker images (adopting leftovers from a killed session), run
+the local entrypoint, and delete every session endpoint on exit. the
+api is the only source of truth; nothing is persisted locally.
+"""
+
+import base64
+import logging
+from pathlib import Path
+from typing import Dict, List, Optional, Union
+
+from .api import AppsApiClient
+from .app import App
+from .handles import ApiHandle, FunctionHandle
+from .utils.events import emit
+from .spec import ResourceKind, ResourceSpec
+from .targets import LiveTarget
+
+log = logging.getLogger(__name__)
+
+DEV_PREFIX = "dev"
+
+import os as _os
+
+
+def dev_endpoint_name(app_name: str, resource_name: str) -> str:
+ """unique endpoint name for a dev (app, resource) pair.
+
+ dash-joined names alone are ambiguous (("a-b","c") vs ("a","b-c")),
+ so a short digest of the exact pair disambiguates.
+ """
+ import hashlib
+
+ digest = hashlib.sha256(
+ f"{app_name}/{resource_name}".encode()
+ ).hexdigest()[:6]
+ return f"{DEV_PREFIX}-{app_name}-{resource_name}-{digest}"
+
+
+def _resource_of(endpoint_name: str) -> str:
+ """resource name from a dev endpoint name (display only)."""
+ return endpoint_name.rsplit("-", 2)[-2]
+
+
+def _comparable(payload: Dict) -> Dict:
+ """payload normalized for change detection.
+
+ the generation env is bumped on every refresh by design; strip it so
+ only real configuration differences count.
+ """
+ import copy
+
+ clean = copy.deepcopy(payload)
+ clean.pop("id", None)
+ template = clean.get("template") or {}
+ template["env"] = [
+ e for e in template.get("env") or [] if e.get("key") != GENERATION_ENV
+ ]
+ return clean
+
+
+def _changed_fields(old: Dict, new: Dict) -> List[str]:
+ """human-readable names of top-level payload fields that differ."""
+ fields = []
+ for key in sorted(set(old) | set(new)):
+ if old.get(key) == new.get(key):
+ continue
+ if key == "template":
+ t_old, t_new = old.get(key) or {}, new.get(key) or {}
+ for sub in sorted(set(t_old) | set(t_new)):
+ if t_old.get(sub) != t_new.get(sub):
+ fields.append("image" if sub == "imageName" else sub)
+ else:
+ fields.append(key)
+ return fields
+
+
+def _image_for(spec: ResourceSpec) -> str:
+ """dev worker image: custom image, env override, or the builtin
+ runtime image matched to the local python (dev requests carry
+ pickled source, so client and worker versions should align)."""
+
+ if spec.image:
+ return spec.image
+
+ override = _os.getenv("RUNPOD_DEV_IMAGE")
+ if override:
+ return override
+
+ from .images import image_for_spec, local_python_version
+
+ return image_for_spec(spec, python_version=local_python_version())
+
+
+def _bootstrap_source() -> str:
+ return (Path(__file__).parent.parent / "runtimes" / "bootstrap.py").read_text()
+
+
+def _bootstrap_docker_args() -> str:
+ """shell command that materializes and starts the bootstrap on a
+ custom image."""
+ from .shim import shell_launcher
+
+ return shell_launcher("RUNPOD_BOOTSTRAP_B64", "/bootstrap.py")
+
+
+def _render_env(env):
+ from .secret import render_env
+
+ return render_env(env)
+
+
+def _client_api_key() -> str:
+ from .targets import _api_key
+
+ try:
+ return _api_key()
+ except RuntimeError:
+ # payload construction must not require credentials (tests,
+ # dry runs); the session itself fails loudly on its first call
+ return ""
+
+
+def _cpu_locations(instance_ids: List[str]) -> str:
+ """locations covering every requested cpu flavor's stock, limited to
+ datacenters with storage and S3 support. the flash images are
+ autocached on every host (host imagecache daemon, no DC filter), so
+ the widest supported spread maximizes provisioning odds."""
+ from .datacenter import CPU3_DATACENTERS, CPU5_DATACENTERS
+
+ if any(i.startswith("cpu5") for i in instance_ids):
+ return ",".join(dc.value for dc in CPU5_DATACENTERS)
+ return ",".join(dc.value for dc in CPU3_DATACENTERS)
+
+
+# template env var bumped on refresh; env is a version-triggering template
+# property, so changing it recreates all workers server-side
+GENERATION_ENV = "RUNPOD_DEV_GENERATION"
+
+
+def _endpoint_input(app: App, spec: ResourceSpec, generation: int = 1) -> Dict:
+ """build the saveEndpoint payload for a live dev endpoint.
+
+ the template is nested in the saveEndpoint input so it is bound to
+ the endpoint and cascades on deleteEndpoint.
+ """
+
+ payload: Dict = {
+ "name": dev_endpoint_name(app.name, spec.name),
+ "workersMin": spec.workers[0],
+ "workersMax": spec.workers[1],
+ "idleTimeout": spec.idle_timeout,
+ "scalerType": spec.effective_scaler_type,
+ "scalerValue": spec.scaler_value,
+ "executionTimeoutMs": spec.execution_timeout_ms,
+ "template": {
+ "name": f"{dev_endpoint_name(app.name, spec.name)}-template",
+ "imageName": _image_for(spec),
+ "containerDiskInGb": spec.container_disk_gb or (10 if spec.is_cpu else 30),
+ "dockerArgs": "",
+ "env": [
+ {"key": GENERATION_ENV, "value": str(generation)},
+ # nested .remote() calls from inside a dev worker need
+ # credentials and the dev-session marker to resolve
+ # sibling dev endpoints by name
+ *(
+ [{"key": "RUNPOD_API_KEY", "value": key}]
+ if (key := _client_api_key())
+ else []
+ ),
+ {"key": "RUNPOD_DEV_APP", "value": app.name},
+ *(
+ [
+ {
+ "key": "RUNPOD_MAX_CONCURRENCY",
+ "value": str(spec.max_concurrency),
+ }
+ ]
+ if spec.max_concurrency > 1
+ else []
+ ),
+ *({"key": k, "value": v} for k, v in _render_env(spec.env).items()),
+ ],
+ },
+ }
+
+ if spec.flashboot:
+ payload["flashBootType"] = "FLASHBOOT"
+
+ if spec.image:
+ # custom image: inject the bootstrap so the worker runtime starts
+ # regardless of what the image contains
+ payload["template"]["env"].extend(
+ [
+ {
+ "key": "RUNPOD_BOOTSTRAP_B64",
+ "value": base64.b64encode(_bootstrap_source().encode()).decode(),
+ },
+ {"key": "RUNPOD_RUNTIME_KIND", "value": spec.kind.value},
+ ]
+ )
+ # the bootstrap pip-installs the runpod package on bare images;
+ # forward a pinned spec (e.g. a git branch during prerelease)
+ package_spec = _os.environ.get("RUNPOD_PACKAGE_SPEC")
+ if package_spec:
+ payload["template"]["env"].append(
+ {"key": "RUNPOD_PACKAGE_SPEC", "value": package_spec}
+ )
+ payload["template"]["dockerArgs"] = _bootstrap_docker_args()
+ if spec.kind is ResourceKind.API:
+ payload["type"] = "LB"
+ if spec.datacenter:
+ payload["locations"] = ",".join(spec.datacenter)
+ if spec.is_cpu:
+ payload["instanceIds"] = spec.cpu
+ if not spec.datacenter:
+ payload["locations"] = _cpu_locations(spec.cpu or [])
+ else:
+ from .gpu import gpu_ids_value
+
+ payload["gpuIds"] = gpu_ids_value(spec.gpu)
+ payload["gpuCount"] = spec.gpu_count
+ if spec.min_cuda_version:
+ payload["minCudaVersion"] = spec.min_cuda_version
+ return payload
+
+
+class DevSession:
+ """owns the live endpoints for one `rp dev` invocation.
+
+ lifecycle: adopt-or-create by name on start, reconcile on refresh
+ (config updates + a generation bump that recreates workers so every
+ request after a code change runs fully fresh), delete on stop.
+
+ `events`, when provided, receives lifecycle callbacks:
+ provisioning(name, kind, hardware), adopted(name, id),
+ ready(name, id), refreshed(name, generation), deleted(name).
+ """
+
+ def __init__(
+ self,
+ apps: List[App],
+ api: Optional[AppsApiClient] = None,
+ events: Optional[object] = None,
+ ):
+ self.apps = apps
+ self.api = api or AppsApiClient()
+ self.generation = 1
+ self.events = events
+ # endpoint name -> id for everything this session owns
+ self._endpoints: Dict[str, str] = {}
+ # endpoint name -> comparable payload, for refresh diffing
+ self._payloads: Dict[str, Dict] = {}
+ # volumes resolve once per session (placement is stable)
+ self._volume_resolver = None
+
+ def _emit(self, event: str, *args) -> None:
+ emit(self.events, event, *args)
+
+ async def _attach_volumes(self, payload: Dict, spec, app) -> None:
+ """resolve the resource's volumes and registry auth onto a
+ dev endpoint payload."""
+ from .deploy import attach_endpoint_volumes
+ from .registry import resolve_registry_auth
+ from .volume import VolumeResolver
+
+ if spec.registry_auth:
+ auth_id = await resolve_registry_auth(spec.registry_auth, api=self.api)
+ payload["template"]["containerRegistryAuthId"] = auth_id
+ if spec.model:
+ from .model import model_reference
+
+ payload["modelReferences"] = [model_reference(spec.model)]
+ if not spec.volume:
+ return
+ if self._volume_resolver is None:
+ self._volume_resolver = VolumeResolver(self.api, events=self.events)
+ await attach_endpoint_volumes(payload, spec, self._volume_resolver, app)
+
+ @property
+ def _endpoint_ids(self) -> List[str]:
+ return list(self._endpoints.values())
+
+ def _provisionable(self, app: App) -> List[Union[FunctionHandle, ApiHandle]]:
+ """endpoints only; tasks have no standing infra to manage."""
+ return [
+ h
+ for h in app.resources.values()
+ if h.spec.kind in (ResourceKind.QUEUE, ResourceKind.API)
+ ]
+
+ async def start(self) -> None:
+ """provision (or adopt) a live endpoint per queue/api resource and
+ register the targets on each app."""
+ self._emit("session_starting")
+ from .secret import secret_names, validate_secrets
+
+ referenced = [
+ name
+ for app in self.apps
+ for handle in app.resources.values()
+ for name in secret_names(handle.spec.env)
+ ]
+ await validate_secrets(referenced, api=self.api)
+ for app in self.apps:
+ # task targets read the sink off the app at resolve time
+ app._dev_events = self.events
+ existing = {e["name"]: e for e in await self.api.list_my_endpoints()}
+
+ for app in self.apps:
+ for handle in self._provisionable(app):
+ spec = handle.spec
+ name = dev_endpoint_name(app.name, spec.name)
+ payload = _endpoint_input(app, spec, self.generation)
+ await self._attach_volumes(payload, spec, app)
+
+ hardware = ",".join(spec.cpu or spec.gpu or ["any"])
+ found = existing.get(name)
+ if found:
+ # adopt: reconcile the leftover endpoint to the
+ # current spec instead of creating a duplicate
+ self._emit("adopted", spec.name, found["id"])
+ payload["id"] = found["id"]
+ result = await self.api.save_endpoint(payload)
+ endpoint_id = result["id"]
+ log.info("adopted dev endpoint %s (%s)", name, endpoint_id)
+ else:
+ self._emit("provisioning", spec.name, spec.kind.value, hardware)
+ result = await self.api.save_endpoint(payload)
+ endpoint_id = result["id"]
+ log.info("provisioned dev endpoint %s (%s)", name, endpoint_id)
+
+ self._emit("ready", spec.name, endpoint_id)
+ self._endpoints[name] = endpoint_id
+ self._payloads[name] = _comparable(payload)
+ app._dev_targets[spec.name] = LiveTarget(
+ endpoint_id,
+ spec.name,
+ events=self.events,
+ metrics_key=result.get("aiKey"),
+ )
+
+ self._emit("session_started")
+
+ async def refresh(self, apps: List[App]) -> None:
+ """reconcile endpoints against a re-scanned set of apps.
+
+ every surviving endpoint gets the new config plus a bumped
+ generation env var; env is a version-triggering template
+ property, so the platform recreates all workers and subsequent
+ requests execute in fresh processes. added resources are
+ provisioned, removed ones deleted."""
+
+ self.generation += 1
+ self.apps = apps
+ for app in apps:
+ # re-scanned apps are fresh instances; re-attach the sink
+ # so task lifecycle events keep rendering after reloads
+ app._dev_events = self.events
+
+ desired: Dict[str, tuple] = {}
+ for app in apps:
+ for handle in self._provisionable(app):
+ name = dev_endpoint_name(app.name, handle.spec.name)
+ desired[name] = (app, handle)
+
+ # delete endpoints whose resources disappeared
+ for name in list(self._endpoints):
+ if name not in desired:
+ endpoint_id = self._endpoints.pop(name)
+ self._payloads.pop(name, None)
+ try:
+ await self.api.delete_endpoint(endpoint_id)
+ self._emit("resource_removed", _resource_of(name))
+ log.info("deleted removed dev endpoint %s", name)
+ except Exception as exc:
+ log.warning("failed to delete %s: %s", name, exc)
+
+ # update survivors (config + generation bump) and create additions
+ for name, (app, handle) in desired.items():
+ payload = _endpoint_input(app, handle.spec, self.generation)
+ await self._attach_volumes(payload, handle.spec, app)
+
+ existing_id = self._endpoints.get(name)
+ comparable = _comparable(payload)
+ previous = self._payloads.get(name)
+
+ if existing_id:
+ payload["id"] = existing_id
+
+ result = await self.api.save_endpoint(payload)
+ endpoint_id = result["id"]
+
+ self._endpoints[name] = endpoint_id
+ self._payloads[name] = comparable
+ app._dev_targets[handle.spec.name] = LiveTarget(
+ endpoint_id,
+ handle.spec.name,
+ events=self.events,
+ metrics_key=result.get("aiKey"),
+ )
+
+ spec = handle.spec
+ hardware = ",".join(spec.cpu or spec.gpu or ["any"])
+
+ if previous is None:
+ self._emit("resource_added", spec.name, spec.kind.value, hardware)
+ elif previous != comparable:
+ self._emit(
+ "resource_changed",
+ spec.name,
+ _changed_fields(previous, comparable),
+ )
+
+ log.info(
+ "refreshed dev endpoint %s (%s, generation %d)",
+ name,
+ endpoint_id,
+ self.generation,
+ )
+
+ async def stop(self, events: Optional[object] = None) -> None:
+ """delete every endpoint this session owns.
+
+ events, when given, overrides the session sink for teardown
+ rendering: cleanup_started(total), deleting(name), deleted(name),
+ delete_failed(name).
+ """
+ sink = events if events is not None else self.events
+
+ pending = list(self._endpoints.items())
+ emit(sink, "cleanup_started", len(pending))
+ for name, endpoint_id in pending:
+ resource = _resource_of(name)
+ emit(sink, "deleting", resource)
+ try:
+ await self.api.delete_endpoint(endpoint_id)
+ emit(sink, "deleted", resource)
+ log.info("deleted dev endpoint %s (%s)", name, endpoint_id)
+ except Exception as exc:
+ emit(sink, "delete_failed", resource)
+ log.warning("failed to delete dev endpoint %s: %s", endpoint_id, exc)
+ self._endpoints.clear()
+ for app in self.apps:
+ app._dev_targets.clear()
+ app._dev_events = None
+
+ async def __aenter__(self) -> "DevSession":
+ await self.start()
+ return self
+
+ async def __aexit__(self, *exc_info) -> None:
+ await self.stop()
diff --git a/runpod/apps/discovery.py b/runpod/apps/discovery.py
new file mode 100644
index 00000000..4179b6ff
--- /dev/null
+++ b/runpod/apps/discovery.py
@@ -0,0 +1,163 @@
+"""module discovery for `rp deploy` and `rp dev`.
+
+imports target modules under __name__ != "__main__" (so main guards
+never run) and collects App instances from the registry. a per-module
+import timeout guards against module-level code that blocks.
+"""
+
+import importlib.util
+import logging
+import os
+import sys
+import threading
+from pathlib import Path
+from typing import List
+
+from .app import App, get_registered_apps
+
+log = logging.getLogger(__name__)
+
+IMPORT_TIMEOUT_SECONDS = 30
+
+# set while discovery imports modules; handle invocation checks it so
+# module-level .remote()/.spawn() calls fail fast with a precise
+# diagnosis instead of attempting network calls mid-scan
+DISCOVERY_ENV = "RUNPOD_DISCOVERY_SCAN"
+
+
+def in_discovery() -> bool:
+ return bool(os.environ.get(DISCOVERY_ENV))
+
+
+class DiscoveryInvocationError(Exception):
+ """a handle was invoked at module level during a discovery scan."""
+
+ def __init__(self, resource_name: str):
+ super().__init__(
+ f"'{resource_name}' was invoked at import time. move calls "
+ f"into a function, an entrypoint, or an "
+ f'`if __name__ == "__main__":` guard so importing the file '
+ f"has no side effects."
+ )
+
+
+_SKIP_DIRS = {
+ ".git",
+ ".venv",
+ "venv",
+ "node_modules",
+ "__pycache__",
+ ".runpod",
+ ".flash",
+ "build",
+ "dist",
+}
+
+
+class DiscoveryError(Exception):
+ """a module failed to import during discovery."""
+
+
+def _python_files(target: Path) -> List[Path]:
+ if target.is_file():
+ if target.suffix != ".py":
+ raise DiscoveryError(f"{target} is not a python file")
+ return [target]
+ files = []
+ for path in sorted(target.rglob("*.py")):
+ if any(part in _SKIP_DIRS for part in path.parts):
+ continue
+ files.append(path)
+ return files
+
+
+def _import_module(path: Path) -> None:
+ """import one file as a uniquely-named module, never as __main__."""
+ module_name = f"_runpod_discovered_{path.stem}_{abs(hash(str(path)))}"
+ spec = importlib.util.spec_from_file_location(module_name, path)
+ if spec is None or spec.loader is None:
+ raise DiscoveryError(f"cannot load {path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[module_name] = module
+
+ error: List[BaseException] = []
+
+ def run() -> None:
+ try:
+ spec.loader.exec_module(module)
+ # BaseException on purpose: user module top-level code can raise
+ # SystemExit/KeyboardInterrupt; those must surface as discovery
+ # errors on the caller thread, not kill this worker thread
+ except BaseException as exc: # noqa: BLE001 - reported to caller
+ error.append(exc)
+
+ thread = threading.Thread(target=run, daemon=True)
+ thread.start()
+ thread.join(IMPORT_TIMEOUT_SECONDS)
+
+ if thread.is_alive():
+ raise DiscoveryError(
+ f"importing {path} timed out after {IMPORT_TIMEOUT_SECONDS}s; "
+ f"module-level code must not block (guard it with "
+ f'`if __name__ == "__main__":` or runpod.is_local())'
+ )
+ if error:
+ raise DiscoveryError(f"importing {path} failed: {error[0]}") from error[0]
+
+
+def discover_apps(target: Path) -> List[App]:
+ """import python files under target and return the apps they define.
+
+ a single-file target imports strictly and raises on any failure.
+ a directory walk is tolerant: files that fail to import are
+ collected as warnings, and discovery only fails outright when no
+ app was found anywhere (the failures are then the likely cause and
+ are included in the error).
+ """
+ before = set(id(a) for a in get_registered_apps())
+
+ sys_path_added = False
+ root = target if target.is_dir() else target.parent
+ root_str = str(root.resolve())
+ if root_str not in sys.path:
+ sys.path.insert(0, root_str)
+ sys_path_added = True
+
+ strict = target.is_file()
+ failures: List[str] = []
+ os.environ[DISCOVERY_ENV] = "1"
+ try:
+ for path in _python_files(target):
+ seen = set(id(a) for a in get_registered_apps())
+ try:
+ _import_module(path)
+ except DiscoveryError as exc:
+ if strict:
+ raise
+ failures.append(str(exc))
+ log.warning("%s", exc)
+ continue
+ # stamp fresh apps with their defining file so callers can
+ # report where each app came from
+ for app in get_registered_apps():
+ if id(app) not in seen and not hasattr(app, "_source_file"):
+ app._source_file = path
+ finally:
+ os.environ.pop(DISCOVERY_ENV, None)
+ if sys_path_added:
+ sys.path.remove(root_str)
+
+ found = [a for a in get_registered_apps() if id(a) not in before]
+
+ # dedupe
+ by_name = {}
+ for app in found:
+ by_name.setdefault(app.name, app)
+ found = list(by_name.values())
+
+ if not found and failures:
+ raise DiscoveryError(
+ "no runpod.App found; some files failed to import:\n "
+ + "\n ".join(failures)
+ )
+ return found
diff --git a/runpod/apps/entrypoint.py b/runpod/apps/entrypoint.py
new file mode 100644
index 00000000..e920308c
--- /dev/null
+++ b/runpod/apps/entrypoint.py
@@ -0,0 +1,43 @@
+"""@runpod.local_entrypoint: the dev-session entry hook.
+
+ @runpod.local_entrypoint
+ async def main():
+ result = transcribe.remote("https://...")
+
+the decorated function is registered so `rp dev ` can find and
+run it (sync or async). importing the module never executes it.
+"""
+
+import inspect
+from typing import Any, Callable, List, Optional
+
+_ENTRYPOINTS: List[Callable] = []
+
+
+def local_entrypoint(fn: Callable) -> Callable:
+ """register a function as the dev-session entrypoint.
+
+ the function is returned unwrapped so it stays directly callable.
+ """
+ _ENTRYPOINTS.append(fn)
+ return fn
+
+
+def get_entrypoint() -> Optional[Callable]:
+ """the most recently registered entrypoint, if any."""
+ return _ENTRYPOINTS[-1] if _ENTRYPOINTS else None
+
+
+def run_entrypoint(fn: Callable) -> Any:
+ """execute an entrypoint, driving the loop for async functions."""
+ from .context import block
+
+ result = fn()
+ if inspect.isawaitable(result):
+ return block(result)
+ return result
+
+
+def _clear_entrypoints() -> None:
+ """testing only."""
+ _ENTRYPOINTS.clear()
diff --git a/runpod/apps/errors.py b/runpod/apps/errors.py
new file mode 100644
index 00000000..acd3dd0a
--- /dev/null
+++ b/runpod/apps/errors.py
@@ -0,0 +1,35 @@
+"""errors raised by the apps surface."""
+
+
+class AppError(Exception):
+ """base error for the apps sdk."""
+
+
+class EndpointNotFound(AppError):
+ """a resource was invoked but is not deployed."""
+
+ def __init__(self, app_name: str, resource_name: str):
+ self.app_name = app_name
+ self.resource_name = resource_name
+ super().__init__(
+ f"'{resource_name}' in app '{app_name}' is not deployed. "
+ f"run `rp deploy` first, or use .local() to run it here."
+ )
+
+
+class RemoteExecutionError(AppError):
+ """the remote worker reported a failure executing the function."""
+
+
+class ScheduleNotSupported(AppError):
+ """@schedule requires backend support that is not yet available."""
+
+ def __init__(self) -> None:
+ super().__init__(
+ "recurring schedules are not yet supported by the runpod api. "
+ "the @schedule decorator records intent but cannot be deployed."
+ )
+
+
+class InvalidResourceError(AppError):
+ """a decorator was applied to an unsupported target or with bad config."""
diff --git a/runpod/apps/gpu.py b/runpod/apps/gpu.py
new file mode 100644
index 00000000..21a261fb
--- /dev/null
+++ b/runpod/apps/gpu.py
@@ -0,0 +1,225 @@
+"""gpu and cpu selection enums.
+
+GpuGroup values are backend pool ids; GpuType values are exact device
+display names. either (or plain strings) are accepted anywhere a gpu is
+configured. CpuInstanceType values are backend cpu flavor ids.
+"""
+
+from enum import Enum
+from typing import List, Optional, Union
+
+
+class GpuGroup(Enum):
+ ANY = "any"
+ """any gpu"""
+
+ ADA_24 = "ADA_24"
+ """NVIDIA GeForce RTX 4090"""
+
+ ADA_32_PRO = "ADA_32_PRO"
+ """NVIDIA GeForce RTX 5090"""
+
+ ADA_48_PRO = "ADA_48_PRO"
+ """NVIDIA RTX 6000 Ada Generation, NVIDIA L40, NVIDIA L40S"""
+
+ ADA_80_PRO = "ADA_80_PRO"
+ """NVIDIA H100 PCIe, NVIDIA H100 80GB HBM3, NVIDIA H100 NVL"""
+
+ AMPERE_16 = "AMPERE_16"
+ """NVIDIA RTX A4000, NVIDIA RTX A4500, NVIDIA RTX 4000 Ada Generation, NVIDIA RTX 2000 Ada Generation"""
+
+ AMPERE_24 = "AMPERE_24"
+ """NVIDIA RTX A5000, NVIDIA L4, NVIDIA GeForce RTX 3090"""
+
+ AMPERE_48 = "AMPERE_48"
+ """NVIDIA A40, NVIDIA RTX A6000"""
+
+ AMPERE_80 = "AMPERE_80"
+ """NVIDIA A100 80GB PCIe, NVIDIA A100-SXM4-80GB"""
+
+ HOPPER_141 = "HOPPER_141"
+ """NVIDIA H200"""
+
+ BLACKWELL_96 = "BLACKWELL_96"
+ """NVIDIA RTX PRO 6000 Blackwell Server/Workstation/Max-Q editions"""
+
+ BLACKWELL_180 = "BLACKWELL_180"
+ """NVIDIA B200"""
+
+ @classmethod
+ def all(cls) -> List["GpuGroup"]:
+ """all gpu groups except ANY."""
+ return [g for g in cls if g is not cls.ANY]
+
+ def device_names(self) -> List[str]:
+ """exact device display names in this pool.
+
+ serverless endpoints take pool ids directly (gpuIds), but pods
+ take device names (gpuTypeIdList), so tasks need the expansion.
+ """
+ return [t.value for t in POOLS_TO_TYPES.get(self, [])]
+
+
+class GpuType(Enum):
+ ANY = "any"
+ """any gpu"""
+
+ NVIDIA_GEFORCE_RTX_4090 = "NVIDIA GeForce RTX 4090"
+ NVIDIA_GEFORCE_RTX_5090 = "NVIDIA GeForce RTX 5090"
+ NVIDIA_RTX_6000_ADA_GENERATION = "NVIDIA RTX 6000 Ada Generation"
+ NVIDIA_RTX_PRO_6000_BLACKWELL_SERVER_EDITION = (
+ "NVIDIA RTX PRO 6000 Blackwell Server Edition"
+ )
+ NVIDIA_RTX_PRO_6000_BLACKWELL_WORKSTATION_EDITION = (
+ "NVIDIA RTX PRO 6000 Blackwell Workstation Edition"
+ )
+ NVIDIA_RTX_PRO_6000_BLACKWELL_MAX_Q_WORKSTATION_EDITION = (
+ "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition"
+ )
+ NVIDIA_H100_80GB_HBM3 = "NVIDIA H100 80GB HBM3"
+ NVIDIA_RTX_A4000 = "NVIDIA RTX A4000"
+ NVIDIA_RTX_A4500 = "NVIDIA RTX A4500"
+ NVIDIA_RTX_4000_ADA_GENERATION = "NVIDIA RTX 4000 Ada Generation"
+ NVIDIA_RTX_2000_ADA_GENERATION = "NVIDIA RTX 2000 Ada Generation"
+ NVIDIA_RTX_A5000 = "NVIDIA RTX A5000"
+ NVIDIA_L4 = "NVIDIA L4"
+ NVIDIA_GEFORCE_RTX_3090 = "NVIDIA GeForce RTX 3090"
+ NVIDIA_A40 = "NVIDIA A40"
+ NVIDIA_RTX_A6000 = "NVIDIA RTX A6000"
+ NVIDIA_A100_80GB_PCIe = "NVIDIA A100 80GB PCIe"
+ NVIDIA_A100_SXM4_80GB = "NVIDIA A100-SXM4-80GB"
+ NVIDIA_H200 = "NVIDIA H200"
+ NVIDIA_B200 = "NVIDIA B200"
+
+ @classmethod
+ def all(cls) -> List["GpuType"]:
+ """all gpu types except ANY."""
+ return [g for g in cls if g is not cls.ANY]
+
+
+POOLS_TO_TYPES = {
+ GpuGroup.ADA_24: [GpuType.NVIDIA_GEFORCE_RTX_4090],
+ GpuGroup.ADA_32_PRO: [GpuType.NVIDIA_GEFORCE_RTX_5090],
+ GpuGroup.ADA_48_PRO: [GpuType.NVIDIA_RTX_6000_ADA_GENERATION],
+ GpuGroup.ADA_80_PRO: [GpuType.NVIDIA_H100_80GB_HBM3],
+ GpuGroup.AMPERE_16: [
+ GpuType.NVIDIA_RTX_A4000,
+ GpuType.NVIDIA_RTX_A4500,
+ GpuType.NVIDIA_RTX_4000_ADA_GENERATION,
+ GpuType.NVIDIA_RTX_2000_ADA_GENERATION,
+ ],
+ GpuGroup.AMPERE_24: [
+ GpuType.NVIDIA_RTX_A5000,
+ GpuType.NVIDIA_L4,
+ GpuType.NVIDIA_GEFORCE_RTX_3090,
+ ],
+ GpuGroup.AMPERE_48: [GpuType.NVIDIA_A40, GpuType.NVIDIA_RTX_A6000],
+ GpuGroup.AMPERE_80: [
+ GpuType.NVIDIA_A100_80GB_PCIe,
+ GpuType.NVIDIA_A100_SXM4_80GB,
+ ],
+ GpuGroup.HOPPER_141: [GpuType.NVIDIA_H200],
+ GpuGroup.BLACKWELL_96: [
+ GpuType.NVIDIA_RTX_PRO_6000_BLACKWELL_SERVER_EDITION,
+ GpuType.NVIDIA_RTX_PRO_6000_BLACKWELL_WORKSTATION_EDITION,
+ GpuType.NVIDIA_RTX_PRO_6000_BLACKWELL_MAX_Q_WORKSTATION_EDITION,
+ ],
+ GpuGroup.BLACKWELL_180: [GpuType.NVIDIA_B200],
+}
+
+
+def pool_for_gpu_type(gpu_type: GpuType) -> Optional[GpuGroup]:
+ """the pool a specific gpu type belongs to, if any."""
+ for group, types in POOLS_TO_TYPES.items():
+ if gpu_type in types:
+ return group
+ return None
+
+
+class CpuInstanceType(str, Enum):
+ """cpu instance flavors: {generation}{type}-{vcpu}-{memory_gb}."""
+
+ CPU3G_1_4 = "cpu3g-1-4"
+ CPU3G_2_8 = "cpu3g-2-8"
+ CPU3G_4_16 = "cpu3g-4-16"
+ CPU3G_8_32 = "cpu3g-8-32"
+ CPU3C_1_2 = "cpu3c-1-2"
+ CPU3C_2_4 = "cpu3c-2-4"
+ CPU3C_4_8 = "cpu3c-4-8"
+ CPU3C_8_16 = "cpu3c-8-16"
+ CPU5C_1_2 = "cpu5c-1-2"
+ CPU5C_2_4 = "cpu5c-2-4"
+ CPU5C_4_8 = "cpu5c-4-8"
+ CPU5C_8_16 = "cpu5c-8-16"
+
+
+GpuLike = Union[GpuGroup, GpuType, str]
+
+
+def resolve_gpu_string(value: str) -> List[str]:
+ """resolve a user-supplied gpu string to api-facing values.
+
+ accepted forms, in match order:
+ - "any"
+ - pool ids ("ADA_24", case-insensitive)
+ - exact device names ("NVIDIA B200")
+ - enum-style names ("NVIDIA_B200")
+ - shorthand device fragments ("B200", "4090", "h100"): every
+ device whose name contains the fragment matches, so "H100"
+ selects all H100 variants
+
+ unknown strings raise with the full list of valid options.
+ """
+ text = value.strip()
+ if text.lower() == "any":
+ return [GpuGroup.ANY.value]
+
+ upper = text.upper()
+ for group in GpuGroup.all():
+ if group.value.upper() == upper:
+ return [group.value]
+
+ for gpu_type in GpuType.all():
+ if gpu_type.value.upper() == upper or gpu_type.name == upper:
+ return [gpu_type.value]
+
+ fragment = upper.replace("_", " ")
+ matches = [
+ gpu_type.value
+ for gpu_type in GpuType.all()
+ if fragment in gpu_type.value.upper()
+ ]
+ if matches:
+ return matches
+
+ from .errors import InvalidResourceError
+
+ pools = ", ".join(g.value for g in GpuGroup.all())
+ devices = ", ".join(t.value for t in GpuType.all())
+ raise InvalidResourceError(
+ f"unknown gpu '{value}'. use a pool id ({pools}), a device "
+ f"name ({devices}), or a fragment like '4090' or 'H100'"
+ )
+
+
+def gpu_ids_value(gpu: Optional[List[str]]) -> str:
+ """the gpuIds string for an endpoint payload.
+
+ "any gpu" (no selection, or the ANY sentinel) means every pool id:
+ the api has no wildcard and rejects "any". device names map back
+ to their pool (endpoints select by pool, not device).
+ """
+ if not gpu or any(str(g).lower() == "any" for g in gpu):
+ return ",".join(g.value for g in GpuGroup.all())
+ pools: List[str] = []
+ for entry in gpu:
+ value = str(entry)
+ try:
+ pool = pool_for_gpu_type(GpuType(value))
+ value = pool.value if pool is not None else value
+ except ValueError:
+ # not a known GpuType: pass the raw pool/id string through
+ pass
+ if value not in pools:
+ pools.append(value)
+ return ",".join(pools)
diff --git a/runpod/apps/handles.py b/runpod/apps/handles.py
new file mode 100644
index 00000000..08e8fca3
--- /dev/null
+++ b/runpod/apps/handles.py
@@ -0,0 +1,329 @@
+"""handles returned by app decorators.
+
+a handle replaces the decorated object and is the single way to interact
+with the resource:
+
+ @app.queue(name="transcribe", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090)
+ async def transcribe(audio_url: str): ...
+
+ transcribe.remote("https://...") # sync remote call
+ await transcribe.remote.aio("https://...")
+ transcribe.spawn("https://...") # fire and forget -> job handle
+ transcribe.local("https://...") # run the body here
+
+ for chunk in generate.stream(prompt="hi"): # generator functions
+ ...
+
+ @transcribe.init
+ def load_model(): ... # worker startup hook
+"""
+
+import inspect
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ AsyncIterator,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Type,
+)
+
+from .context import Context, current_context
+from .errors import InvalidResourceError
+from .invoker import Invoker, StreamInvoker
+from .job import Job
+from .markers import INIT_ATTR, is_init, route_of
+from .schedule import SCHEDULE_ATTR
+from .spec import ResourceKind, ResourceSpec, RouteSpec
+
+if TYPE_CHECKING:
+ from .app import App
+
+
+class FunctionHandle:
+ """handle for @app.queue and @app.task functions."""
+
+ def __init__(
+ self,
+ app: "App",
+ fn: Callable,
+ spec: ResourceSpec,
+ ):
+ self._app = app
+ self._fn = fn
+ self.spec = spec
+ self._init_fn: Optional[Callable] = None
+ # per-job options merged into the run payload; set by with_options
+ self._job_options: Dict[str, Any] = {}
+
+ # adopt a schedule stamped by @schedule below the app decorator
+ stamped = getattr(fn, SCHEDULE_ATTR, None)
+ if stamped and not spec.schedule:
+ spec.schedule = stamped
+
+ self.remote = Invoker(self._remote_async)
+ self.stream = StreamInvoker(self._stream_async)
+ self.spawn = Invoker(self._spawn_async)
+ self.job = Invoker(self._job_async)
+
+ self.__name__ = getattr(fn, "__name__", spec.name)
+ self.__doc__ = getattr(fn, "__doc__", None)
+ self.__wrapped__ = fn
+
+ def with_options(
+ self,
+ *,
+ webhook: Optional[str] = None,
+ execution_timeout: Optional[int] = None,
+ ttl: Optional[int] = None,
+ low_priority: Optional[bool] = None,
+ s3_config: Optional[Dict[str, Any]] = None,
+ ) -> "FunctionHandle":
+ """bind per-job options for the next call, returning a new handle.
+
+ transcribe.with_options(webhook="https://...").spawn(url)
+
+ options apply per invocation and stack across chained calls;
+ the original handle is untouched. queue only: tasks run on a
+ dedicated pod and take no job payload options.
+ """
+ from .targets import build_job_options, merge_job_options
+
+ if self.spec.kind is ResourceKind.TASK:
+ raise InvalidResourceError(
+ "per-job options apply only to @app.queue functions; "
+ "tasks run on a dedicated pod"
+ )
+ new_options = build_job_options(
+ webhook, execution_timeout, ttl, low_priority, s3_config
+ )
+ clone = FunctionHandle(self._app, self._fn, self.spec)
+ clone._init_fn = self._init_fn
+ clone._job_options = merge_job_options(self._job_options, new_options)
+ return clone
+
+ def init(self, fn: Callable) -> Callable:
+ """register a worker-startup hook; runs before the worker is ready,
+ never locally."""
+ setattr(fn, INIT_ATTR, True)
+ self._init_fn = fn
+ return fn
+
+ def local(self, *args: Any, **kwargs: Any) -> Any:
+ """run the function body here. returns a coroutine iff the
+ function is async."""
+ return self._fn(*args, **kwargs)
+
+ async def _remote_async(self, *args: Any, **kwargs: Any) -> Any:
+ self._guard_discovery()
+ ctx = current_context()
+
+ if ctx is Context.WORKER and self._is_current_worker():
+ result = self._fn(*args, **kwargs)
+ if inspect.isawaitable(result):
+ result = await result
+ # generators aggregate, matching the deployed worker's
+ # return_aggregate_stream output for .remote()
+ if inspect.isasyncgen(result):
+ return [chunk async for chunk in result]
+ if inspect.isgenerator(result):
+ return list(result)
+ return result
+
+ target = await self._app._resolve(self.spec)
+ payload = target.build_payload(self._fn, self.spec, args, kwargs)
+ return await target.invoke(self._apply_options(payload))
+
+ async def _stream_async(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
+ """invoke a generator function remotely, yielding partial outputs
+ as the worker produces them."""
+ self._guard_discovery()
+ self._guard_generator()
+ ctx = current_context()
+
+ if ctx is Context.WORKER and self._is_current_worker():
+ gen = self._fn(*args, **kwargs)
+ if inspect.isasyncgen(gen):
+ async for chunk in gen:
+ yield chunk
+ else:
+ for chunk in gen:
+ yield chunk
+ return
+
+ target = await self._app._resolve(self.spec)
+ payload = target.build_payload(self._fn, self.spec, args, kwargs)
+ data = await target.submit(self._apply_options(payload))
+ async for chunk in target.stream_job(data["id"]):
+ yield chunk
+
+ def _guard_generator(self) -> None:
+ if self.spec.kind is ResourceKind.TASK:
+ raise InvalidResourceError(
+ "tasks do not stream; use @app.queue for generator functions"
+ )
+ if not (
+ inspect.isgeneratorfunction(self._fn)
+ or inspect.isasyncgenfunction(self._fn)
+ ):
+ raise InvalidResourceError(
+ f"'{self.__name__}' is not a generator function; "
+ f"use .remote(...) instead"
+ )
+
+ def _apply_options(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if not self._job_options:
+ return payload
+ return {**payload, **self._job_options}
+
+ async def _spawn_async(self, *args: Any, **kwargs: Any) -> Any:
+ self._guard_discovery()
+ target = await self._app._resolve(self.spec)
+ payload = target.build_payload(self._fn, self.spec, args, kwargs)
+ data = await target.submit(self._apply_options(payload))
+ # queue targets return raw job data; task targets return a TaskJob
+ if isinstance(data, dict):
+ return Job(data, target)
+ return data
+
+ async def _job_async(self, job_id: str) -> Job:
+ """reconnect to a submitted queue job by id."""
+ if self.spec.kind is ResourceKind.TASK:
+ raise InvalidResourceError(
+ "task jobs cannot be reconnected by id; retain the job "
+ "returned by .spawn()"
+ )
+ target = await self._app._resolve(self.spec)
+ return Job({"id": job_id, "status": "UNKNOWN"}, target)
+
+ def _guard_discovery(self) -> None:
+ from .discovery import DiscoveryInvocationError, in_discovery
+
+ if in_discovery():
+ raise DiscoveryInvocationError(self.spec.name)
+
+ def _is_current_worker(self) -> bool:
+ import os
+
+ current = os.getenv("FLASH_RESOURCE_NAME") or os.getenv("RUNPOD_RESOURCE_NAME")
+ return current is not None and current == self.spec.name
+
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
+ raise TypeError(
+ f"'{self.__name__}' is a runpod {self.spec.kind.value} handle. "
+ f"use .remote(...) to execute remotely, .local(...) to run here, "
+ f"or .spawn(...) to fire and forget."
+ )
+
+ def __repr__(self) -> str:
+ return (
+ f"<{type(self).__name__} {self.spec.kind.value}:{self.spec.name} "
+ f"app={self._app.name!r}>"
+ )
+
+
+class _RouteCaller:
+ """client-side http verb on an ApiHandle: Inference.post("/x", body)."""
+
+ __slots__ = ("_handle", "_method")
+
+ def __init__(self, handle: "ApiHandle", method: str):
+ self._handle = handle
+ self._method = method
+
+ def __call__(self, path: str, body: Any = None, **kwargs: Any) -> Any:
+ from .context import block
+
+ return block(self.aio(path, body, **kwargs))
+
+ async def aio(self, path: str, body: Any = None, **kwargs: Any) -> Any:
+ from .discovery import DiscoveryInvocationError, in_discovery
+
+ if in_discovery():
+ raise DiscoveryInvocationError(self._handle.spec.name)
+ target = await self._handle._app._resolve(self._handle.spec)
+ # live targets need the module source to materialize the api
+ # server-side; other targets ignore the reference
+ setter = getattr(target, "attach_source", None)
+ if setter is not None:
+ setter(
+ self._handle.__wrapped__,
+ self._handle.spec.name,
+ self._handle.spec,
+ )
+ return await target.request(self._method, path, body, **kwargs)
+
+
+class ApiHandle:
+ """handle for @app.api classes and asgi factories.
+
+ route registration happens inside the decorated class via markers
+ (@get/@post/...); the handle only ever makes client calls, so there is
+ no decorator/client dual mode.
+ """
+
+ def __init__(self, app: "App", target: Any, spec: ResourceSpec):
+ self._app = app
+ self.spec = spec
+ self._cls: Optional[Type] = None
+ self._asgi_factory: Optional[Callable] = None
+ self._init_name: Optional[str] = None
+
+ if inspect.isclass(target):
+ self._cls = target
+ self._collect_routes(target)
+ elif callable(target):
+ self._asgi_factory = target
+ spec.asgi_factory = getattr(target, "__qualname__", target.__name__)
+ else:
+ raise InvalidResourceError(
+ "@app.api must decorate a class with route markers or a "
+ "zero-argument function returning an asgi app"
+ )
+
+ self.get = _RouteCaller(self, "GET")
+ self.post = _RouteCaller(self, "POST")
+ self.put = _RouteCaller(self, "PUT")
+ self.delete = _RouteCaller(self, "DELETE")
+ self.patch = _RouteCaller(self, "PATCH")
+
+ self.__name__ = getattr(target, "__name__", spec.name)
+ self.__doc__ = getattr(target, "__doc__", None)
+ self.__wrapped__ = target
+
+ def _collect_routes(self, cls: Type) -> None:
+ seen: Dict[tuple, str] = {}
+ routes: List[RouteSpec] = []
+ for name, member in inspect.getmembers(cls, callable):
+ route = route_of(member)
+ if route is not None:
+ method, path = route
+ if (method, path) in seen:
+ raise InvalidResourceError(
+ f"duplicate route {method} {path} on {cls.__name__}: "
+ f"'{seen[(method, path)]}' and '{name}'"
+ )
+ seen[(method, path)] = name
+ routes.append(RouteSpec(method=method, path=path, handler_name=name))
+ if is_init(member):
+ if self._init_name is not None and self._init_name != name:
+ raise InvalidResourceError(
+ f"multiple @init methods on {cls.__name__}: "
+ f"'{self._init_name}' and '{name}'"
+ )
+ self._init_name = name
+ if not routes:
+ raise InvalidResourceError(
+ f"@app.api class {cls.__name__} defines no routes; mark "
+ f"methods with @get/@post/@put/@delete/@patch"
+ )
+ self.spec.routes = routes
+
+ def __repr__(self) -> str:
+ n_routes = len(self.spec.routes)
+ return (
+ f""
+ )
diff --git a/runpod/apps/images.py b/runpod/apps/images.py
new file mode 100644
index 00000000..10d1400f
--- /dev/null
+++ b/runpod/apps/images.py
@@ -0,0 +1,80 @@
+"""runtime image selection.
+
+one place maps (resource kind, gpu/cpu, python version) to the runtime
+image built by the runtimes workflow. cpu images are python:X.Y-slim
+based; gpu images additionally preinstall the torch family (matching
+the build-time exclusion set) so deployed artifacts never install it
+at cold start.
+
+RUNPOD_RUNTIME_TAG selects the image channel (latest, dev, or a pinned
+version).
+"""
+
+import os
+import sys
+from typing import Optional
+
+from .spec import ResourceKind, ResourceSpec
+
+# policy: non-EOL cpython versions with torch wheel support. 3.10
+# ages out at its 2026-10 EOL. new versions join once torch ships
+# wheels for them.
+SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14")
+DEFAULT_PYTHON_VERSION = "3.12"
+
+_REPOS = {
+ (ResourceKind.QUEUE, False): "runpod/queue",
+ (ResourceKind.QUEUE, True): "runpod/queue-gpu",
+ (ResourceKind.API, False): "runpod/api",
+ (ResourceKind.API, True): "runpod/api-gpu",
+ (ResourceKind.TASK, False): "runpod/task",
+ (ResourceKind.TASK, True): "runpod/task-gpu",
+}
+
+
+def runtime_tag() -> str:
+ return os.environ.get("RUNPOD_RUNTIME_TAG", "latest")
+
+
+def local_python_version() -> str:
+ """the local interpreter's version, which the worker must match.
+
+ dev sessions and tasks ship cloudpickle payloads produced on the
+ client interpreter; those are not reliably portable across python
+ versions, so a silent fallback would surface as cryptic
+ deserialization failures on the worker. mismatches fail here,
+ loudly, instead.
+ """
+ version = f"{sys.version_info.major}.{sys.version_info.minor}"
+ if version not in SUPPORTED_PYTHON_VERSIONS:
+ raise RuntimeError(
+ f"python {version} has no runtime image "
+ f"(supported: {', '.join(SUPPORTED_PYTHON_VERSIONS)}). "
+ f"run under a supported python or use a custom image= built "
+ f"for {version}."
+ )
+ return version
+
+
+def runtime_image(
+ kind: ResourceKind,
+ *,
+ gpu: bool,
+ python_version: Optional[str] = None,
+) -> str:
+ """the builtin runtime image for a resource shape."""
+ version = python_version or DEFAULT_PYTHON_VERSION
+ if version not in SUPPORTED_PYTHON_VERSIONS:
+ raise ValueError(
+ f"python {version} is not supported by the runtime images "
+ f"(supported: {', '.join(SUPPORTED_PYTHON_VERSIONS)})"
+ )
+ repo = _REPOS[(kind, gpu)]
+ return f"{repo}:py{version}-{runtime_tag()}"
+
+
+def image_for_spec(spec: ResourceSpec, *, python_version: Optional[str] = None) -> str:
+ """the image a resource runs on: its custom image or the builtin."""
+ if spec.image:
+ return spec.image
+ return runtime_image(spec.kind, gpu=not spec.is_cpu, python_version=python_version)
diff --git a/runpod/apps/init.py b/runpod/apps/init.py
new file mode 100644
index 00000000..b7a59edc
--- /dev/null
+++ b/runpod/apps/init.py
@@ -0,0 +1,80 @@
+"""project scaffolding for `rp init`.
+
+writes a minimal app project: a main module with one queue function and
+a local entrypoint, a requirements file, and a .runpodignore. existing
+files are never overwritten unless the caller says so.
+"""
+
+from pathlib import Path
+from typing import Dict, List
+
+MAIN_TEMPLATE = '''"""{name}: a Runpod app.
+
+Run it live:
+
+ rp dev main.py
+
+Deploy it:
+
+ rp deploy
+"""
+
+import runpod
+from runpod import App
+
+app = App("{name}")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def hello(name: str):
+ # this print streams back to your terminal during rp dev
+ print(f"running in the cloud, greeting {{name}}")
+ return f"hello {{name}}!"
+
+
+@runpod.local_entrypoint
+def main():
+ # runs on your machine; hello() runs on a cloud worker
+ print(hello.remote("world"))
+'''
+
+REQUIREMENTS_TEMPLATE = """# packages your functions need on the workers
+# (also installed locally for rp dev)
+"""
+
+RUNPODIGNORE_TEMPLATE = """# excluded from the deploy artifact
+.git
+.venv
+__pycache__
+*.pyc
+.env
+"""
+
+PROJECT_FILES: Dict[str, str] = {
+ "main.py": MAIN_TEMPLATE,
+ "requirements.txt": REQUIREMENTS_TEMPLATE,
+ ".runpodignore": RUNPODIGNORE_TEMPLATE,
+}
+
+
+def detect_conflicts(project_dir: Path) -> List[str]:
+ """names of skeleton files that already exist in project_dir."""
+ return [
+ name for name in PROJECT_FILES if (project_dir / name).exists()
+ ]
+
+
+def create_project(
+ project_dir: Path, name: str, *, overwrite: bool = False
+) -> List[Path]:
+ """write the project skeleton; returns the files written."""
+ project_dir.mkdir(parents=True, exist_ok=True)
+ written: List[Path] = []
+ for filename, template in PROJECT_FILES.items():
+ path = project_dir / filename
+ if path.exists() and not overwrite:
+ continue
+ content = template.format(name=name) if "{name}" in template else template
+ path.write_text(content, encoding="utf-8")
+ written.append(path)
+ return written
diff --git a/runpod/apps/invoker.py b/runpod/apps/invoker.py
new file mode 100644
index 00000000..f44c6585
--- /dev/null
+++ b/runpod/apps/invoker.py
@@ -0,0 +1,49 @@
+"""sync-by-default invocation with an async escape hatch.
+
+an Invoker wraps a coroutine factory. calling it blocks and returns the
+result; calling `.aio(...)` returns the coroutine for the caller to await.
+
+ handle.remote(x) # sync, blocks
+ await handle.remote.aio(x) # async
+
+a StreamInvoker is the same idea for async generators: calling it
+returns a sync iterator, `.aio(...)` returns the async iterator.
+"""
+
+from typing import Any, AsyncIterator, Callable, Coroutine, Iterator
+
+from .context import block
+
+CoroFactory = Callable[..., Coroutine[Any, Any, Any]]
+AsyncGenFactory = Callable[..., AsyncIterator[Any]]
+
+
+class Invoker:
+ __slots__ = ("_factory",)
+
+ def __init__(self, factory: CoroFactory):
+ self._factory = factory
+
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
+ return block(self._factory(*args, **kwargs))
+
+ def aio(self, *args: Any, **kwargs: Any) -> Coroutine[Any, Any, Any]:
+ return self._factory(*args, **kwargs)
+
+
+class StreamInvoker:
+ __slots__ = ("_factory",)
+
+ def __init__(self, factory: AsyncGenFactory):
+ self._factory = factory
+
+ def __call__(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
+ agen = self._factory(*args, **kwargs)
+ while True:
+ try:
+ yield block(agen.__anext__())
+ except StopAsyncIteration:
+ return
+
+ def aio(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
+ return self._factory(*args, **kwargs)
diff --git a/runpod/apps/job.py b/runpod/apps/job.py
new file mode 100644
index 00000000..0d0dc24c
--- /dev/null
+++ b/runpod/apps/job.py
@@ -0,0 +1,80 @@
+"""submitted queue jobs shared by decorated handles and Queue stubs."""
+
+from typing import Any, AsyncIterator, Dict, TYPE_CHECKING
+
+from .invoker import Invoker, StreamInvoker
+from .targets import FINAL_STATUSES, DEFAULT_TIMEOUT_SECONDS
+
+if TYPE_CHECKING:
+ from .targets import InvocationTarget
+
+
+class Job:
+ """a submitted queue job.
+
+ job operations are sync by default and expose async forms through
+ ``.aio``:
+
+ job.status()
+ await job.status.aio()
+ job.result()
+ job.cancel()
+ job.retry()
+
+ generator jobs stream partial outputs:
+
+ for chunk in job.stream(): ...
+ async for chunk in job.stream.aio(): ...
+ """
+
+ def __init__(self, data: Dict[str, Any], target: "InvocationTarget"):
+ self._data = dict(data)
+ self._target = target
+ self.status = Invoker(self._status_async)
+ self.result = Invoker(self._result_async)
+ self.output = self.result
+ self.cancel = Invoker(self._cancel_async)
+ self.retry = Invoker(self._retry_async)
+ self.stream = StreamInvoker(self._stream_async)
+
+ @property
+ def id(self) -> str:
+ return self._data.get("id", "")
+
+ @property
+ def done(self) -> bool:
+ return self._data.get("status", "UNKNOWN") in FINAL_STATUSES
+
+ def _update(self, data: Dict[str, Any]) -> None:
+ self._data.update(data)
+
+ async def _status_async(self) -> str:
+ # terminal statuses never change; skip the network round trip
+ if not self.done:
+ self._update(await self._target.job_status(self.id))
+ return self._data.get("status", "UNKNOWN")
+
+ async def _result_async(self, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> Any:
+ return await self._target.wait(
+ self._data,
+ timeout=timeout,
+ on_status=self._update,
+ )
+
+ async def _cancel_async(self) -> "Job":
+ self._update(await self._target.cancel_job(self.id))
+ return self
+
+ async def _retry_async(self) -> "Job":
+ self._update(await self._target.retry_job(self.id))
+ return self
+
+ async def _stream_async(
+ self, timeout: float = DEFAULT_TIMEOUT_SECONDS
+ ) -> AsyncIterator[Any]:
+ async for chunk in self._target.stream_job(self.id, timeout=timeout):
+ yield chunk
+
+ def __repr__(self) -> str:
+ status = self._data.get("status", "UNKNOWN")
+ return f"Job(id={self.id!r}, status={status!r})"
diff --git a/runpod/apps/logs.py b/runpod/apps/logs.py
new file mode 100644
index 00000000..482e0967
--- /dev/null
+++ b/runpod/apps/logs.py
@@ -0,0 +1,97 @@
+"""pod log access via the host api (hapi).
+
+hosts expose per-pod container and system logs; this is the fastest
+way to see why a worker is stuck (image pull progress, region errors,
+crash output) without ssh. snapshot and sse streaming supported.
+
+ GET https://hapi.runpod.net/v1/pod/{podId}/logs
+ ?type=all|container|system
+ GET .../logs?stream=true&type=...&tail=N&since=RFC3339
+"""
+
+import json
+import os
+from typing import Any, AsyncIterator, Dict, List, Optional
+
+import aiohttp
+
+HAPI_BASE = os.environ.get("RUNPOD_HAPI_URL", "https://hapi.runpod.net")
+
+STREAM_TIMEOUT_SECONDS = 3600.0
+
+
+def _headers() -> Dict[str, str]:
+ from .targets import _api_key
+
+ return {"Authorization": f"Bearer {_api_key()}"}
+
+
+async def pod_logs(
+ pod_id: str,
+ *,
+ log_type: str = "all",
+ timeout: float = 30.0,
+) -> Dict[str, List[str]]:
+ """snapshot of a pod's logs: {"container": [...], "system": [...]}."""
+ url = f"{HAPI_BASE}/v1/pod/{pod_id}/logs"
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.get(
+ url, params={"type": log_type}, headers=_headers()
+ ) as resp:
+ resp.raise_for_status()
+ data = await resp.json()
+ return {k: v or [] for k, v in data.items()}
+
+
+async def stream_pod_logs(
+ pod_id: str,
+ *,
+ log_type: str = "all",
+ tail: int = 100,
+ since: Optional[str] = None,
+) -> AsyncIterator[Dict[str, Any]]:
+ """follow a pod's logs as they arrive.
+
+ yields {"source": "container"|"system", "line": str, "ts": str}
+ parsed from the host's sse stream. ends when the host closes the
+ stream (1h server-side cap) or the caller breaks out.
+ """
+ url = f"{HAPI_BASE}/v1/pod/{pod_id}/logs"
+ params: Dict[str, Any] = {
+ "stream": "true",
+ "type": log_type,
+ "tail": str(tail),
+ }
+ if since:
+ params["since"] = since
+
+ client_timeout = aiohttp.ClientTimeout(
+ total=STREAM_TIMEOUT_SECONDS, sock_read=60
+ )
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.get(
+ url, params=params, headers=_headers()
+ ) as resp:
+ resp.raise_for_status()
+ async for raw in resp.content:
+ line = raw.decode("utf-8", "replace").strip()
+ # sse frames: "data: {...}"; comments (heartbeats) start
+ # with ":"
+ if not line.startswith("data:"):
+ continue
+ try:
+ yield json.loads(line[len("data:") :].strip())
+ except json.JSONDecodeError:
+ continue
+
+
+def tail_summary(logs: Dict[str, List[str]], lines: int = 20) -> str:
+ """human-readable tail of a log snapshot, for error messages."""
+ parts = []
+ for source in ("system", "container"):
+ entries = logs.get(source) or []
+ if entries:
+ parts.append(f"--- {source} (last {min(lines, len(entries))}) ---")
+ parts.extend(entries[-lines:])
+ return "\n".join(parts) if parts else "(no logs available)"
diff --git a/runpod/apps/manage.py b/runpod/apps/manage.py
new file mode 100644
index 00000000..9c260e1f
--- /dev/null
+++ b/runpod/apps/manage.py
@@ -0,0 +1,138 @@
+"""app and environment lifecycle: list, inspect, undeploy, delete.
+
+everything resolves server-side (the flash app registry is the source
+of truth); nothing is tracked locally. undeploying an environment
+deletes its endpoints first so no orphaned workers keep billing.
+"""
+
+import logging
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+from .api import AppsApiClient
+from .errors import AppError
+from .utils.events import emit
+
+log = logging.getLogger(__name__)
+
+
+class AppNotFound(AppError):
+ def __init__(self, app_name: str):
+ super().__init__(
+ f"no app named '{app_name}' found. run `rp deploy` to create one."
+ )
+
+
+class EnvironmentNotFound(AppError):
+ def __init__(self, app_name: str, env_name: str):
+ super().__init__(
+ f"no environment '{env_name}' in app '{app_name}'."
+ )
+
+
+@dataclass
+class UndeployResult:
+ """outcome of tearing down one environment."""
+
+ endpoints_deleted: int = 0
+ environment_deleted: bool = False
+ app_deleted: bool = False
+ failures: List[str] = field(default_factory=list)
+
+
+async def list_apps(api: Optional[AppsApiClient] = None) -> List[Dict]:
+ """all apps with their environments, newest first."""
+ client = api or AppsApiClient()
+ apps = await client.list_apps()
+ return sorted(apps, key=lambda a: a.get("name") or "")
+
+
+async def get_app(
+ app_name: str, api: Optional[AppsApiClient] = None
+) -> Dict:
+ client = api or AppsApiClient()
+ app = await client.get_app_by_name(app_name)
+ if app is None:
+ raise AppNotFound(app_name)
+ return app
+
+
+async def get_environment(
+ app_name: str, env_name: str, api: Optional[AppsApiClient] = None
+) -> Dict:
+ client = api or AppsApiClient()
+ env = await client.get_environment_by_name(app_name, env_name)
+ if env is None:
+ raise EnvironmentNotFound(app_name, env_name)
+ return env
+
+
+async def undeploy_environment(
+ app_name: str,
+ env_name: str,
+ *,
+ api: Optional[AppsApiClient] = None,
+ delete_env: bool = True,
+ events: Optional[object] = None,
+) -> UndeployResult:
+ """tear down an environment: endpoints first, then the environment.
+
+ events, when given, receives cleanup_started(total), deleting(name),
+ deleted(name), delete_failed(name).
+ """
+ client = api or AppsApiClient()
+ env = await get_environment(app_name, env_name, api=client)
+
+ result = UndeployResult()
+ endpoints = env.get("endpoints") or []
+ emit(events, "cleanup_started", len(endpoints))
+ for endpoint in endpoints:
+ name = endpoint.get("name") or endpoint.get("id")
+ emit(events, "deleting", name)
+ try:
+ await client.delete_endpoint(endpoint["id"])
+ result.endpoints_deleted += 1
+ emit(events, "deleted", name)
+ log.info("deleted endpoint %s (%s)", name, endpoint["id"])
+ except Exception as exc: # noqa: BLE001 - collected for the caller
+ result.failures.append(f"endpoint {name}: {exc}")
+ emit(events, "delete_failed", name)
+
+ if delete_env and not result.failures:
+ try:
+ await client.delete_environment(env["id"])
+ result.environment_deleted = True
+ log.info("deleted environment %s (%s)", env_name, env["id"])
+ except Exception as exc: # noqa: BLE001 - collected for the caller
+ result.failures.append(f"environment {env_name}: {exc}")
+
+ return result
+
+
+async def delete_app(
+ app_name: str,
+ *,
+ api: Optional[AppsApiClient] = None,
+ events: Optional[object] = None,
+) -> UndeployResult:
+ """delete an app after undeploying every environment in it."""
+ client = api or AppsApiClient()
+ app = await get_app(app_name, api=client)
+
+ result = UndeployResult()
+ for env in app.get("flashEnvironments") or []:
+ env_result = await undeploy_environment(
+ app_name, env["name"], api=client, events=events
+ )
+ result.endpoints_deleted += env_result.endpoints_deleted
+ result.failures.extend(env_result.failures)
+
+ if not result.failures:
+ try:
+ await client.delete_app(app["id"])
+ result.app_deleted = True
+ log.info("deleted app %s (%s)", app_name, app["id"])
+ except Exception as exc: # noqa: BLE001 - collected for the caller
+ result.failures.append(f"app {app_name}: {exc}")
+
+ return result
diff --git a/runpod/apps/markers.py b/runpod/apps/markers.py
new file mode 100644
index 00000000..9f8e1e09
--- /dev/null
+++ b/runpod/apps/markers.py
@@ -0,0 +1,89 @@
+"""route and lifecycle markers for @app.api classes.
+
+these decorators stamp metadata on methods; the ApiHandle collects them
+when the class is registered. they do not wrap or change the function.
+
+ @app.api(name="inference", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090)
+ class Inference:
+ @init
+ def setup(self):
+ self.model = load_model()
+
+ @post("/generate")
+ async def generate(self, body: dict):
+ return {"text": self.model.run(body["prompt"])}
+"""
+
+from typing import Any, Callable
+
+ROUTE_ATTR = "__runpod_route__"
+INIT_ATTR = "__runpod_init__"
+
+_VALID_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH"})
+
+# paths used by the worker runtime; user routes must not collide
+RESERVED_PATHS = frozenset({"/execute", "/ping"})
+
+
+def _route_marker(method: str, path: str) -> Callable[[Callable], Callable]:
+ if method not in _VALID_METHODS:
+ raise ValueError(f"method must be one of {sorted(_VALID_METHODS)}")
+ if not path.startswith("/"):
+ raise ValueError(f"path must start with '/', got: {path!r}")
+ if path in RESERVED_PATHS:
+ raise ValueError(
+ f"path {path!r} is reserved by the worker runtime "
+ f"(reserved: {', '.join(sorted(RESERVED_PATHS))})"
+ )
+
+ def marker(fn: Callable) -> Callable:
+ setattr(fn, ROUTE_ATTR, (method, path))
+ return fn
+
+ return marker
+
+
+def get(path: str) -> Callable[[Callable], Callable]:
+ """mark a method as a GET route."""
+ return _route_marker("GET", path)
+
+
+def post(path: str) -> Callable[[Callable], Callable]:
+ """mark a method as a POST route."""
+ return _route_marker("POST", path)
+
+
+def put(path: str) -> Callable[[Callable], Callable]:
+ """mark a method as a PUT route."""
+ return _route_marker("PUT", path)
+
+
+def delete(path: str) -> Callable[[Callable], Callable]:
+ """mark a method as a DELETE route."""
+ return _route_marker("DELETE", path)
+
+
+def patch(path: str) -> Callable[[Callable], Callable]:
+ """mark a method as a PATCH route."""
+ return _route_marker("PATCH", path)
+
+
+def init(fn: Callable) -> Callable:
+ """mark a method (or function) as a worker-startup hook.
+
+ on an @app.api class, the marked method runs after instantiation and
+ before the worker reports healthy. on a queue/task handle, use
+ `@handle.init` instead.
+ """
+ setattr(fn, INIT_ATTR, True)
+ return fn
+
+
+def route_of(fn: Any) -> "tuple[str, str] | None":
+ """return (method, path) if fn is marked as a route."""
+ return getattr(fn, ROUTE_ATTR, None)
+
+
+def is_init(fn: Any) -> bool:
+ """true if fn is marked as an init hook."""
+ return getattr(fn, INIT_ATTR, False) is True
diff --git a/runpod/apps/model.py b/runpod/apps/model.py
new file mode 100644
index 00000000..d751b8d7
--- /dev/null
+++ b/runpod/apps/model.py
@@ -0,0 +1,91 @@
+"""platform-cached models: weights staged on hosts before workers start.
+
+a Model references huggingface weights by repo id. attached to a
+resource, the platform stages the weights on the host and defers
+worker start until they are ready, so cold starts never download
+model files.
+
+ llama = runpod.Model("meta-llama/Llama-3.1-8B-Instruct")
+
+ @app.queue(gpu="H100", model=llama, env={"HF_TOKEN": runpod.Secret("hf")})
+ def chat(prompt: str):
+ llm = vllm.LLM(model=str(llama.path))
+
+inside the worker the weights appear in two layouts:
+ - runpod store: /runpod/model-store/huggingface/{org}/{name}/{revision}
+ (Model.path, revision from the MODEL_REVISION env var)
+ - hf cache: /runpod-volume/huggingface-cache/hub/models--{org}--{name}
+ (transformers/vllm find it via the standard cache convention)
+
+gated models need an HF_TOKEN env var on the same resource (a Secret
+reference works; the platform decrypts it for validation).
+"""
+
+import os
+import re
+from pathlib import Path
+from typing import Optional
+
+from .errors import AppError
+
+MODEL_STORE_ROOT = Path("/runpod/model-store/huggingface")
+HF_CACHE_ROOT = Path("/runpod-volume/huggingface-cache")
+
+_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
+
+
+class ModelError(AppError):
+ pass
+
+
+class Model:
+ """a huggingface model reference, staged by the platform."""
+
+ def __init__(self, reference: str):
+ if not reference or not isinstance(reference, str):
+ raise ModelError("model reference must be a non-empty string")
+ repo, _, revision = reference.partition(":")
+ if not _REPO_RE.match(repo):
+ raise ModelError(
+ f"invalid model reference '{reference}': expected "
+ f"'owner/name' or 'owner/name:revision'"
+ )
+ self.reference = reference
+ self.owner, self.name = repo.split("/")
+ self.revision = revision or None
+
+ @property
+ def path(self) -> Path:
+ """the staged weights directory inside the worker.
+
+ the platform resolves the exact revision at deploy time and
+ exposes it via MODEL_REVISION; outside a worker (or before the
+ mount exists) this still forms the correct path shape.
+ """
+ revision = os.environ.get("MODEL_REVISION") or self.revision or ""
+ base = MODEL_STORE_ROOT / self.owner / self.name
+ return base / revision if revision else base
+
+ @property
+ def hf_cache_path(self) -> Path:
+ """the huggingface-convention cache directory for this model."""
+ return (
+ HF_CACHE_ROOT / "hub" / f"models--{self.owner}--{self.name}"
+ )
+
+ def __repr__(self) -> str:
+ return f""
+
+
+def model_reference(model) -> Optional[str]:
+ """normalize a spec's model field to the api-facing reference."""
+ if model is None:
+ return None
+ if isinstance(model, Model):
+ return model.reference
+ if isinstance(model, str):
+ return Model(model).reference
+ raise ModelError(
+ f"model must be a runpod.Model or 'owner/name' string, "
+ f"got {type(model).__name__}"
+ )
diff --git a/runpod/apps/monitor.py b/runpod/apps/monitor.py
new file mode 100644
index 00000000..c8e8c25f
--- /dev/null
+++ b/runpod/apps/monitor.py
@@ -0,0 +1,278 @@
+"""request-time worker observability for dev sessions.
+
+while a dev call is in flight, a WorkerMonitor watches the endpoint's
+worker metrics for state transitions (initializing, throttled, ready)
+and, once the job is assigned a worker, follows that worker's container
+logs over the hapi sse stream. everything surfaces through a duck-typed
+event sink; missing handlers are silently skipped.
+"""
+
+import asyncio
+import json
+import logging
+import re
+from datetime import datetime, timezone
+from typing import Any, Dict, List, Optional
+
+from .utils.events import emit
+
+log = logging.getLogger(__name__)
+
+METRICS_POLL_INTERVAL = 2.0
+
+# worker states worth reporting, in display order
+_TRACKED_STATES = ("initializing", "ready", "running", "throttled", "unhealthy")
+
+# leading iso timestamp on hapi log lines
+_TS_PREFIX = re.compile(
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\s*"
+)
+
+
+def _worker_frame(line: str) -> Optional[Dict[str, Any]]:
+ """parse the serverless sdk's structured json log lines."""
+ if not line.startswith("{"):
+ return None
+ try:
+ frame = json.loads(line)
+ except json.JSONDecodeError:
+ return None
+ if isinstance(frame, dict) and "message" in frame and "level" in frame:
+ return frame
+ return None
+
+
+def _filter_line(line: str) -> Optional[str]:
+ """what to show from a raw container log line.
+
+ the serverless sdk's own frames (fitness checks, queue counts,
+ started/finished) are runtime noise; user prints pass through
+ verbatim, and sdk error/warn frames surface as their message.
+ """
+ if line.startswith("--- Starting Serverless Worker"):
+ return None
+ frame = _worker_frame(line)
+ if frame is None:
+ return line
+ if frame.get("level", "").upper() in ("ERROR", "WARN", "WARNING"):
+ return str(frame.get("message", ""))
+ return None
+
+
+class PodLogStream:
+ """follows one pod's container logs and emits worker_log events.
+
+ shared by live endpoint workers and task pods: attach() starts the
+ follow (retrying aggressively while the gateway warms up to a fresh
+ pod), stop() cancels it and falls back to a snapshot when the
+ stream never yielded a line (jobs shorter than the attach window).
+ lines dedup across reconnects and sdk runtime frames are filtered.
+ """
+
+ def __init__(self, pod_id: str, resource_name: str, events: object):
+ self.pod_id = pod_id
+ self.name = resource_name
+ self.events = events
+ self._task: Optional[asyncio.Task] = None
+ self._since = datetime.now(timezone.utc).isoformat()
+ self._lines_emitted = 0
+ # (ts, line) pairs already shown; reconnect backfill overlaps
+ # the previous window and must not replay lines
+ self._seen: set = set()
+
+ def attach(self) -> None:
+ if self._task is None:
+ self._task = asyncio.ensure_future(self._follow())
+
+ async def stop(self) -> None:
+ if self._task is not None:
+ self._task.cancel()
+ await asyncio.gather(self._task, return_exceptions=True)
+ self._task = None
+ # the stream often cannot attach within a short job's lifetime
+ # (fresh pods 403 on the logs endpoint until the gateway can
+ # resolve them); a final snapshot recovers the output
+ if not self._lines_emitted:
+ await self._snapshot()
+
+ def _emit_line(self, key: tuple, line: str) -> None:
+ if key in self._seen:
+ return
+ self._seen.add(key)
+ shown = _filter_line(line)
+ if shown:
+ emit(self.events, "worker_log", self.name, shown)
+
+ async def _snapshot(self) -> None:
+ from .logs import pod_logs
+
+ try:
+ logs = await pod_logs(self.pod_id, log_type="container")
+ except Exception: # noqa: BLE001 - observability is best-effort
+ log.debug("log snapshot for %s failed", self.pod_id, exc_info=True)
+ return
+ for raw in logs.get("container") or []:
+ if _line_before(raw, self._since):
+ continue
+ line = _TS_PREFIX.sub("", raw.rstrip())
+ if line:
+ self._emit_line((raw.rstrip(), line), line)
+
+ async def _follow(self) -> None:
+ from .logs import stream_pod_logs
+
+ attempt = 0
+ while True:
+ streamed = False
+ try:
+ # backfill (tail) + since: lines printed between request
+ # start and the attach finally succeeding (fresh pods
+ # 403 until the gateway can resolve them) are recovered
+ # from the backfill instead of dropped
+ async for event in stream_pod_logs(
+ self.pod_id,
+ log_type="container",
+ tail=1000,
+ since=self._since,
+ ):
+ streamed = True
+ attempt = 0
+ raw = (event.get("line") or "").rstrip()
+ line = _TS_PREFIX.sub("", raw)
+ if line:
+ self._lines_emitted += 1
+ self._emit_line((event.get("ts") or raw, line), line)
+ except asyncio.CancelledError:
+ raise
+ except Exception: # noqa: BLE001 - observability is best-effort
+ log.debug(
+ "log stream attach for %s failed (attempt %d)",
+ self.pod_id,
+ attempt + 1,
+ exc_info=True,
+ )
+ if streamed:
+ # the server closed a healthy stream (1h cap or worker
+ # teardown); resume immediately from where we left off
+ self._since = datetime.now(timezone.utc).isoformat()
+ continue
+ attempt += 1
+ # attach latency is the product: retry aggressively while
+ # the gateway warms up to the fresh pod (sub-second polls),
+ # then ease off if it stays unreachable
+ if attempt <= 20:
+ await asyncio.sleep(0.5)
+ else:
+ await asyncio.sleep(min(attempt - 20, 15))
+
+
+class WorkerMonitor:
+ """observes one in-flight request against a live endpoint.
+
+ start() launches a metrics poller; on_status(payload) should be fed
+ every job-status payload so the assigned worker's log stream can
+ attach as soon as a workerId appears. stop() tears everything down.
+ """
+
+ def __init__(
+ self,
+ endpoint_id: str,
+ resource_name: str,
+ events: object,
+ metrics_key: Optional[str] = None,
+ ):
+ self.endpoint_id = endpoint_id
+ self.name = resource_name
+ self.events = events
+ # /metrics is served on the data plane behind the endpoint's own
+ # ai key; the user api key is rejected there
+ self.metrics_key = metrics_key
+ self._tasks: List[asyncio.Task] = []
+ self._streams: Dict[str, PodLogStream] = {}
+ self._last_counts: Optional[Dict[str, int]] = None
+
+ async def start(self) -> None:
+ if self.metrics_key:
+ self._tasks.append(asyncio.ensure_future(self._poll_metrics()))
+
+ def on_status(self, data: Dict[str, Any]) -> None:
+ """inspect a job-status payload for the assigned worker."""
+ worker_id = data.get("workerId")
+ if worker_id and worker_id not in self._streams:
+ emit(self.events, "worker_ready", self.name, str(worker_id))
+ stream = PodLogStream(str(worker_id), self.name, self.events)
+ stream.attach()
+ self._streams[str(worker_id)] = stream
+
+ async def stop(self) -> None:
+ for task in self._tasks:
+ task.cancel()
+ if self._tasks:
+ await asyncio.gather(*self._tasks, return_exceptions=True)
+ self._tasks.clear()
+ for stream in self._streams.values():
+ await stream.stop()
+
+ async def _poll_metrics(self) -> None:
+ from .targets import _endpoint_url_base, _get_json
+
+ url = f"{_endpoint_url_base()}/{self.endpoint_id}/metrics"
+ headers = {"Authorization": f"Bearer {self.metrics_key}"}
+ while True:
+ try:
+ data = await _get_json(url, headers, 10.0)
+ except asyncio.CancelledError:
+ raise
+ except Exception: # noqa: BLE001 - observability is best-effort
+ await asyncio.sleep(METRICS_POLL_INTERVAL)
+ continue
+ self._report_counts(data)
+ await asyncio.sleep(METRICS_POLL_INTERVAL)
+
+ def _report_counts(self, data: Dict[str, Any]) -> None:
+ # once a worker picked the job up, pool churn is noise
+ if self._streams:
+ return
+ workers = data.get("workers")
+ if not isinstance(workers, dict):
+ return
+ counts = {
+ state: workers.get(state, 0) or 0 for state in _TRACKED_STATES
+ }
+ if counts == self._last_counts:
+ return
+ first = self._last_counts is None
+ self._last_counts = counts
+ # the first snapshot is only interesting when workers are still
+ # coming up (or wedged); a steady ready/running pool is implied
+ if first and not (
+ counts["initializing"] or counts["throttled"] or counts["unhealthy"]
+ ):
+ return
+ emit(self.events, "worker_status", self.name, counts)
+
+
+def _line_before(raw: str, since_iso: str) -> bool:
+ """true when the log line's leading timestamp predates since."""
+ match = _TS_PREFIX.match(raw)
+ if not match:
+ return False
+ ts = match.group(0).strip().replace("Z", "+00:00")
+ # docker timestamps carry nanoseconds; fromisoformat caps at micro
+ ts = re.sub(r"(\.\d{6})\d+", r"\1", ts)
+ try:
+ line_ts = datetime.fromisoformat(ts)
+ since = datetime.fromisoformat(since_iso)
+ except ValueError:
+ return False
+ return line_ts <= since
+
+
+def format_worker_counts(counts: Dict[str, int]) -> str:
+ """human summary like '1 initializing, 2 ready'."""
+ parts = [
+ f"{count} {state}"
+ for state, count in counts.items()
+ if count
+ ]
+ return ", ".join(parts) if parts else "no workers"
diff --git a/runpod/apps/placement.py b/runpod/apps/placement.py
new file mode 100644
index 00000000..4f88ad77
--- /dev/null
+++ b/runpod/apps/placement.py
@@ -0,0 +1,216 @@
+"""datacenter placement: solving where volumes and resources can live.
+
+a network volume pins everything attached to it to one datacenter, so
+placement is a constraint solve over the whole app: each resource's
+schedulable datacenters (hardware stock ∩ user pins ∩ storage support)
+intersected per volume, ranked maximin by stock so the chosen DC is
+the one where the most-constrained resource has the best availability.
+"""
+
+import asyncio
+import logging
+from typing import Dict, Iterable, List, Optional, Set, Tuple
+
+from .datacenter import DataCenter
+from .errors import AppError
+from .gpu import GpuGroup
+from .utils.client import default_client
+
+log = logging.getLogger(__name__)
+
+# stock signal ranking; unknown/none scores zero
+_STOCK_SCORE = {"HIGH": 3, "MEDIUM": 2, "LOW": 1}
+
+
+class PlacementError(AppError):
+ pass
+
+
+def _score(status: Optional[str]) -> int:
+ if not isinstance(status, str):
+ return 0
+ return _STOCK_SCORE.get(status.strip().upper(), 0)
+
+
+def _hardware_keys(spec) -> List[Tuple[str, str]]:
+ """(kind, id) stock lookup keys for a resource's hardware.
+
+ gpu entries may be pool ids or device names; pools expand to their
+ device names since the stock api takes devices. tasks run on pods,
+ whose gpu availability differs from the serverless plane, so their
+ keys carry a distinct kind and query pod stock.
+ """
+ from .spec import ResourceKind
+
+ if spec.is_cpu:
+ return [("cpu", c) for c in spec.cpu or []]
+ gpu_kind = "gpu-pod" if spec.kind is ResourceKind.TASK else "gpu"
+ gpu = spec.gpu
+ if not gpu or any(str(g).lower() == "any" for g in gpu):
+ return [(gpu_kind, "*")]
+ keys: List[Tuple[str, str]] = []
+ for entry in gpu:
+ try:
+ for device in GpuGroup(str(entry)).device_names():
+ keys.append((gpu_kind, device))
+ except ValueError:
+ keys.append((gpu_kind, str(entry)))
+ return keys
+
+
+class StockMap:
+ """per-(hardware, datacenter) stock signals, fetched lazily in bulk."""
+
+ def __init__(self, api=None):
+ self._api = api
+ self._gpu: Dict[Tuple[str, str], int] = {}
+ self._gpu_pod: Dict[Tuple[str, str], int] = {}
+ self._cpu: Dict[Tuple[str, str], int] = {}
+ self._fetched_gpu: Set[str] = set()
+ self._fetched_gpu_pod: Set[str] = set()
+ self._fetched_cpu: Set[str] = set()
+
+ async def _client(self):
+ self._api = default_client(self._api)
+ return self._api
+
+ async def fetch(self, keys: Iterable[Tuple[str, str]]) -> None:
+ """populate stock for the given hardware keys across all DCs."""
+ keys = list(keys)
+ gpu_ids = {
+ k[1]
+ for k in keys
+ if k[0] == "gpu" and k[1] != "*" and k[1] not in self._fetched_gpu
+ }
+ gpu_pod_ids = {
+ k[1]
+ for k in keys
+ if k[0] == "gpu-pod"
+ and k[1] != "*"
+ and k[1] not in self._fetched_gpu_pod
+ }
+ cpu_ids = {
+ k[1] for k in keys if k[0] == "cpu" and k[1] not in self._fetched_cpu
+ }
+ client = await self._client()
+ jobs = []
+ for gpu_id in gpu_ids:
+ self._fetched_gpu.add(gpu_id)
+ for dc in DataCenter.all():
+ jobs.append(self._fetch_gpu(client, gpu_id, dc.value, False))
+ for gpu_id in gpu_pod_ids:
+ self._fetched_gpu_pod.add(gpu_id)
+ for dc in DataCenter.all():
+ jobs.append(self._fetch_gpu(client, gpu_id, dc.value, True))
+ for cpu_id in cpu_ids:
+ self._fetched_cpu.add(cpu_id)
+ for dc in DataCenter.all():
+ jobs.append(self._fetch_cpu(client, cpu_id, dc.value))
+ if jobs:
+ await asyncio.gather(*jobs)
+
+ async def _fetch_gpu(
+ self, client, gpu_id: str, dc: str, pods: bool
+ ) -> None:
+ try:
+ status = await client.gpu_stock_status(gpu_id, dc, pods=pods)
+ except Exception: # noqa: BLE001 - stock is advisory
+ log.debug("gpu stock query failed for %s@%s", gpu_id, dc, exc_info=True)
+ status = None
+ target = self._gpu_pod if pods else self._gpu
+ target[(gpu_id, dc)] = _score(status)
+
+ async def _fetch_cpu(self, client, instance_id: str, dc: str) -> None:
+ try:
+ status = await client.cpu_stock_status(instance_id, dc)
+ except Exception: # noqa: BLE001 - stock is advisory
+ log.debug("cpu stock query failed for %s@%s", instance_id, dc, exc_info=True)
+ status = None
+ self._cpu[(instance_id, dc)] = _score(status)
+
+ def score(self, key: Tuple[str, str], dc: str) -> int:
+ kind, hw = key
+ if kind in ("gpu", "gpu-pod"):
+ table = self._gpu_pod if kind == "gpu-pod" else self._gpu
+ if hw == "*":
+ # any gpu: best signal among fetched devices, else assume ok
+ scores = [s for (g, d), s in table.items() if d == dc]
+ return max(scores, default=1)
+ return table.get((hw, dc), 0)
+ return self._cpu.get((hw, dc), 0)
+
+
+def candidates(spec, stock: StockMap) -> Set[str]:
+ """datacenters where a resource is schedulable.
+
+ hardware needs stock in the DC (any of the resource's acceptable
+ devices/flavors), intersected with an explicit datacenter pin.
+ """
+ allowed = {dc.value for dc in DataCenter.all()}
+ if spec.datacenter:
+ allowed &= {str(d) for d in spec.datacenter}
+
+ keys = _hardware_keys(spec)
+ if not keys:
+ return allowed
+ viable = set()
+ for dc in allowed:
+ if any(stock.score(key, dc) > 0 for key in keys):
+ viable.add(dc)
+ return viable
+
+
+def _resource_best_score(spec, stock: StockMap, dc: str) -> int:
+ keys = _hardware_keys(spec)
+ if not keys:
+ return 1
+ return max(stock.score(key, dc) for key in keys)
+
+
+def solve_placement(
+ specs: List,
+ stock: StockMap,
+ *,
+ volume_name: str,
+ existing_dc: Optional[str] = None,
+) -> str:
+ """pick the datacenter for one volume given every resource using it.
+
+ an existing volume's DC is a hard constraint (verified schedulable);
+ a new volume lands in the intersection of every resource's candidate
+ set, ranked maximin: the DC where the most-constrained resource has
+ the best stock.
+ """
+ per_resource = {spec.name: candidates(spec, stock) for spec in specs}
+
+ if existing_dc is not None:
+ blocked = [
+ name for name, dcs in per_resource.items() if existing_dc not in dcs
+ ]
+ if blocked:
+ raise PlacementError(
+ f"volume '{volume_name}' lives in {existing_dc}, but "
+ f"{', '.join(blocked)} cannot schedule there "
+ f"(no hardware stock or conflicting datacenter pin)"
+ )
+ return existing_dc
+
+ shared = set.intersection(*per_resource.values()) if per_resource else set()
+ if not shared:
+ lines = [
+ f" {name:<12} schedulable in: {', '.join(sorted(dcs)) or '(nowhere)'}"
+ for name, dcs in per_resource.items()
+ ]
+ raise PlacementError(
+ f"cannot place volume '{volume_name}': no datacenter can host "
+ f"every resource using it\n" + "\n".join(lines) + "\n"
+ f"use separate volumes or compatible hardware"
+ )
+
+ # maximin: rank each DC by the worst resource's stock there,
+ # tiebreak by the aggregate
+ def rank(dc: str) -> Tuple[int, int]:
+ scores = [_resource_best_score(spec, stock, dc) for spec in specs]
+ return (min(scores), sum(scores))
+
+ return max(sorted(shared), key=rank)
diff --git a/runpod/apps/protocol.py b/runpod/apps/protocol.py
new file mode 100644
index 00000000..bc9f34bb
--- /dev/null
+++ b/runpod/apps/protocol.py
@@ -0,0 +1,60 @@
+"""wire protocol for remote function execution.
+
+this is the contract between the sdk and the worker runtime images.
+the worker repo imports these shapes from the runpod package so client
+and worker can never drift.
+
+live mode ships function source with every request; deployed mode omits
+the code (the worker unpacked the build and resolves the function by
+name). args/kwargs are base64 cloudpickle unless serialization_format
+is "json".
+"""
+
+from dataclasses import asdict, dataclass, field
+from typing import Any, Dict, List, Optional
+
+EXECUTION_FUNCTION = "function"
+
+FORMAT_CLOUDPICKLE = "cloudpickle"
+FORMAT_JSON = "json"
+
+
+@dataclass
+class FunctionRequest:
+ """a request to execute one function on a worker."""
+
+ function_name: str
+ function_code: Optional[str] = None
+ args: List[Any] = field(default_factory=list)
+ kwargs: Dict[str, Any] = field(default_factory=dict)
+ dependencies: Optional[List[str]] = None
+ system_dependencies: Optional[List[str]] = None
+ execution_type: str = EXECUTION_FUNCTION
+ accelerate_downloads: bool = True
+ serialization_format: str = FORMAT_CLOUDPICKLE
+
+ def to_input(self) -> Dict[str, Any]:
+ """serialize as the job input dict."""
+ data = asdict(self)
+ return {k: v for k, v in data.items() if v is not None}
+
+
+@dataclass
+class FunctionResponse:
+ """the worker's result for one function execution."""
+
+ success: bool
+ result: Optional[str] = None
+ json_result: Any = None
+ error: Optional[str] = None
+ stdout: Optional[str] = None
+
+ @classmethod
+ def from_output(cls, output: Dict[str, Any]) -> "FunctionResponse":
+ return cls(
+ success=output.get("success", False),
+ result=output.get("result"),
+ json_result=output.get("json_result"),
+ error=output.get("error"),
+ stdout=output.get("stdout"),
+ )
diff --git a/runpod/apps/registry.py b/runpod/apps/registry.py
new file mode 100644
index 00000000..e237ef44
--- /dev/null
+++ b/runpod/apps/registry.py
@@ -0,0 +1,45 @@
+"""container registry credentials for private images.
+
+credentials are pure provision-time config referenced by name:
+
+ @app.queue(image="ghcr.io/me/private:latest", registry_auth="my-ghcr")
+ def infer(): ...
+
+create and manage them with `rp registry add/list/delete`. resolution
+(name -> containerRegistryAuthId) happens when the resource provisions.
+"""
+
+import logging
+from typing import Optional
+
+from .errors import AppError
+from .utils.lookup import find_by_id_or_name
+
+log = logging.getLogger(__name__)
+
+
+class RegistryAuthError(AppError):
+ pass
+
+
+async def resolve_registry_auth(
+ name: Optional[str], api=None
+) -> Optional[str]:
+ """resolve a credential name (or id) to a containerRegistryAuthId."""
+ if not name:
+ return None
+ if api is None:
+ from .api import AppsApiClient
+
+ api = AppsApiClient()
+ creds = await api.list_registry_auths()
+ match = find_by_id_or_name(
+ creds, name, noun="registry credentials", error=RegistryAuthError
+ )
+ if match:
+ return match["id"]
+ available = ", ".join(sorted(c["name"] for c in creds)) or "(none)"
+ raise RegistryAuthError(
+ f"registry credential '{name}' not found. available: {available}. "
+ f"create one with `rp registry add {name}`"
+ )
diff --git a/runpod/apps/schedule.py b/runpod/apps/schedule.py
new file mode 100644
index 00000000..c55d4bfc
--- /dev/null
+++ b/runpod/apps/schedule.py
@@ -0,0 +1,41 @@
+"""the @schedule decorator: records a cron expression on a handle.
+
+schedules only take effect through `rp deploy` (the schedule must live
+server-side). recurring schedule execution is gated on backend support;
+deploying a scheduled resource raises ScheduleNotSupported until it lands.
+"""
+
+from typing import Callable, TypeVar
+
+T = TypeVar("T")
+
+SCHEDULE_ATTR = "__runpod_schedule__"
+
+# flipped when the backend ships recurring schedule triggers
+SCHEDULES_ENABLED = False
+
+
+def schedule(*, cron: str) -> Callable[[T], T]:
+ """attach a cron schedule to a queue or task handle.
+
+ @app.task(name="hourly", gpu=GpuType.NVIDIA_GEFORCE_RTX_4090)
+ @schedule(cron="0 * * * *")
+ async def hourly_job(): ...
+
+ decorator order is flexible: @schedule can sit above or below the
+ app decorator. the cron string is validated at deploy time.
+ """
+ if not cron or not isinstance(cron, str):
+ raise ValueError("cron must be a non-empty string")
+
+ def decorator(target: T) -> T:
+ # works on both raw functions (before app decorator) and handles
+ # (after), since handles expose their spec and raw fns get stamped
+ spec = getattr(target, "spec", None)
+ if spec is not None:
+ spec.schedule = cron
+ else:
+ setattr(target, SCHEDULE_ATTR, cron)
+ return target
+
+ return decorator
diff --git a/runpod/apps/secret.py b/runpod/apps/secret.py
new file mode 100644
index 00000000..d1742f96
--- /dev/null
+++ b/runpod/apps/secret.py
@@ -0,0 +1,95 @@
+"""platform secrets: encrypted values injected into worker env vars.
+
+a Secret marks an env value for reference-syntax rendering; the
+platform decrypts it when the worker boots. values never travel
+through the sdk.
+
+ @app.queue(env={"HF_TOKEN": runpod.Secret("hf-token")})
+ def download(): ...
+
+the worker sees a plain HF_TOKEN env var with the decrypted value.
+create and manage secrets with `rp secret add/list/delete`.
+"""
+
+import re
+from typing import Any, Dict, List, Optional
+
+from .errors import AppError
+
+# the platform's env reference syntax (decrypted server-side at boot)
+_REFERENCE_TEMPLATE = "{{{{ RUNPOD_SECRET_{name} }}}}"
+_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
+
+
+class SecretError(AppError):
+ pass
+
+
+class Secret:
+ """a reference to a platform secret by name."""
+
+ def __init__(self, name: str):
+ if not name or not isinstance(name, str):
+ raise SecretError("secret name must be a non-empty string")
+ if not _NAME_RE.match(name):
+ raise SecretError(
+ f"invalid secret name '{name}': letters, digits, "
+ f"underscores, dots, and dashes only"
+ )
+ self.name = name
+
+ @property
+ def reference(self) -> str:
+ """the env-var value the platform substitutes at boot."""
+ return _REFERENCE_TEMPLATE.format(name=self.name)
+
+ def __repr__(self) -> str:
+ return f""
+
+
+def render_env(env: Optional[Dict[str, Any]]) -> Dict[str, str]:
+ """render an env mapping, expanding Secret values to references.
+
+ plain values stringify; Secret values become the platform's
+ reference syntax.
+ """
+ if not env:
+ return {}
+ rendered: Dict[str, str] = {}
+ for key, value in env.items():
+ if isinstance(value, Secret):
+ rendered[key] = value.reference
+ else:
+ rendered[key] = str(value)
+ return rendered
+
+
+def secret_names(env: Optional[Dict[str, Any]]) -> List[str]:
+ """names of every Secret referenced in an env mapping."""
+ if not env:
+ return []
+ return [v.name for v in env.values() if isinstance(v, Secret)]
+
+
+async def validate_secrets(
+ names: List[str], api=None
+) -> None:
+ """fail fast when referenced secrets do not exist.
+
+ workers with unresolvable references boot with the literal
+ template string in the env var, which is a confusing runtime
+ failure; checking up front turns it into a clear provision error.
+ """
+ if not names:
+ return
+ if api is None:
+ from .api import AppsApiClient
+
+ api = AppsApiClient()
+ existing = {s["name"] for s in await api.list_secrets()}
+ missing = sorted(set(names) - existing)
+ if missing:
+ raise SecretError(
+ f"secret{'s' if len(missing) > 1 else ''} not found: "
+ f"{', '.join(missing)}. create with `rp secret add `"
+ )
diff --git a/runpod/apps/serialization.py b/runpod/apps/serialization.py
new file mode 100644
index 00000000..684ccd71
--- /dev/null
+++ b/runpod/apps/serialization.py
@@ -0,0 +1,101 @@
+"""argument and result serialization for remote execution.
+
+args/kwargs cross the wire as base64-encoded cloudpickle strings, the
+format the worker runtime images expect. function source is extracted
+with decorators stripped so the worker can exec it standalone.
+"""
+
+import ast
+import base64
+import inspect
+import os
+import textwrap
+from typing import Any, Callable, Dict, List
+
+import cloudpickle
+
+
+def serialize_arg(arg: Any) -> str:
+ return base64.b64encode(cloudpickle.dumps(arg)).decode("utf-8")
+
+
+def serialize_args(args: tuple) -> List[str]:
+ return [serialize_arg(a) for a in args]
+
+
+def serialize_kwargs(kwargs: dict) -> Dict[str, str]:
+ return {k: serialize_arg(v) for k, v in kwargs.items()}
+
+
+def deserialize_result(result_b64: str) -> Any:
+ try:
+ return cloudpickle.loads(base64.b64decode(result_b64))
+ except ModuleNotFoundError as exc:
+ from .errors import RemoteExecutionError
+
+ raise RemoteExecutionError(
+ f"the remote result contains an object from the "
+ f"'{exc.name}' package, which is not installed locally. "
+ f"return plain python types (e.g. str(...) it) or install "
+ f"'{exc.name}' on this machine."
+ ) from exc
+
+
+def require_json_args(fn: Callable, args: tuple, kwargs: dict) -> None:
+ """reject arguments that cannot cross the queue data plane.
+
+ queue jobs are json on the wire in every mode, so non-json
+ arguments fail at call time with a pointed error instead of a
+ worker-side decode failure.
+ """
+ import json
+
+ try:
+ json.dumps(list(args))
+ json.dumps(kwargs)
+ except (TypeError, ValueError) as exc:
+ raise TypeError(
+ f"{fn.__name__}() arguments must be json-serializable "
+ f"(queue jobs are plain json): {exc}"
+ ) from exc
+
+
+def get_function_source(fn: Callable) -> str:
+ """the full source of the function's module.
+
+ the worker execs this and resolves the function by name, exactly
+ mirroring deployed mode (which imports the user's module from the
+ build artifact): module-level imports, globals, and decorators
+ behave identically in dev and deploy.
+ """
+ fn = inspect.unwrap(fn)
+ module = inspect.getmodule(fn)
+ if module is not None:
+ try:
+ return inspect.getsource(module)
+ except (OSError, TypeError):
+ pass
+ # exec'd module (nested hop inside a live worker): the runner
+ # materialized the shipped module to a real file; re-ship it whole
+ # so decorators and globals keep working on the next worker
+ filename = getattr(getattr(fn, "__code__", None), "co_filename", "")
+ if filename and os.path.isfile(filename):
+ with open(filename) as f:
+ return f.read()
+ # last resort (repl): the bare function body with decorators
+ # stripped, since their context cannot travel
+ return _bare_function_source(fn)
+
+
+def _bare_function_source(fn: Callable) -> str:
+ """a function's own def, decorators removed."""
+ source = textwrap.dedent(inspect.getsource(fn))
+ tree = ast.parse(source)
+ for node in ast.walk(tree):
+ if (
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
+ and node.name == fn.__name__
+ ):
+ lines = source.split("\n")
+ return textwrap.dedent("\n".join(lines[node.lineno - 1 :]))
+ return source
diff --git a/runpod/apps/shim.py b/runpod/apps/shim.py
new file mode 100644
index 00000000..13cfa8f5
--- /dev/null
+++ b/runpod/apps/shim.py
@@ -0,0 +1,51 @@
+"""shell launcher for booting injected runtime scripts on custom images.
+
+builds the dockerArgs command that materializes a base64 env payload
+into a file and runs it with the image's python. constraints:
+
+ - POSIX sh only (no bash on busybox/alpine images)
+ - python may not be on sh's PATH (conda/venv images), so well-known
+ interpreter locations are probed
+ - no base64 binary assumed: the discovered python does the decode
+ - no python at all is a configuration error: the whole feature is
+ running the user's python function on their image, so a pythonless
+ image gets a loud, clear failure
+
+the launcher must stay single-quote-free internally: the host parses
+dockerArgs with a shell lexer and the whole script rides inside one
+pair of single quotes.
+"""
+
+# well-known interpreter locations beyond PATH
+_PYTHON_CANDIDATES = (
+ "python3",
+ "python",
+ "/usr/local/bin/python3",
+ "/usr/bin/python3",
+ "/opt/conda/bin/python",
+ "/opt/venv/bin/python",
+ "/venv/bin/python",
+ "/root/.venv/bin/python",
+ "/app/.venv/bin/python",
+ "/usr/local/bin/python",
+)
+
+
+def shell_launcher(env_var: str, dest: str) -> str:
+ """dockerArgs command that decodes $env_var into dest and execs it."""
+ probes = " ".join(_PYTHON_CANDIDATES)
+ script = (
+ f'PY=""; '
+ f"for c in {probes}; do "
+ f'if command -v "$c" >/dev/null 2>&1; then PY="$c"; break; fi; '
+ f"done; "
+ f'if [ -z "$PY" ]; then '
+ f'echo "[shim] FATAL: no python interpreter found in this image. "'
+ f'"custom images must include python3." >&2; '
+ f"exit 1; fi; "
+ f'echo "${env_var}" | "$PY" -c '
+ f'"import base64,sys;sys.stdout.buffer.write(base64.b64decode(sys.stdin.read()))" '
+ f"> {dest} && "
+ f'exec "$PY" {dest}'
+ )
+ return f"sh -c '{script}'"
diff --git a/runpod/apps/spec.py b/runpod/apps/spec.py
new file mode 100644
index 00000000..e87e9b03
--- /dev/null
+++ b/runpod/apps/spec.py
@@ -0,0 +1,289 @@
+"""resource specifications produced by app decorators.
+
+a ResourceSpec is the declarative description of one deployable resource.
+it is what the manifest serializes, what `rp deploy` ships to the backend,
+and what dev-session provisioning consumes. it holds no live state.
+"""
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+from .errors import InvalidResourceError
+from .gpu import GpuGroup, GpuType
+
+DEFAULT_WORKERS: Tuple[int, int] = (0, 3)
+
+SCALER_TYPES = frozenset({"QUEUE_DELAY", "REQUEST_COUNT"})
+DEFAULT_SCALER_VALUE = 4
+
+# cuda versions the platform can filter hosts by
+CUDA_VERSIONS = frozenset(
+ {
+ "11.8",
+ "12.0",
+ "12.1",
+ "12.2",
+ "12.3",
+ "12.4",
+ "12.5",
+ "12.6",
+ "12.7",
+ "12.8",
+ "12.9",
+ "13.0",
+ }
+)
+
+
+class ResourceKind(str, Enum):
+ QUEUE = "queue"
+ """queue-based serverless endpoint."""
+
+ API = "api"
+ """load-balanced serverless endpoint with http routes."""
+
+ TASK = "task"
+ """ephemeral pod compute, provisioned per call."""
+
+
+@dataclass(frozen=True)
+class RouteSpec:
+ """one http route on an api resource."""
+
+ method: str
+ path: str
+ handler_name: str
+
+
+def normalize_workers(workers: Union[int, Tuple[int, int], None]) -> Tuple[int, int]:
+ """int n -> (0, n); (min, max) passes through; None -> default."""
+ if workers is None:
+ return DEFAULT_WORKERS
+ if isinstance(workers, int):
+ parsed = (DEFAULT_WORKERS[0], workers)
+ elif isinstance(workers, (tuple, list)) and len(workers) == 2:
+ parsed = (int(workers[0]), int(workers[1]))
+ else:
+ raise InvalidResourceError(
+ f"workers must be an int or (min, max) tuple, got {workers!r}"
+ )
+ min_w, max_w = parsed
+ if min_w < 0 or max_w < 1:
+ raise InvalidResourceError(f"invalid worker range ({min_w}, {max_w})")
+ if min_w > max_w:
+ raise InvalidResourceError(
+ f"workers min ({min_w}) cannot exceed max ({max_w})"
+ )
+ return (min_w, max_w)
+
+
+def normalize_gpu(
+ gpu: Union[GpuGroup, GpuType, str, List[Union[GpuGroup, GpuType, str]], None],
+) -> Optional[List[str]]:
+ """normalize gpu input to a list of api-facing string values."""
+ if gpu is None:
+ return None
+ if not isinstance(gpu, list):
+ gpu = [gpu]
+ from .gpu import resolve_gpu_string
+
+ out: List[str] = []
+ for g in gpu:
+ if isinstance(g, (GpuGroup, GpuType)):
+ out.append(g.value)
+ elif isinstance(g, str):
+ # strings resolve strictly: pool ids and device names pass
+ # through, shorthands ("4090", "B200") expand, and typos
+ # fail at decoration time instead of at the api
+ out.extend(resolve_gpu_string(g))
+ else:
+ raise InvalidResourceError(
+ f"gpu must be a GpuGroup, GpuType, or string, got {type(g).__name__}"
+ )
+ return out
+
+
+def normalize_cpu(
+ cpu: Union[str, List[str], None],
+) -> Optional[List[str]]:
+ """normalize cpu instance ids to a list of strings."""
+ if cpu is None:
+ return None
+ if isinstance(cpu, str):
+ return [cpu]
+ if isinstance(cpu, list):
+ return [str(c) for c in cpu]
+ raise InvalidResourceError(
+ f"cpu must be an instance id string or list, got {type(cpu).__name__}"
+ )
+
+
+def normalize_scaler_type(scaler_type: Optional[str]) -> Optional[str]:
+ """validate a scaler type string, tolerating lowercase."""
+ if scaler_type is None:
+ return None
+ normalized = str(scaler_type).strip().upper()
+ if normalized not in SCALER_TYPES:
+ raise InvalidResourceError(
+ f"scaler_type must be one of {sorted(SCALER_TYPES)}, "
+ f"got {scaler_type!r}"
+ )
+ return normalized
+
+
+def normalize_cuda_version(version: Optional[str]) -> Optional[str]:
+ """validate a minimum cuda version string."""
+ if version is None:
+ return None
+ normalized = str(version).strip()
+ if normalized not in CUDA_VERSIONS:
+ raise InvalidResourceError(
+ f"min_cuda_version must be one of "
+ f"{', '.join(sorted(CUDA_VERSIONS))}, got {version!r}"
+ )
+ return normalized
+
+
+@dataclass
+class ResourceSpec:
+ """declarative config for one app resource."""
+
+ kind: ResourceKind
+ name: str
+ gpu: Optional[List[str]] = None
+ cpu: Optional[List[str]] = None
+ gpu_count: int = 1
+ workers: Tuple[int, int] = DEFAULT_WORKERS
+ idle_timeout: int = 60
+ dependencies: Optional[List[str]] = None
+ system_dependencies: Optional[List[str]] = None
+ volume: Optional[Any] = None
+ env: Optional[Dict[str, Any]] = None
+ datacenter: Optional[List[str]] = None
+ image: Optional[str] = None
+ registry_auth: Optional[str] = None
+ model: Optional[Any] = None
+ schedule: Optional[str] = None
+ max_concurrency: int = 1
+ execution_timeout_ms: int = 0
+ flashboot: bool = True
+ scaler_type: Optional[str] = None
+ scaler_value: int = DEFAULT_SCALER_VALUE
+ min_cuda_version: Optional[str] = None
+ accelerate_downloads: bool = True
+ container_disk_gb: Optional[int] = None
+ routes: List[RouteSpec] = field(default_factory=list)
+ asgi_factory: Optional[str] = None
+
+ def __post_init__(self) -> None:
+ if self.gpu is not None and self.cpu is not None:
+ raise InvalidResourceError(
+ f"resource '{self.name}': gpu and cpu are mutually exclusive"
+ )
+ if not self.name:
+ raise InvalidResourceError("resource name must not be empty")
+ if self.max_concurrency < 1:
+ raise InvalidResourceError(
+ f"resource '{self.name}': max_concurrency must be >= 1, "
+ f"got {self.max_concurrency}"
+ )
+ if self.execution_timeout_ms < 0:
+ raise InvalidResourceError(
+ f"resource '{self.name}': execution_timeout_ms must be >= 0"
+ )
+ if self.scaler_value < 1:
+ raise InvalidResourceError(
+ f"resource '{self.name}': scaler_value must be >= 1"
+ )
+ if self.container_disk_gb is not None and self.container_disk_gb < 1:
+ raise InvalidResourceError(
+ f"resource '{self.name}': container_disk_gb must be >= 1"
+ )
+ if self.min_cuda_version is not None and self.is_cpu:
+ raise InvalidResourceError(
+ f"resource '{self.name}': min_cuda_version has no effect "
+ f"on cpu resources"
+ )
+ if self.model is not None and self.kind is ResourceKind.TASK:
+ raise InvalidResourceError(
+ f"resource '{self.name}': platform-cached models are only "
+ f"available on queue and api resources; tasks download "
+ f"weights themselves"
+ )
+
+ @property
+ def is_cpu(self) -> bool:
+ return self.cpu is not None
+
+ @property
+ def effective_scaler_type(self) -> str:
+ """the scaler the endpoint deploys with: explicit or kind default."""
+ if self.scaler_type:
+ return self.scaler_type
+ return (
+ "REQUEST_COUNT" if self.kind is ResourceKind.API else "QUEUE_DELAY"
+ )
+
+ def to_manifest(self) -> Dict[str, Any]:
+ """serialize for the deploy manifest."""
+ data: Dict[str, Any] = {
+ "kind": self.kind.value,
+ "name": self.name,
+ "gpuCount": self.gpu_count,
+ "workersMin": self.workers[0],
+ "workersMax": self.workers[1],
+ "idleTimeout": self.idle_timeout,
+ }
+ if self.gpu is not None:
+ data["gpus"] = self.gpu
+ if self.cpu is not None:
+ data["instanceIds"] = self.cpu
+ if self.dependencies:
+ data["dependencies"] = self.dependencies
+ if self.system_dependencies:
+ data["systemDependencies"] = self.system_dependencies
+ if self.volume:
+ data["networkVolume"] = getattr(
+ self.volume, "name", None
+ ) or str(self.volume)
+ if self.env:
+ from .secret import render_env
+
+ data["env"] = render_env(self.env)
+ if self.datacenter:
+ data["locations"] = ",".join(self.datacenter)
+ if self.image:
+ data["imageName"] = self.image
+ if self.registry_auth:
+ data["registryAuth"] = self.registry_auth
+ if self.model:
+ from .model import model_reference
+
+ data["model"] = model_reference(self.model)
+ if self.schedule:
+ data["schedule"] = self.schedule
+ if self.max_concurrency != 1:
+ data["maxConcurrency"] = self.max_concurrency
+ if self.execution_timeout_ms:
+ data["executionTimeoutMs"] = self.execution_timeout_ms
+ if not self.flashboot:
+ data["flashboot"] = False
+ if self.scaler_type:
+ data["scalerType"] = self.scaler_type
+ if self.scaler_value != DEFAULT_SCALER_VALUE:
+ data["scalerValue"] = self.scaler_value
+ if self.min_cuda_version:
+ data["minCudaVersion"] = self.min_cuda_version
+ if not self.accelerate_downloads:
+ data["accelerateDownloads"] = False
+ if self.container_disk_gb:
+ data["containerDiskGb"] = self.container_disk_gb
+ if self.routes:
+ data["routes"] = [
+ {"method": r.method, "path": r.path, "handler": r.handler_name}
+ for r in self.routes
+ ]
+ if self.asgi_factory:
+ data["asgiFactory"] = self.asgi_factory
+ return data
diff --git a/runpod/apps/stubs.py b/runpod/apps/stubs.py
new file mode 100644
index 00000000..04579d01
--- /dev/null
+++ b/runpod/apps/stubs.py
@@ -0,0 +1,150 @@
+"""stubs for resources deployed from other codebases.
+
+when you know a resource exists but don't have its source, a stub gives
+you the same invocation surface as a local handle:
+
+ external_queue = runpod.Queue(app="other-app", name="transcribe")
+ external_queue.remote(audio_url="https://...")
+
+ external_api = runpod.Api(app="other-app", name="inference")
+ external_api.post("/generate", {"prompt": "hi"})
+
+resolution goes through the same server-side sentinel path as deployed
+handles, so a stub is just a handle without a function body.
+"""
+
+import os
+from typing import Any, AsyncIterator, Optional
+
+from .invoker import Invoker, StreamInvoker
+from .job import Job
+from .targets import SentinelTarget
+
+DEFAULT_ENV = "default"
+
+
+class _StubBase:
+ def __init__(
+ self,
+ *,
+ app: str,
+ name: Optional[str] = None,
+ id: Optional[str] = None,
+ env: Optional[str] = None,
+ ):
+ if (name is None) == (id is None):
+ raise ValueError("provide exactly one of name= or id=")
+ self.app_name = app
+ self.resource_name = name
+ self.resource_id = id
+ self.env = env or os.getenv("FLASH_ENVIRONMENT") or DEFAULT_ENV
+
+ def _target(self) -> SentinelTarget:
+ # id-based stubs still route through the sentinel; the id is
+ # passed as the resource key and resolved server-side
+ resource = self.resource_name or self.resource_id or ""
+ return SentinelTarget(self.app_name, self.env, resource)
+
+
+class Queue(_StubBase):
+ """client for a queue resource deployed elsewhere."""
+
+ def __init__(self, **kwargs: Any):
+ super().__init__(**kwargs)
+ self._job_options: dict = {}
+ self.remote = Invoker(self._remote_async)
+ self.stream = StreamInvoker(self._stream_async)
+ self.spawn = Invoker(self._spawn_async)
+ self.job = Invoker(self._job_async)
+
+ def with_options(
+ self,
+ *,
+ webhook: Optional[str] = None,
+ execution_timeout: Optional[int] = None,
+ ttl: Optional[int] = None,
+ low_priority: Optional[bool] = None,
+ s3_config: Optional[dict] = None,
+ ) -> "Queue":
+ """bind per-job options for the next call, returning a new stub.
+
+ options apply per invocation and stack across chained calls; the
+ original stub is untouched.
+ """
+ from .targets import build_job_options, merge_job_options
+
+ new_options = build_job_options(
+ webhook, execution_timeout, ttl, low_priority, s3_config
+ )
+ clone = Queue(
+ app=self.app_name,
+ name=self.resource_name,
+ id=self.resource_id,
+ env=self.env,
+ )
+ clone._job_options = merge_job_options(self._job_options, new_options)
+ return clone
+
+ def _payload(self, kwargs: dict) -> dict:
+ payload = {"input": kwargs or {"__empty": True}}
+ if self._job_options:
+ payload.update(self._job_options)
+ return payload
+
+ async def _remote_async(self, **kwargs: Any) -> Any:
+ return await self._target().invoke(self._payload(kwargs))
+
+ async def _spawn_async(self, **kwargs: Any) -> Job:
+ target = self._target()
+ data = await target.submit(self._payload(kwargs))
+ return Job(data, target)
+
+ async def _stream_async(self, **kwargs: Any) -> AsyncIterator[Any]:
+ target = self._target()
+ data = await target.submit(self._payload(kwargs))
+ async for chunk in target.stream_job(data["id"]):
+ yield chunk
+
+ async def _job_async(self, job_id: str) -> Job:
+ return Job(
+ {"id": job_id, "status": "UNKNOWN"},
+ self._target(),
+ )
+
+ def __repr__(self) -> str:
+ ref = self.resource_name or self.resource_id
+ return f""
+
+
+class _StubRouteCaller:
+ __slots__ = ("_stub", "_method")
+
+ def __init__(self, stub: "Api", method: str):
+ self._stub = stub
+ self._method = method
+
+ def __call__(self, path: str, body: Any = None, **kwargs: Any) -> Any:
+ from .context import block
+
+ return block(self.aio(path, body, **kwargs))
+
+ async def aio(self, path: str, body: Any = None, **kwargs: Any) -> Any:
+ return await self._stub._target().request(
+ self._method, path, body, **kwargs
+ )
+
+
+class Api(_StubBase):
+ """client for an api resource deployed elsewhere."""
+
+ def __init__(self, **kwargs: Any):
+ super().__init__(**kwargs)
+ self.get = _StubRouteCaller(self, "GET")
+ self.post = _StubRouteCaller(self, "POST")
+ self.put = _StubRouteCaller(self, "PUT")
+ self.delete = _StubRouteCaller(self, "DELETE")
+ self.patch = _StubRouteCaller(self, "PATCH")
+
+ def __repr__(self) -> str:
+ ref = self.resource_name or self.resource_id
+ return f""
diff --git a/runpod/apps/targets.py b/runpod/apps/targets.py
new file mode 100644
index 00000000..08a9136c
--- /dev/null
+++ b/runpod/apps/targets.py
@@ -0,0 +1,869 @@
+"""invocation targets: where a `.remote()` call actually goes.
+
+a target is resolved per-call by App._resolve and knows how to reach one
+deployed resource. all resolution is server-side (sentinel headers or the
+runpod api); no local state is kept.
+"""
+
+import inspect
+import json
+import os
+from abc import ABC, abstractmethod
+from typing import Any, AsyncIterator, Callable, Dict, Optional
+
+import aiohttp
+
+from ..user_agent import USER_AGENT
+from .errors import EndpointNotFound, RemoteExecutionError
+from .protocol import FORMAT_JSON, FunctionRequest, FunctionResponse
+from .spec import ResourceSpec
+
+# the sentinel pseudo-endpoint id; ai-api resolves the real endpoint from
+# the X-Flash-App / X-Flash-Environment / X-Flash-Endpoint headers.
+SENTINEL_ID = "flash"
+
+DEFAULT_TIMEOUT_SECONDS = 300.0
+
+# terminal job statuses on the queue data plane
+FINAL_STATUSES = frozenset({"COMPLETED", "FAILED", "CANCELLED", "TIMED_OUT"})
+
+
+def _api_key() -> str:
+ key = os.getenv("RUNPOD_API_KEY")
+ if not key:
+ import runpod
+
+ key = runpod.api_key
+ if not key:
+ raise RuntimeError(
+ "no api key configured. run `rp login` or set RUNPOD_API_KEY."
+ )
+ return key
+
+
+def _endpoint_url_base() -> str:
+ import runpod
+
+ return runpod.endpoint_url_base.rstrip("/")
+
+
+def _lb_domain() -> str:
+ """host portion of the data-plane base url, for lb subdomain urls."""
+ base = _endpoint_url_base()
+ host = base.split("://", 1)[-1]
+ return host.split("/", 1)[0]
+
+
+def _headers(extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
+ headers = {
+ "Authorization": f"Bearer {_api_key()}",
+ "Content-Type": "application/json",
+ "User-Agent": USER_AGENT,
+ }
+ if extra:
+ headers.update(extra)
+ return headers
+
+
+def args_to_input(fn: Callable, args: tuple, kwargs: dict) -> Dict[str, Any]:
+ """map positional args onto parameter names so the job input is a dict."""
+ sig = inspect.signature(fn)
+ params = [n for n in sig.parameters if n not in ("self", "cls")]
+ body: Dict[str, Any] = {}
+ for i, arg in enumerate(args):
+ if i >= len(params):
+ raise TypeError(
+ f"{fn.__name__}() got {len(args)} positional args, "
+ f"expected at most {len(params)}"
+ )
+ body[params[i]] = arg
+ body.update(kwargs)
+ # the platform strips empty input dicts from jobs; keep one field so
+ # the worker's job polling never sees a missing input
+ if not body:
+ body = {"__empty": True}
+ return body
+
+
+def build_job_options(
+ webhook: Optional[str] = None,
+ execution_timeout: Optional[int] = None,
+ ttl: Optional[int] = None,
+ low_priority: Optional[bool] = None,
+ s3_config: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """shape per-job queue options into the wire payload.
+
+ these ride alongside `input` on the run/runsync request, matching
+ the raw data-plane api's job payload.
+ """
+ options: Dict[str, Any] = {}
+ if webhook is not None:
+ options["webhook"] = webhook
+ policy: Dict[str, Any] = {}
+ if execution_timeout is not None:
+ policy["executionTimeout"] = execution_timeout
+ if ttl is not None:
+ policy["ttl"] = ttl
+ if low_priority is not None:
+ policy["lowPriority"] = low_priority
+ if policy:
+ options["policy"] = policy
+ if s3_config is not None:
+ options["s3Config"] = s3_config
+ return options
+
+
+def merge_job_options(
+ base: Dict[str, Any], new: Dict[str, Any]
+) -> Dict[str, Any]:
+ """combine two option sets; policy fields accumulate rather than
+ overwrite so chained with_options calls compose."""
+ merged = dict(base)
+ for key, value in new.items():
+ if key == "policy" and isinstance(merged.get("policy"), dict):
+ merged["policy"] = {**merged["policy"], **value}
+ else:
+ merged[key] = value
+ return merged
+
+
+def unwrap_job_output(data: Dict[str, Any]) -> Any:
+ """extract output from a runsync-style response, raising on failure."""
+ if data.get("status") == "FAILED" or data.get("error"):
+ err = data.get("error") or data.get("output", {}).get("error", "unknown")
+ raise RemoteExecutionError(f"remote execution failed: {err}")
+ output = data.get("output", data)
+ if isinstance(output, dict) and "error" in output:
+ raise RemoteExecutionError(f"remote execution failed: {output['error']}")
+ return output
+
+
+class InvocationTarget(ABC):
+ """one resolved destination for remote calls.
+
+ each target owns its payload shape: sentinel targets send plain
+ kwargs (the deployed worker resolves functions from the unpacked
+ build), live targets send the full FunctionRequest with source.
+ """
+
+ def build_payload(
+ self, fn: Callable, spec: ResourceSpec, args: tuple, kwargs: dict
+ ) -> Dict[str, Any]:
+ return {"input": args_to_input(fn, args, kwargs)}
+
+ def unwrap(self, data: Dict[str, Any]) -> Any:
+ return unwrap_job_output(data)
+
+ @abstractmethod
+ async def invoke(self, payload: Dict[str, Any], *, timeout: float) -> Any:
+ """submit and wait for the result."""
+
+ @abstractmethod
+ async def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ """submit without waiting; returns the raw job data."""
+
+ async def job_status(self, job_id: str) -> Dict[str, Any]:
+ """fetch the current state of a submitted job."""
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support job status"
+ )
+
+ async def cancel_job(self, job_id: str) -> Dict[str, Any]:
+ """cancel a submitted job."""
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support job cancellation"
+ )
+
+ async def retry_job(self, job_id: str) -> Dict[str, Any]:
+ """retry a failed or timed-out job."""
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support job retries"
+ )
+
+ def stream_job(
+ self, job_id: str, *, timeout: float
+ ) -> AsyncIterator[Any]:
+ """yield partial outputs of a generator job as they arrive."""
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support job streaming"
+ )
+
+ async def wait(
+ self,
+ job_data: Dict[str, Any],
+ *,
+ timeout: float,
+ on_status: Optional[Callable[[Dict[str, Any]], None]] = None,
+ ) -> Any:
+ """wait for a submitted job to finish and return its output."""
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support job polling"
+ )
+
+ async def request(
+ self, method: str, path: str, body: Any = None, *, timeout: float
+ ) -> Any:
+ """plain http call (lb resources only)."""
+ raise NotImplementedError(f"{type(self).__name__} does not serve http routes")
+
+
+# transient statuses worth retrying: gateway/edge errors that occur
+# while the sentinel cache warms or an edge node hiccups. 4xx (other
+# than 429) are never retried; the request itself is wrong.
+RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504, 520, 522, 524})
+RETRY_ATTEMPTS = 4
+RETRY_BASE_DELAY = 0.5
+
+
+async def _request_json(
+ method: str,
+ url: str,
+ headers: Dict[str, str],
+ timeout: float,
+ *,
+ payload: Any = None,
+ app_name: str = "",
+ resource_name: str = "",
+) -> Dict[str, Any]:
+ """http json call with exponential backoff on transient failures."""
+ import asyncio
+
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
+ last_exc: Optional[Exception] = None
+ for attempt in range(RETRY_ATTEMPTS):
+ if attempt:
+ await asyncio.sleep(RETRY_BASE_DELAY * (2 ** (attempt - 1)))
+ try:
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.request(
+ method, url, json=payload, headers=headers
+ ) as resp:
+ if resp.status == 404:
+ raise EndpointNotFound(app_name, resource_name)
+ if resp.status in RETRYABLE_STATUSES:
+ last_exc = aiohttp.ClientResponseError(
+ resp.request_info,
+ resp.history,
+ status=resp.status,
+ message=await resp.text(),
+ )
+ continue
+ resp.raise_for_status()
+ return await resp.json()
+ except (aiohttp.ClientConnectionError, asyncio.TimeoutError) as exc:
+ # connection-level failures (reset, ssl hiccup, dns) are
+ # transient by nature; response-level errors above already
+ # decided retryability
+ last_exc = exc
+ continue
+ if last_exc is None: # pragma: no cover - loop always runs once
+ raise RuntimeError("request retry loop exited cleanly")
+ raise last_exc
+
+
+async def _post_json(
+ url: str,
+ payload: Any,
+ headers: Dict[str, str],
+ timeout: float,
+ *,
+ app_name: str = "",
+ resource_name: str = "",
+) -> Dict[str, Any]:
+ return await _request_json(
+ "POST",
+ url,
+ headers,
+ timeout,
+ payload=payload,
+ app_name=app_name,
+ resource_name=resource_name,
+ )
+
+
+async def _get_json(
+ url: str, headers: Dict[str, str], timeout: float
+) -> Dict[str, Any]:
+ return await _request_json("GET", url, headers, timeout)
+
+
+async def _wait_terminal(
+ base_url: str,
+ data: Dict[str, Any],
+ headers: Dict[str, str],
+ timeout: float,
+ on_status: Optional[Callable[[Dict[str, Any]], None]] = None,
+) -> Dict[str, Any]:
+ """poll /status until the job reaches a terminal state.
+
+ runsync returns early (e.g. IN_QUEUE) when the job outlives the sync
+ window, typically on cold starts; polling covers the rest. on_status,
+ when given, sees every payload (observability hooks read workerId).
+ """
+ import asyncio
+ import time
+
+ if on_status is not None:
+ on_status(data)
+ deadline = time.monotonic() + timeout
+ interval = 0.5
+ # observed polls (dev sessions) stay tight so the worker id
+ # surfaces fast enough for log streams to attach in realtime
+ max_interval = 1.0 if on_status is not None else 5.0
+ while data.get("status") not in FINAL_STATUSES:
+ job_id = data.get("id")
+ if not job_id:
+ return data
+ if time.monotonic() >= deadline:
+ raise TimeoutError(
+ f"job {job_id} did not complete within {timeout}s "
+ f"(last status: {data.get('status', 'UNKNOWN')})"
+ )
+ await asyncio.sleep(interval)
+ interval = min(interval * 1.5, max_interval)
+ data = await _get_json(f"{base_url}/status/{job_id}", headers, 30.0)
+ if on_status is not None:
+ on_status(data)
+ return data
+
+
+class QueueClient:
+ """queue data-plane client for a single endpoint id.
+
+ owns the run/runsync/status/cancel/retry routes and the lb subdomain
+ for http resources. targets compose it with their own routing
+ headers: the sentinel client carries flash headers, a live client
+ carries plain auth headers.
+ """
+
+ def __init__(
+ self,
+ endpoint_id: str,
+ headers: Callable[[], Dict[str, str]],
+ *,
+ app_name: str = "",
+ resource_name: str = "",
+ ):
+ self.endpoint_id = endpoint_id
+ self._headers = headers
+ self._app_name = app_name
+ self._resource_name = resource_name
+
+ @property
+ def base_url(self) -> str:
+ return f"{_endpoint_url_base()}/{self.endpoint_id}"
+
+ async def _call(
+ self,
+ method: str,
+ path: str,
+ payload: Any = None,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ ) -> Dict[str, Any]:
+ return await _request_json(
+ method,
+ f"{self.base_url}/{path}",
+ self._headers(),
+ timeout,
+ payload=payload,
+ app_name=self._app_name,
+ resource_name=self._resource_name,
+ )
+
+ async def run(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ return await self._call("POST", "run", payload)
+
+ async def runsync(
+ self, payload: Dict[str, Any], *, timeout: float
+ ) -> Dict[str, Any]:
+ return await self._call("POST", "runsync", payload, timeout)
+
+ async def status(self, job_id: str) -> Dict[str, Any]:
+ return await self._call("GET", f"status/{job_id}")
+
+ async def cancel(self, job_id: str) -> Dict[str, Any]:
+ return await self._call("POST", f"cancel/{job_id}")
+
+ async def retry(self, job_id: str) -> Dict[str, Any]:
+ return await self._call("POST", f"retry/{job_id}")
+
+ async def stream(
+ self, job_id: str, *, timeout: float
+ ) -> AsyncIterator[Any]:
+ """yield partial outputs from /stream until the job is terminal.
+
+ the route long-polls (wait=) and drains buffered chunks, so each
+ call returns quickly with whatever is available.
+ """
+ import time
+
+ deadline = time.monotonic() + timeout
+ while True:
+ if time.monotonic() >= deadline:
+ raise TimeoutError(
+ f"job {job_id} stream did not complete within {timeout}s"
+ )
+ data = await self._call("GET", f"stream/{job_id}")
+ if data.get("status") == "FAILED" or data.get("error"):
+ unwrap_job_output(data)
+ for chunk in data.get("stream") or []:
+ yield chunk.get("output")
+ if data.get("status") in FINAL_STATUSES:
+ return
+
+ async def wait(
+ self,
+ data: Dict[str, Any],
+ *,
+ timeout: float,
+ on_status: Optional[Callable[[Dict[str, Any]], None]] = None,
+ ) -> Dict[str, Any]:
+ return await _wait_terminal(
+ self.base_url, data, self._headers(), timeout, on_status
+ )
+
+ async def http(
+ self,
+ method: str,
+ path: str,
+ body: Any = None,
+ *,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ ) -> Any:
+ """plain http call through the endpoint's lb subdomain."""
+ url = f"https://{self.endpoint_id}.{_lb_domain()}{path}"
+ client_timeout = aiohttp.ClientTimeout(total=timeout)
+ async with aiohttp.ClientSession(timeout=client_timeout) as session:
+ async with session.request(
+ method, url, json=body, headers=self._headers()
+ ) as resp:
+ if resp.status == 404 and self._app_name:
+ raise EndpointNotFound(self._app_name, self._resource_name)
+ resp.raise_for_status()
+ text = await resp.text()
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ return text
+
+
+class SentinelTarget(InvocationTarget):
+ """routes through the sentinel endpoint; ai-api resolves the real
+ endpoint server-side from app/env/resource headers.
+
+ used for deployed resources from every context (worker-to-worker and
+ local caller alike), which is what makes resolution stateless.
+ """
+
+ def __init__(self, app_name: str, env_name: str, resource_name: str):
+ self.app_name = app_name
+ self.env_name = env_name
+ self.resource_name = resource_name
+ self._client = QueueClient(
+ SENTINEL_ID,
+ self._sentinel_headers,
+ app_name=app_name,
+ resource_name=resource_name,
+ )
+
+ def _sentinel_headers(self) -> Dict[str, str]:
+ return _headers(
+ {
+ "X-Flash-App": self.app_name,
+ "X-Flash-Environment": self.env_name,
+ "X-Flash-Endpoint": self.resource_name,
+ }
+ )
+
+ async def invoke(
+ self, payload: Dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SECONDS
+ ) -> Any:
+ data = await self._client.runsync(payload, timeout=timeout)
+ data = await self._client.wait(data, timeout=timeout)
+ return self.unwrap(data)
+
+ async def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ return await self._client.run(payload)
+
+ async def job_status(self, job_id: str) -> Dict[str, Any]:
+ return await self._client.status(job_id)
+
+ async def cancel_job(self, job_id: str) -> Dict[str, Any]:
+ return await self._client.cancel(job_id)
+
+ async def retry_job(self, job_id: str) -> Dict[str, Any]:
+ return await self._client.retry(job_id)
+
+ def stream_job(
+ self, job_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS
+ ) -> AsyncIterator[Any]:
+ return self._client.stream(job_id, timeout=timeout)
+
+ async def wait(
+ self,
+ job_data: Dict[str, Any],
+ *,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ on_status: Optional[Callable[[Dict[str, Any]], None]] = None,
+ ) -> Any:
+ data = await self._client.wait(
+ job_data, timeout=timeout, on_status=on_status
+ )
+ return self.unwrap(data)
+
+ async def request(
+ self,
+ method: str,
+ path: str,
+ body: Any = None,
+ *,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ ) -> Any:
+ return await self._client.http(method, path, body, timeout=timeout)
+
+
+class LiveTarget(InvocationTarget):
+ """a dev-session live endpoint: source code ships with every request,
+ so the worker executes whatever is currently on disk.
+
+ provisioning of the live endpoint itself is owned by the dev session
+ (see runpod.apps.dev); this target only speaks to an endpoint id.
+ when an event sink is attached, each call gets a dispatch line,
+ live worker status/log feed, and a completion line.
+ """
+
+ def __init__(
+ self,
+ endpoint_id: str,
+ resource_name: str = "",
+ events: Optional[object] = None,
+ metrics_key: Optional[str] = None,
+ ):
+ self.endpoint_id = endpoint_id
+ self.resource_name = resource_name
+ self.events = events
+ self.metrics_key = metrics_key
+ self._client = QueueClient(endpoint_id, _headers)
+ self._source_target: Optional[Any] = None
+ self._source_resource: str = ""
+ self._source_spec: Optional[ResourceSpec] = None
+ # source hash the worker last confirmed; None forces a sync
+ self._synced_hash: Optional[str] = None
+
+ def attach_source(
+ self, target: Any, resource: str, spec: Optional[ResourceSpec] = None
+ ) -> None:
+ """register the object whose module backs this api resource.
+
+ live api endpoints materialize their routes from module source
+ pushed over /_runpod/sync (once per source change), keeping dev
+ behavior identical to deployed. the spec rides along so the
+ worker can install the resource's dependencies before @init.
+ """
+ self._source_target = target
+ self._source_resource = resource
+ self._source_spec = spec
+
+ async def _sync_source(self, timeout: float) -> None:
+ """push the current module source to the worker if it changed."""
+ if self._source_target is None:
+ return
+ import hashlib
+
+ from .serialization import get_function_source
+
+ source = get_function_source(self._source_target)
+ digest = hashlib.sha256(source.encode()).hexdigest()
+ if digest == self._synced_hash:
+ return
+ url = f"https://{self.endpoint_id}.{_lb_domain()}/_runpod/sync"
+ payload: Dict[str, Any] = {
+ "source": source,
+ "resource": self._source_resource,
+ }
+ if self._source_spec is not None:
+ payload["dependencies"] = self._source_spec.dependencies
+ payload["system_dependencies"] = (
+ self._source_spec.system_dependencies
+ )
+ await _post_json(url, payload, _headers(), timeout)
+ self._synced_hash = digest
+
+ def build_payload(
+ self, fn: Callable, spec: ResourceSpec, args: tuple, kwargs: dict
+ ) -> Dict[str, Any]:
+ from .serialization import get_function_source, require_json_args
+
+ # queue arguments are plain json, the same contract deployed
+ # workers serve; non-json arguments fail here in dev exactly as
+ # they would against a deployed endpoint
+ require_json_args(fn, args, kwargs)
+ request = FunctionRequest(
+ function_name=fn.__name__,
+ function_code=get_function_source(fn),
+ args=list(args),
+ kwargs=dict(kwargs),
+ dependencies=spec.dependencies,
+ system_dependencies=spec.system_dependencies,
+ accelerate_downloads=spec.accelerate_downloads,
+ serialization_format=FORMAT_JSON,
+ )
+ return {"input": request.to_input()}
+
+ def unwrap(self, data: Dict[str, Any]) -> Any:
+ output = unwrap_job_output(data)
+ # the live handler is a generator handler, so job output is an
+ # aggregated list: one plain response for normal functions,
+ # __stream__-marked chunk envelopes for generator functions
+ if isinstance(output, list):
+ if (
+ len(output) == 1
+ and isinstance(output[0], dict)
+ and not output[0].get("__stream__")
+ ):
+ return self._unwrap_response(output[0])
+ return [self._unwrap_response(o) for o in output]
+ return self._unwrap_response(output)
+
+ def _unwrap_response(self, output: Any) -> Any:
+ if isinstance(output, dict) and "success" in output:
+ response = FunctionResponse.from_output(output)
+ if not response.success:
+ raise RemoteExecutionError(
+ f"remote execution failed: {response.error or 'unknown'}"
+ )
+ if response.result is not None:
+ from .serialization import deserialize_result
+
+ return deserialize_result(response.result)
+ return response.json_result
+ return output
+
+ def _monitor(self):
+ if self.events is None:
+ return None
+ from .monitor import WorkerMonitor
+
+ return WorkerMonitor(
+ self.endpoint_id,
+ self.resource_name,
+ self.events,
+ metrics_key=self.metrics_key,
+ )
+
+ async def invoke(
+ self, payload: Dict[str, Any], *, timeout: float = DEFAULT_TIMEOUT_SECONDS
+ ) -> Any:
+ import time
+
+ from .monitor import emit
+
+ monitor = self._monitor()
+ emit(self.events, "dispatch", self.resource_name)
+ start = time.monotonic()
+ if monitor is not None:
+ await monitor.start()
+ try:
+ if monitor is not None:
+ # async submit + status polling: runsync would hold the
+ # connection, hiding the workerId until completion and
+ # starving the live worker feed
+ data = await self._client.run(payload)
+ else:
+ data = await self._client.runsync(payload, timeout=timeout)
+ data = await self._client.wait(
+ data,
+ timeout=timeout,
+ on_status=monitor.on_status if monitor else None,
+ )
+ result = self.unwrap(data)
+ except Exception:
+ emit(
+ self.events,
+ "request_failed",
+ self.resource_name,
+ time.monotonic() - start,
+ )
+ raise
+ finally:
+ if monitor is not None:
+ await monitor.stop()
+ emit(
+ self.events,
+ "request_completed",
+ self.resource_name,
+ time.monotonic() - start,
+ )
+ return result
+
+ async def submit(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ return await self._client.run(payload)
+
+ async def job_status(self, job_id: str) -> Dict[str, Any]:
+ return await self._client.status(job_id)
+
+ async def cancel_job(self, job_id: str) -> Dict[str, Any]:
+ return await self._client.cancel(job_id)
+
+ async def retry_job(self, job_id: str) -> Dict[str, Any]:
+ return await self._client.retry(job_id)
+
+ async def stream_job(
+ self, job_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS
+ ) -> AsyncIterator[Any]:
+ # live workers stream serialized chunk envelopes through the
+ # same /stream route deployed workers use
+ async for chunk in self._client.stream(job_id, timeout=timeout):
+ yield self._unwrap_response(chunk)
+
+ async def wait(
+ self,
+ job_data: Dict[str, Any],
+ *,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ on_status: Optional[Callable[[Dict[str, Any]], None]] = None,
+ ) -> Any:
+ data = await self._client.wait(
+ job_data, timeout=timeout, on_status=on_status
+ )
+ return self.unwrap(data)
+
+ async def request(
+ self,
+ method: str,
+ path: str,
+ body: Any = None,
+ *,
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
+ ) -> Any:
+ import time
+
+ from .monitor import emit
+
+ emit(self.events, "dispatch", self.resource_name, f"{method} {path}")
+ start = time.monotonic()
+ try:
+ await self._sync_source(timeout)
+ result = await self._client.http(method, path, body, timeout=timeout)
+ except Exception:
+ emit(
+ self.events,
+ "request_failed",
+ self.resource_name,
+ time.monotonic() - start,
+ )
+ raise
+ emit(
+ self.events,
+ "request_completed",
+ self.resource_name,
+ time.monotonic() - start,
+ )
+ return result
+
+
+class PodTarget(InvocationTarget):
+ """ephemeral pod execution for @app.task: provision a pod, run the
+ function body, collect the result, terminate.
+
+ tasks run to completion far beyond the queue data plane's sync
+ window, so the transport is a direct http channel to a single-shot
+ runner on the pod (see runpod.apps.tasks).
+ """
+
+ # tasks default to a long window; the pod's terminateAfter is the backstop
+ TASK_TIMEOUT_SECONDS = 3600.0
+
+ def __init__(
+ self,
+ spec: ResourceSpec,
+ fn: Callable,
+ events: Optional[object] = None,
+ ):
+ self.spec = spec
+ self.fn = fn
+ self.events = events
+
+ def build_payload(
+ self, fn: Callable, spec: ResourceSpec, args: tuple, kwargs: dict
+ ) -> Dict[str, Any]:
+ from .serialization import (
+ get_function_source,
+ serialize_args,
+ serialize_kwargs,
+ )
+
+ request = FunctionRequest(
+ function_name=fn.__name__,
+ function_code=get_function_source(fn),
+ args=serialize_args(args),
+ kwargs=serialize_kwargs(kwargs),
+ dependencies=spec.dependencies,
+ system_dependencies=spec.system_dependencies,
+ accelerate_downloads=spec.accelerate_downloads,
+ )
+ return request.to_input()
+
+ async def invoke(
+ self, payload: Dict[str, Any], *, timeout: float = TASK_TIMEOUT_SECONDS
+ ) -> Any:
+ import time
+
+ from .monitor import emit
+ from .tasks import TaskExecution, unwrap_task_response
+
+ name = self.spec.name
+ hardware = ",".join(self.spec.cpu or self.spec.gpu or ["any"])
+ emit(self.events, "dispatch", name)
+ emit(self.events, "task_status", name, f"provisioning pod on {hardware}")
+ start = time.monotonic()
+
+ stream = None
+ execution = TaskExecution(self.spec)
+ try:
+ await execution.start()
+ if execution.pod_id:
+ emit(
+ self.events,
+ "task_status",
+ name,
+ f"pod {execution.pod_id[:12]} waiting for runtime",
+ )
+ if self.events is not None:
+ from .monitor import PodLogStream
+
+ stream = PodLogStream(execution.pod_id, name, self.events)
+ stream.attach()
+ await execution.wait_ready()
+ emit(self.events, "worker_ready", name, execution.pod_id or "")
+ response = await execution.execute(payload, timeout)
+ except Exception:
+ emit(
+ self.events,
+ "request_failed",
+ name,
+ time.monotonic() - start,
+ )
+ raise
+ finally:
+ if stream is not None:
+ await stream.stop()
+ await execution.terminate()
+ result = unwrap_task_response(response)
+ emit(
+ self.events,
+ "request_completed",
+ name,
+ time.monotonic() - start,
+ )
+ return result
+
+ async def submit(self, payload: Dict[str, Any]) -> Any:
+ from .tasks import TaskExecution, TaskJob
+
+ execution = TaskExecution(self.spec)
+ await execution.start()
+ await execution.wait_ready()
+ await execution.submit(payload)
+ return TaskJob(execution)
diff --git a/runpod/apps/tasks.py b/runpod/apps/tasks.py
new file mode 100644
index 00000000..cb46fc6d
--- /dev/null
+++ b/runpod/apps/tasks.py
@@ -0,0 +1,376 @@
+"""ephemeral pod execution for @app.task.
+
+lifecycle per call:
+ 1. deploy a pod on a task runtime image (runpod/task*, built from
+ runtimes/task in this repo). custom images get the runner source
+ delivered base64-encoded in the pod env and booted via dockerArgs,
+ so any image with a python3 binary works.
+ 2. wait for the runner's /ping via the pod http proxy
+ 3. POST the FunctionRequest to /execute (remote) or /submit (spawn)
+ 4. collect the response, terminate the pod
+
+`terminateAfter` is set at deploy time as a server-side safety net so a
+crashed client cannot leak a running pod indefinitely.
+"""
+
+import asyncio
+import base64
+import logging
+import secrets
+import time
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import aiohttp
+
+from .api import AppsApiClient
+from .errors import RemoteExecutionError
+from .spec import ResourceSpec
+
+log = logging.getLogger(__name__)
+
+TASK_PORT = 8080
+
+# safety net: pods self-terminate server-side after this long
+DEFAULT_MAX_LIFETIME = timedelta(hours=1)
+
+import os as _os
+
+READY_POLL_INTERVAL = 2.0
+READY_TIMEOUT = 600.0
+RESULT_POLL_INTERVAL = 2.0
+
+
+def _runner_source() -> str:
+ """single-file runner for bare-image bootstrap.
+
+ the shared executor is concatenated ahead of the task server so the
+ shipped file has no runpod imports; the server's executor import
+ fails on the pod and falls through to the names already in scope.
+ """
+ runtimes = Path(__file__).parent.parent / "runtimes"
+ executor = (runtimes / "executor.py").read_text()
+ server = (runtimes / "task" / "runner.py").read_text()
+ return f"{executor}\n\n{server}"
+
+
+def _bootstrap_command() -> str:
+ """shell command that materializes and starts the runner."""
+ from .shim import shell_launcher
+
+ return shell_launcher("RUNPOD_TASK_RUNNER_B64", "/task_runner.py")
+
+
+def cuda_versions_at_least(minimum: str) -> List[str]:
+ """all platform cuda versions >= minimum, ascending."""
+ from .spec import CUDA_VERSIONS
+
+ def _key(version: str):
+ major, minor = version.split(".")
+ return (int(major), int(minor))
+
+ floor = _key(minimum)
+ return sorted(
+ (v for v in CUDA_VERSIONS if _key(v) >= floor), key=_key
+ )
+
+
+def _device_names(gpu: Optional[List[str]]) -> List[str]:
+ """pods take exact device names, not pool ids; expand pool ids
+ (e.g. ADA_24 -> NVIDIA GeForce RTX 4090) and pass device names
+ through. no gpu constraint means any device, which the pod api
+ only understands as the full device list."""
+ from .gpu import POOLS_TO_TYPES, GpuGroup
+
+ if not gpu:
+ return sorted(
+ {t.value for types in POOLS_TO_TYPES.values() for t in types}
+ )
+ names: List[str] = []
+ for entry in gpu:
+ try:
+ names.extend(GpuGroup(entry).device_names() or [entry])
+ except ValueError:
+ names.append(entry)
+ return names
+
+
+def _proxy_url(pod_id: str) -> str:
+ return f"https://{pod_id}-{TASK_PORT}.proxy.runpod.net"
+
+
+def _pod_input(spec: ResourceSpec, token: str, task_name: str) -> Dict[str, Any]:
+ """build the pod deploy input for one task execution.
+
+ runtime images have the runner baked in (CMD starts it); custom
+ images bootstrap the runner from a base64 env payload via dockerArgs.
+ """
+ terminate_after = (
+ datetime.now(timezone.utc) + DEFAULT_MAX_LIFETIME
+ ).isoformat()
+
+ from .secret import render_env
+
+ env = {
+ "RUNPOD_TASK_TOKEN": token,
+ "RUNPOD_TASK_PORT": str(TASK_PORT),
+ **render_env(spec.env),
+ }
+
+ from .images import image_for_spec, local_python_version
+
+ pod: Dict[str, Any] = {
+ "name": f"task-{task_name}-{secrets.token_hex(4)}",
+ "imageName": image_for_spec(
+ spec, python_version=local_python_version()
+ ),
+ "ports": f"{TASK_PORT}/http",
+ "containerDiskInGb": spec.container_disk_gb
+ or (10 if spec.is_cpu else 30),
+ "terminateAfter": terminate_after,
+ "supportPublicIp": True,
+ }
+
+ if spec.image:
+ # custom image: inject the runner source and boot it explicitly
+ env["RUNPOD_TASK_RUNNER_B64"] = base64.b64encode(
+ _runner_source().encode()
+ ).decode()
+ package_spec = _os.environ.get("RUNPOD_PACKAGE_SPEC")
+ if package_spec:
+ env["RUNPOD_PACKAGE_SPEC"] = package_spec
+ pod["dockerArgs"] = _bootstrap_command()
+
+ pod["env"] = [{"key": k, "value": v} for k, v in env.items()]
+ if spec.datacenter:
+ pod["dataCenterIds"] = spec.datacenter
+ else:
+ # spread across the storage-supported set; a null DC can pin
+ # repeated deploys to one (possibly broken) machine
+ from .datacenter import CPU3_DATACENTERS, DataCenter
+
+ pool = CPU3_DATACENTERS if spec.is_cpu else DataCenter.all()
+ pod["dataCenterIds"] = [dc.value for dc in pool]
+ if spec.is_cpu:
+ pod["instanceIds"] = spec.cpu
+ else:
+ pod["gpuTypeIdList"] = _device_names(spec.gpu)
+ pod["gpuCount"] = spec.gpu_count
+ if spec.min_cuda_version:
+ # pods have no min-version filter; allow every version at
+ # or above the requested floor
+ pod["allowedCudaVersions"] = cuda_versions_at_least(
+ spec.min_cuda_version
+ )
+ return pod
+
+
+class TaskExecution:
+ """one pod running one function."""
+
+ def __init__(self, spec: ResourceSpec, api: Optional[AppsApiClient] = None):
+ self.spec = spec
+ self.api = api or AppsApiClient()
+ self.token = secrets.token_urlsafe(32)
+ self.pod_id: Optional[str] = None
+
+ @property
+ def _headers(self) -> Dict[str, str]:
+ return {"Authorization": f"Bearer {self.token}"}
+
+ async def start(self) -> None:
+ pod = _pod_input(self.spec, self.token, self.spec.name)
+ if self.spec.registry_auth:
+ from .registry import resolve_registry_auth
+
+ pod["containerRegistryAuthId"] = await resolve_registry_auth(
+ self.spec.registry_auth, api=self.api
+ )
+ if self.spec.volume:
+ pod = await self._attach_volume(pod)
+ result = await self.api.deploy_task_pod(
+ pod, is_cpu=self.spec.is_cpu
+ )
+ self.pod_id = result["id"]
+ log.info("task pod %s deployed for %s", self.pod_id, self.spec.name)
+
+ async def _attach_volume(self, pod: Dict[str, Any]) -> Dict[str, Any]:
+ """resolve the task's volume and pin the pod to its datacenter."""
+ from .volume import VolumeError, VolumeResolver, volume_list
+
+ volumes = volume_list(self.spec.volume)
+ if len(volumes) > 1:
+ raise VolumeError(
+ f"task '{self.spec.name}': pods mount exactly one volume "
+ f"({len(volumes)} given)"
+ )
+ resolver = VolumeResolver(self.api)
+ resolved = await resolver.resolve(volumes[0], [self.spec])
+ from .volume import POD_MOUNT_PATH
+
+ pod["networkVolumeId"] = resolved["id"]
+ # the host bind-mounts to this target; empty means a broken
+ # mount config and the container never starts
+ pod["volumeMountPath"] = str(POD_MOUNT_PATH)
+ pod["dataCenterIds"] = [resolved["dataCenterId"]]
+ return pod
+
+ async def wait_ready(self, timeout: float = READY_TIMEOUT) -> None:
+ """poll the runner's /ping through the pod proxy until it answers."""
+ url = f"{_proxy_url(self.pod_id)}/ping"
+ deadline = time.monotonic() + timeout
+ async with aiohttp.ClientSession() as session:
+ while time.monotonic() < deadline:
+ try:
+ async with session.get(
+ url, timeout=aiohttp.ClientTimeout(total=5)
+ ) as resp:
+ if resp.status == 200:
+ return
+ except (aiohttp.ClientError, asyncio.TimeoutError):
+ # pod not reachable yet; the surrounding deadline
+ # decides when to stop trying
+ pass
+ await asyncio.sleep(READY_POLL_INTERVAL)
+ raise TimeoutError(
+ f"task pod {self.pod_id} did not become ready within {timeout}s"
+ )
+
+ async def execute(self, request: Dict[str, Any], timeout: float) -> Dict[str, Any]:
+ """run to completion: submit, then poll for the result.
+
+ the pod proxy caps how long a single request can stay open
+ (long jobs would 524), so completion always goes through the
+ background slot + short /result polls.
+ """
+ await self.submit(request)
+ deadline = time.monotonic() + timeout
+ while True:
+ if time.monotonic() >= deadline:
+ raise TimeoutError(
+ f"task on pod {self.pod_id} did not finish in {timeout}s"
+ )
+ response = await self.poll_result()
+ if response is not None:
+ return response
+ await asyncio.sleep(RESULT_POLL_INTERVAL)
+
+ async def submit(self, request: Dict[str, Any]) -> None:
+ """start in the background via /submit.
+
+ the proxy answers 404 for pods an edge node has not learned
+ about yet; since this pod demonstrably exists (we created it
+ and /ping succeeded), 404s here are propagation races and are
+ retried briefly rather than surfaced.
+ """
+ url = f"{_proxy_url(self.pod_id)}/submit"
+ attempts = 6
+ async with aiohttp.ClientSession() as session:
+ for attempt in range(attempts):
+ async with session.post(
+ url,
+ json=request,
+ headers=self._headers,
+ timeout=aiohttp.ClientTimeout(total=30),
+ ) as resp:
+ if resp.status == 404 and attempt < attempts - 1:
+ await asyncio.sleep(2 * (attempt + 1))
+ continue
+ resp.raise_for_status()
+ return
+
+ async def poll_result(self) -> Optional[Dict[str, Any]]:
+ """fetch /result; returns the response dict once DONE, else None.
+
+ the proxy answers 404 for pods an edge node has not learned
+ about yet (the same race /submit tolerates), so 404s are
+ retried briefly; a persistent 404 means the pod is gone and
+ surfaces as an error.
+ """
+ url = f"{_proxy_url(self.pod_id)}/result"
+ attempts = 6
+ data: Dict[str, Any] = {}
+ async with aiohttp.ClientSession() as session:
+ for attempt in range(attempts):
+ async with session.get(
+ url,
+ headers=self._headers,
+ timeout=aiohttp.ClientTimeout(total=30),
+ ) as resp:
+ if resp.status == 404 and attempt < attempts - 1:
+ await asyncio.sleep(2 * (attempt + 1))
+ continue
+ resp.raise_for_status()
+ data = await resp.json()
+ break
+ if data.get("status") == "DONE":
+ return data.get("response")
+ return None
+
+ async def terminate(self) -> None:
+ if self.pod_id is None:
+ return
+ try:
+ await self.api.terminate_pod(self.pod_id)
+ log.info("task pod %s terminated", self.pod_id)
+ except Exception as exc: # noqa: BLE001 - terminateAfter is the backstop
+ log.warning(
+ "failed to terminate task pod %s (terminateAfter is the "
+ "backstop): %s",
+ self.pod_id,
+ exc,
+ )
+ self.pod_id = None
+
+
+def unwrap_task_response(response: Dict[str, Any]) -> Any:
+ """extract the function result from a runner response."""
+ if not response.get("success"):
+ raise RemoteExecutionError(
+ f"task execution failed: {response.get('error', 'unknown')}"
+ )
+ if response.get("result") is not None:
+ from .serialization import deserialize_result
+
+ return deserialize_result(response["result"])
+ return response.get("json_result")
+
+
+class TaskJob:
+ """handle for a spawned task; owns the pod until the result is read."""
+
+ def __init__(self, execution: TaskExecution):
+ self._execution = execution
+ self._result: Any = None
+ self._done = False
+
+ @property
+ def pod_id(self) -> Optional[str]:
+ return self._execution.pod_id
+
+ async def wait(self, timeout: Optional[float] = None) -> Any:
+ """block until the task finishes; terminates the pod and returns
+ the result."""
+ deadline = time.monotonic() + timeout if timeout is not None else None
+ try:
+ while not self._done:
+ if deadline is not None and time.monotonic() >= deadline:
+ raise TimeoutError(
+ f"task on pod {self.pod_id} did not finish in {timeout}s"
+ )
+ response = await self._execution.poll_result()
+ if response is not None:
+ self._result = unwrap_task_response(response)
+ self._done = True
+ break
+ await asyncio.sleep(RESULT_POLL_INTERVAL)
+ finally:
+ if self._done:
+ await self._execution.terminate()
+ return self._result
+
+ async def cancel(self) -> None:
+ """terminate the pod, abandoning the task."""
+ await self._execution.terminate()
+ self._done = True
diff --git a/runpod/apps/utils/__init__.py b/runpod/apps/utils/__init__.py
new file mode 100644
index 00000000..5d1311e9
--- /dev/null
+++ b/runpod/apps/utils/__init__.py
@@ -0,0 +1 @@
+"""shared helpers for the apps surface."""
diff --git a/runpod/apps/utils/client.py b/runpod/apps/utils/client.py
new file mode 100644
index 00000000..44cca305
--- /dev/null
+++ b/runpod/apps/utils/client.py
@@ -0,0 +1,16 @@
+"""lazy control-plane client construction."""
+
+from typing import Optional
+
+
+def default_client(api: Optional[object] = None):
+ """return api when given, else a fresh AppsApiClient.
+
+ the import is deferred so modules that only sometimes touch the
+ control plane do not pull it in at import time.
+ """
+ if api is not None:
+ return api
+ from ..api import AppsApiClient
+
+ return AppsApiClient()
diff --git a/runpod/apps/utils/events.py b/runpod/apps/utils/events.py
new file mode 100644
index 00000000..90cef362
--- /dev/null
+++ b/runpod/apps/utils/events.py
@@ -0,0 +1,20 @@
+"""duck-typed event sink dispatch."""
+
+import logging
+from typing import Any, Optional
+
+log = logging.getLogger(__name__)
+
+
+def emit(sink: Optional[object], event: str, *args: Any) -> None:
+ """invoke the named handler on the sink if it exists.
+
+ handler failures are swallowed and logged: a broken event
+ renderer must never break the operation it observes.
+ """
+ handler = getattr(sink, event, None)
+ if handler is not None:
+ try:
+ handler(*args)
+ except Exception: # noqa: BLE001 - rendering must never break calls
+ log.debug("event sink %s failed", event, exc_info=True)
diff --git a/runpod/apps/utils/lookup.py b/runpod/apps/utils/lookup.py
new file mode 100644
index 00000000..7200dc94
--- /dev/null
+++ b/runpod/apps/utils/lookup.py
@@ -0,0 +1,26 @@
+"""shared name-or-id resolution for named platform resources."""
+
+from typing import Any, Callable, Dict, List, Optional
+
+
+def find_by_id_or_name(
+ items: List[Dict[str, Any]],
+ ref: str,
+ *,
+ noun: str,
+ error: Callable[[str], Exception],
+) -> Optional[Dict[str, Any]]:
+ """find the item whose id equals ref, else the unique name match.
+
+ returns None when nothing matches. raises error(message) when a
+ name matches more than one item, since the id is then needed to
+ disambiguate.
+ """
+ for item in items:
+ if item["id"] == ref:
+ return item
+ matches = [item for item in items if item.get("name") == ref]
+ if len(matches) > 1:
+ ids = ", ".join(match["id"] for match in matches)
+ raise error(f"multiple {noun} named '{ref}' ({ids}); reference by id")
+ return matches[0] if matches else None
diff --git a/runpod/apps/volume.py b/runpod/apps/volume.py
new file mode 100644
index 00000000..610b19f8
--- /dev/null
+++ b/runpod/apps/volume.py
@@ -0,0 +1,205 @@
+"""network volumes: durable storage shared across resources.
+
+a Volume is a lazy reference by name, resolved (and created when
+missing) at provision time. workers see the volume at /runpod-volume.
+
+ models = runpod.Volume("models") # create if missing, 50GB
+ models = runpod.Volume("models", size=100)
+
+ @app.task(gpu="4090", volume=models)
+ def train():
+ torch.save(sd, models.path / "model.pt")
+
+placement: a volume lives in exactly one datacenter, so everything
+attached to it must schedule there. resolution runs the placement
+solve over every resource sharing the volume (see runpod.apps.placement).
+tasks/pods take one volume; endpoints may take several (one per
+datacenter, locations derived from the volumes).
+"""
+
+import logging
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from .errors import AppError
+from .utils.client import default_client
+from .utils.events import emit
+from .utils.lookup import find_by_id_or_name
+
+log = logging.getLogger(__name__)
+
+DEFAULT_SIZE_GB = 50
+# platform mount conventions: pods bind network volumes at /workspace,
+# serverless endpoint workers at /runpod-volume
+POD_MOUNT_PATH = Path("/workspace")
+ENDPOINT_MOUNT_PATH = Path("/runpod-volume")
+
+
+class VolumeError(AppError):
+ pass
+
+
+class Volume:
+ """a named network volume reference, created on first use."""
+
+ def __init__(
+ self,
+ name: str,
+ *,
+ size: int = DEFAULT_SIZE_GB,
+ datacenter: Optional[str] = None,
+ create: bool = True,
+ ):
+ if not name or not isinstance(name, str):
+ raise VolumeError("volume name must be a non-empty string")
+ self.name = name
+ self.size = size
+ self.datacenter = datacenter
+ self.create = create
+
+ @property
+ def path(self) -> Path:
+ """where the volume appears in the current worker.
+
+ context-sensitive because the platform mounts differ: task
+ pods use /workspace, endpoint workers /runpod-volume. resolved
+ at access time inside the worker, so the same function body
+ works from either.
+ """
+ import os
+
+ if os.environ.get("RUNPOD_ENDPOINT_ID"):
+ return ENDPOINT_MOUNT_PATH
+ return POD_MOUNT_PATH
+
+ def __repr__(self) -> str:
+ return f""
+
+
+def _as_volume(ref: Any) -> Volume:
+ if isinstance(ref, Volume):
+ return ref
+ if isinstance(ref, str):
+ return Volume(ref)
+ raise VolumeError(
+ f"volume must be a runpod.Volume or name/id string, "
+ f"got {type(ref).__name__}"
+ )
+
+
+def volume_list(spec_volume: Any) -> List[Volume]:
+ """normalize a spec's volume field to a list of Volume refs."""
+ if spec_volume is None:
+ return []
+ if isinstance(spec_volume, (list, tuple)):
+ return [_as_volume(v) for v in spec_volume]
+ return [_as_volume(spec_volume)]
+
+
+class VolumeResolver:
+ """resolves every volume in an app once per provision run.
+
+ resolution: find by id or name; when missing, run the placement
+ solve over all resources sharing the volume and create it in the
+ chosen datacenter. results cache by name so each volume resolves
+ exactly once regardless of how many resources reference it.
+ """
+
+ def __init__(self, api=None, events: Optional[object] = None):
+ self._api = api
+ self.events = events
+ self._resolved: Dict[str, Dict[str, Any]] = {}
+ self._stock = None
+
+ async def _client(self):
+ self._api = default_client(self._api)
+ return self._api
+
+ async def resolve(
+ self, volume: Volume, specs: List
+ ) -> Dict[str, Any]:
+ """resolve one volume to {'id', 'dataCenterId'}.
+
+ specs are all resource specs that attach this volume; they
+ drive placement for creation and validate an existing DC.
+ """
+ cached = self._resolved.get(volume.name)
+ if cached is not None:
+ return cached
+
+ from .placement import PlacementError, StockMap, solve_placement
+
+ client = await self._client()
+ existing = await client.list_network_volumes()
+
+ record = find_by_id_or_name(
+ existing, volume.name, noun="volumes", error=VolumeError
+ )
+
+ if self._stock is None:
+ self._stock = StockMap(client)
+ from .placement import _hardware_keys
+
+ keys = [k for spec in specs for k in _hardware_keys(spec)]
+ await self._stock.fetch(keys)
+
+ if record is not None:
+ # existing volume: its DC is a hard constraint
+ dc = solve_placement(
+ specs,
+ self._stock,
+ volume_name=volume.name,
+ existing_dc=record["dataCenterId"],
+ )
+ resolved = {"id": record["id"], "dataCenterId": dc}
+ self._resolved[volume.name] = resolved
+ return resolved
+
+ if not volume.create:
+ raise VolumeError(
+ f"volume '{volume.name}' not found and create=False"
+ )
+
+ if volume.datacenter:
+ dc = solve_placement(
+ specs,
+ self._stock,
+ volume_name=volume.name,
+ existing_dc=volume.datacenter,
+ )
+ else:
+ dc = solve_placement(
+ specs, self._stock, volume_name=volume.name
+ )
+
+ created = await client.create_network_volume(
+ name=volume.name, size=volume.size, data_center_id=dc
+ )
+ emit(
+ self.events,
+ "volume_created",
+ volume.name,
+ volume.size,
+ dc,
+ )
+ log.info(
+ "created volume %s (%s, %dGB, %s)",
+ volume.name,
+ created["id"],
+ volume.size,
+ dc,
+ )
+ resolved = {"id": created["id"], "dataCenterId": dc}
+ self._resolved[volume.name] = resolved
+ return resolved
+
+
+def specs_sharing_volume(apps: List, name: str) -> List:
+ """every resource spec (across apps) attaching the named volume."""
+ out = []
+ for app in apps:
+ for handle in app.resources.values():
+ for ref in volume_list(handle.spec.volume):
+ if ref.name == name:
+ out.append(handle.spec)
+ return out
diff --git a/runpod/apps/watch.py b/runpod/apps/watch.py
new file mode 100644
index 00000000..5f931bda
--- /dev/null
+++ b/runpod/apps/watch.py
@@ -0,0 +1,60 @@
+"""filesystem change detection for dev sessions.
+
+a simple mtime poller: no extra dependency, no platform-specific event
+apis, good enough for the dev loop's granularity.
+"""
+
+import time
+from pathlib import Path
+from typing import Dict, Iterable, Optional
+
+from .discovery import _SKIP_DIRS
+
+
+def _snapshot(roots: Iterable[Path]) -> Dict[str, float]:
+ state: Dict[str, float] = {}
+ for root in roots:
+ if root.is_file():
+ try:
+ state[str(root)] = root.stat().st_mtime
+ except OSError:
+ # file deleted mid-scan; picked up on the next pass
+ pass
+ continue
+ for path in root.rglob("*.py"):
+ if any(part in _SKIP_DIRS for part in path.parts):
+ continue
+ try:
+ state[str(path)] = path.stat().st_mtime
+ except OSError:
+ continue
+ return state
+
+
+class FileWatcher:
+ """tracks python file changes under a set of roots."""
+
+ def __init__(self, roots: Iterable[Path]):
+ self.roots = [Path(r) for r in roots]
+ self._state = _snapshot(self.roots)
+
+ def changed(self) -> bool:
+ """true if any python file was added, removed, or modified since
+ the last call that returned true (or since construction)."""
+ current = _snapshot(self.roots)
+ if current != self._state:
+ self._state = current
+ return True
+ return False
+
+ def wait_for_change(
+ self, poll_interval: float = 0.5, timeout: Optional[float] = None
+ ) -> bool:
+ """block until a change is detected. returns false on timeout."""
+ deadline = time.monotonic() + timeout if timeout is not None else None
+ while True:
+ if self.changed():
+ return True
+ if deadline is not None and time.monotonic() >= deadline:
+ return False
+ time.sleep(poll_interval)
diff --git a/runpod/cli/__init__.py b/runpod/cli/__init__.py
index 08c52a52..a7613489 100644
--- a/runpod/cli/__init__.py
+++ b/runpod/cli/__init__.py
@@ -2,8 +2,6 @@
import threading
-from .groups import config as config, ssh as ssh
-
STOP_EVENT = threading.Event()
diff --git a/runpod/cli/entry.py b/runpod/cli/entry.py
index 6fefa2dd..f2ddb9c2 100644
--- a/runpod/cli/entry.py
+++ b/runpod/cli/entry.py
@@ -6,10 +6,7 @@
import click
-from .groups.config.commands import config_wizard
-from .groups.exec.commands import exec_cli
from .groups.pod.commands import pod_cli
-from .groups.project.commands import project_cli
from .groups.ssh.commands import ssh_cli
@@ -18,9 +15,5 @@ def runpod_cli():
"""A collection of CLI functions for Runpod."""
-runpod_cli.add_command(config_wizard) # runpod config
-
runpod_cli.add_command(ssh_cli) # runpod ssh
runpod_cli.add_command(pod_cli) # runpod pod
-runpod_cli.add_command(exec_cli) # runpod exec
-runpod_cli.add_command(project_cli) # runpod project
diff --git a/runpod/cli/groups/config/commands.py b/runpod/cli/groups/config/commands.py
deleted file mode 100644
index cae08c1b..00000000
--- a/runpod/cli/groups/config/commands.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""
-Commands for the config command group
-"""
-
-import sys
-
-import click
-
-from .functions import check_credentials, set_credentials
-
-
-@click.command("config", help="Configures the Runpod CLI with the user's API key.")
-@click.argument("api-key", required=False, default=None)
-@click.option(
- "--profile", default="default", help="The profile to set the credentials for."
-)
-@click.option("--check", is_flag=True, help="Check if credentials are already set.")
-def config_wizard(api_key, profile, check):
- """Starts the configuration wizard to set up the Runpod CLI.
- If credentials are already set, prompts the user to overwrite them.
- """
- valid, error = check_credentials(profile)
-
- if check and valid:
- click.echo("Credentials already set for profile: " + profile)
- sys.exit(0)
- elif check and not valid:
- click.echo(error)
- sys.exit(1)
-
- if valid:
- click.confirm(
- f"Credentials already set for profile: {profile}. Overwrite?", abort=True
- )
-
- if api_key is None:
- click.echo("Please enter your Runpod API Key.")
- click.echo("You can find it at https://console.runpod.io/user/settings")
- api_key = click.prompt(
- " > Runpod API Key", hide_input=False, confirmation_prompt=False
- )
-
- set_credentials(api_key, profile, overwrite=True)
-
- click.echo(f"Credentials set for profile: {profile} in ~/.runpod/config.toml")
diff --git a/runpod/cli/groups/exec/__init__.py b/runpod/cli/groups/exec/__init__.py
deleted file mode 100644
index b3a934dc..00000000
--- a/runpod/cli/groups/exec/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-""" A collection of CLI execution tools """
diff --git a/runpod/cli/groups/exec/commands.py b/runpod/cli/groups/exec/commands.py
deleted file mode 100644
index 1d372269..00000000
--- a/runpod/cli/groups/exec/commands.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""
-Runpod | CLI | Exec | Commands
-"""
-
-import click
-
-from .functions import python_over_ssh
-from .helpers import get_session_pod
-
-
-@click.group("exec", help="Execute commands in a pods.")
-def exec_cli():
- """A collection of CLI functions for Exec."""
-
-
-@exec_cli.command("python")
-@click.option("--pod_id", default=None, help="The pod ID to run the command on.")
-@click.argument("file", type=click.Path(exists=True), required=True)
-def remote_python(pod_id, file):
- """
- Runs a remote Python shell.
- """
- if pod_id is None:
- pod_id = get_session_pod()
-
- click.echo("Running remote Python shell...")
- python_over_ssh(pod_id, file)
diff --git a/runpod/cli/groups/exec/functions.py b/runpod/cli/groups/exec/functions.py
deleted file mode 100644
index f5bc0a8f..00000000
--- a/runpod/cli/groups/exec/functions.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""
-Runpod | CLI | Exec | Functions
-"""
-
-from runpod.cli.utils import ssh_cmd
-
-
-def python_over_ssh(pod_id, file):
- """
- Runs a Python file over SSH.
- """
- ssh = ssh_cmd.SSHConnection(pod_id)
- ssh.put_file(file, f"/root/{file}")
- ssh.run_commands([f"python3.10 /root/{file}"])
- ssh.close()
diff --git a/runpod/cli/groups/exec/helpers.py b/runpod/cli/groups/exec/helpers.py
deleted file mode 100644
index 687c2b97..00000000
--- a/runpod/cli/groups/exec/helpers.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Helper functions for the runpod cli group exec command"""
-
-import os
-
-import click
-
-from runpod import get_pod
-
-POD_ID_FILE = os.path.join(os.path.expanduser("~"), ".runpod", "pod_id")
-
-
-def get_session_pod():
- """Returns the pod_id for the current session.
-
- Session pod is used to execute commands and run scripts remotely.
-
- - If the pod_id is already set, return it.
- - If the pod_id is not set, prompt the user for it.
- - Save the pod_id to a file so that the user doesn't have to enter it again.
- """
- pod_id = None
-
- if os.path.exists(POD_ID_FILE):
- with open(POD_ID_FILE, "r", encoding="UTF-8") as pod_file:
- pod_id = pod_file.read().strip()
-
- # Confirm that the pod_id is valid
- if get_pod(pod_id) is not None:
- return pod_id
-
- # If file doesn't exist or is empty, prompt user for the pod_id
- pod_id = click.prompt("Please provide the pod ID")
- os.makedirs(os.path.dirname(POD_ID_FILE), exist_ok=True)
- with open(POD_ID_FILE, "w", encoding="UTF-8") as pod_file:
- pod_file.write(pod_id)
-
- return pod_id
diff --git a/runpod/cli/groups/pod/commands.py b/runpod/cli/groups/pod/commands.py
index e6f37c81..805744ef 100644
--- a/runpod/cli/groups/pod/commands.py
+++ b/runpod/cli/groups/pod/commands.py
@@ -69,10 +69,15 @@ def create_new_pod(
@click.argument("pod_id")
def connect_to_pod(pod_id):
"""
- Connects to a pod.
+ Open an interactive terminal on a pod over SSH.
+
+ Requires an SSH key on your account (rp ssh add).
"""
click.echo(f"Connecting to pod {pod_id}...")
- ssh = ssh_cmd.SSHConnection(pod_id)
+ try:
+ ssh = ssh_cmd.SSHConnection(pod_id)
+ except (ValueError, TimeoutError) as exc:
+ raise click.ClickException(str(exc)) from exc
ssh.launch_terminal()
@@ -92,8 +97,8 @@ def sync_pods(source_pod_id, dest_pod_id, source_workspace, dest_workspace):
1. SSH Key Setup:
• You must have an SSH key configured in your Runpod account
- • If you don't have one, create it with: runpod ssh add-key
- • List your keys with: runpod ssh list-keys
+ • If you don't have one, create it with: rp ssh add
+ • List your keys with: rp ssh list
2. Pod Configuration:
• Both pods must have SSH access enabled
@@ -130,10 +135,10 @@ def sync_pods(source_pod_id, dest_pod_id, source_workspace, dest_workspace):
click.echo("❌ No SSH keys found in your Runpod account!")
click.echo("")
click.echo("🔑 To create an SSH key, run:")
- click.echo(" runpod ssh add-key")
+ click.echo(" rp ssh add")
click.echo("")
click.echo("📖 For more help, see:")
- click.echo(" runpod ssh add-key --help")
+ click.echo(" rp ssh add --help")
return
else:
click.echo(f"✅ Found {len(user_keys)} SSH key(s) in your account")
@@ -234,7 +239,7 @@ def sync_pods(source_pod_id, dest_pod_id, source_workspace, dest_workspace):
click.echo("")
click.echo("🔧 Troubleshooting tips:")
click.echo("• Ensure both pods have SSH access enabled")
- click.echo("• Check that your SSH key is added to your Runpod account: runpod ssh list-keys")
+ click.echo("• Check that your SSH key is added to your Runpod account: rp ssh list")
click.echo("• For running pods, you may need to add PUBLIC_KEY env var and restart")
click.echo("• Verify the source and destination paths exist")
finally:
diff --git a/runpod/cli/groups/project/__init__.py b/runpod/cli/groups/project/__init__.py
deleted file mode 100644
index 0f41264e..00000000
--- a/runpod/cli/groups/project/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-""" Allows project import """
diff --git a/runpod/cli/groups/project/commands.py b/runpod/cli/groups/project/commands.py
deleted file mode 100644
index b5df4a3f..00000000
--- a/runpod/cli/groups/project/commands.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""
-Runpod | CLI | Project | Commands
-"""
-
-import os
-import sys
-
-import click
-from InquirerPy import prompt as cli_select
-
-from runpod import get_user
-
-from .functions import create_new_project, create_project_endpoint, start_project
-from .helpers import validate_project_name
-
-
-@click.group("project", help="Create, deploy, and manage Runpod projects.")
-def project_cli():
- """Create and deploy projects on Runpod."""
-
-
-# -------------------------------- New Project ------------------------------- #
-@project_cli.command("new")
-@click.option(
- "--name", "-n", "project_name", type=str, default=None, help="The project name."
-)
-@click.option(
- "--type",
- "-t",
- "model_type",
- type=click.Choice(["llama2"], case_sensitive=False),
- default=None,
- help="The type of Hugging Face model.",
-)
-@click.option(
- "--model",
- "-m",
- "model_name",
- type=str,
- default=None,
- help="The name of the Hugging Face model. (e.g. meta-llama/Llama-2-7b)",
-)
-@click.option("--init", "-i", "init_current_dir", is_flag=True, default=False)
-def new_project_wizard(project_name, model_type, model_name, init_current_dir):
- """Create a new project."""
- network_volumes = get_user()["networkVolumes"]
- if len(network_volumes) == 0:
- click.echo("You do not have any network volumes.")
- click.echo(
- "Please create a network volume (https://console.runpod.io/user/storage) and try again."
- ) # pylint: disable=line-too-long
- sys.exit(1)
-
- click.echo("Creating a new project...")
-
- if init_current_dir:
- project_name = os.path.basename(os.getcwd())
-
- if project_name is None:
- project_name = click.prompt(" > Enter the project name", type=str)
-
- validate_project_name(project_name)
-
- def print_net_vol(vol):
- return {
- "name": f"{vol['id']}: {vol['name']} ({vol['size']} GB, {vol['dataCenterId']})",
- "value": vol["id"],
- }
-
- network_volumes = list(map(print_net_vol, network_volumes))
- questions = [
- {
- "type": "rawlist",
- "name": "volume-id",
- "qmark": "",
- "amark": "",
- "message": " > Select a Network Volume:",
- "choices": network_volumes,
- }
- ]
- runpod_volume_id = cli_select(questions)["volume-id"]
-
- cuda_version = click.prompt(
- " > Select a CUDA version, or press enter to use the default",
- type=click.Choice(["11.1.1", "11.8.0", "12.1.0"], case_sensitive=False),
- default="11.8.0",
- )
-
- python_version = click.prompt(
- " > Select a Python version, or press enter to use the default",
- type=click.Choice(["3.8", "3.9", "3.10", "3.11"], case_sensitive=False),
- default="3.10",
- )
-
- click.echo("")
- click.echo("Project Summary:")
-
- click.echo(f" - Project Name: {project_name}")
- click.echo(f" - Runpod Network Storage ID: {runpod_volume_id}")
- click.echo(f" - CUDA Version: {cuda_version}")
- click.echo(f" - Python Version: {python_version}")
-
- click.echo("")
- click.echo("The project will be created in the current directory.")
- click.confirm("Do you want to continue?", abort=True)
-
- create_new_project(
- project_name,
- runpod_volume_id,
- cuda_version,
- python_version,
- model_type,
- model_name,
- init_current_dir,
- ) # pylint: disable=too-many-arguments
-
- click.echo(f"Project {project_name} created successfully!")
- click.echo("")
- click.echo(
- "From your project root run `runpod project start` to start a development pod."
- )
-
-
-# ------------------------------- Start Project ------------------------------ #
-@project_cli.command("start")
-def start_project_pod():
- """
- Start the project development pod from runpod.toml
- """
- click.echo(
- "Starting the project will create a development pod on Runpod, if one does not already exist."
- ) # pylint: disable=line-too-long
- click.echo(
- " - You will be charged based on the GPU type specified in runpod.toml."
- )
- click.echo(" - When you are finished close the connection with CTRL+C.")
- click.echo("")
- click.confirm("Do you want to continue?", abort=True)
-
- click.echo("Starting project development pod...")
-
- start_project()
-
-
-# ------------------------------ Deploy Project ------------------------------ #
-@project_cli.command("deploy")
-def deploy_project():
- """Deploy the project to Runpod."""
- click.echo("Deploying project...")
-
- endpoint_id = create_project_endpoint()
-
- click.echo(f"Project deployed successfully! Endpoint ID: {endpoint_id}")
- click.echo("The following urls are available:")
- click.echo(f" - https://api.runpod.ai/v2/{endpoint_id}/runsync")
- click.echo(f" - https://api.runpod.ai/v2/{endpoint_id}/run")
- click.echo(f" - https://api.runpod.ai/v2/{endpoint_id}/health")
diff --git a/runpod/cli/groups/project/functions.py b/runpod/cli/groups/project/functions.py
deleted file mode 100644
index 4fe01157..00000000
--- a/runpod/cli/groups/project/functions.py
+++ /dev/null
@@ -1,429 +0,0 @@
-"""
-Runpod | CLI | Project | Functions
-"""
-
-import os
-import sys
-import uuid
-from datetime import datetime
-
-import tomlkit
-from tomlkit import comment, document, nl, table
-
-from runpod import (
- __version__,
- create_endpoint,
- create_template,
- get_pod,
- update_endpoint_template,
-)
-from runpod.cli import BASE_DOCKER_IMAGE, ENV_VARS, GPU_TYPES
-from runpod.cli.utils.ssh_cmd import SSHConnection
-
-from ...utils.rp_sync import sync_directory
-from .helpers import (
- attempt_pod_launch,
- copy_template_files,
- get_project_endpoint,
- get_project_pod,
- load_project_config,
-)
-
-STARTER_TEMPLATES = os.path.join(os.path.dirname(__file__), "starter_templates")
-
-
-def _launch_dev_pod():
- """Launch a development pod."""
- config = load_project_config() # Load runpod.toml
-
- print("Deploying development pod on Runpod...")
-
- # Prepare the environment variables
- environment_variables = {"RUNPOD_PROJECT_ID": config["project"]["uuid"]}
- for variable in config["project"].get("env_vars", {}):
- environment_variables[variable] = config["project"]["env_vars"][variable]
-
- # Prepare the GPU types
- selected_gpu_types = config["project"].get("gpu_types", [])
- if config["project"].get("gpu", None):
- selected_gpu_types.append(config["project"]["gpu"])
-
- # Attempt to launch a pod with the given configuration
- new_pod = attempt_pod_launch(config, environment_variables)
- if new_pod is None:
- print(
- "Selected GPU types unavailable, try again later or use a different type."
- )
- return None
-
- print("Waiting for pod to come online... ", end="")
- sys.stdout.flush()
-
- # Wait for the pod to come online
- while (
- new_pod.get("desiredStatus", None) != "RUNNING"
- or new_pod.get("runtime") is None
- ):
- new_pod = get_pod(new_pod["id"])
-
- project_pod_id = new_pod["id"]
-
- print(
- f"Project {config['project']['name']} pod ({project_pod_id}) created.",
- end="\n\n",
- )
- return project_pod_id
-
-
-# -------------------------------- New Project ------------------------------- #
-def create_new_project(
- project_name,
- runpod_volume_id,
- cuda_version,
- python_version, # pylint: disable=too-many-locals, too-many-arguments, too-many-statements
- model_type=None,
- model_name=None,
- init_current_dir=False,
-):
- """Create a new project."""
- if not init_current_dir:
- project_folder = os.path.join(os.getcwd(), project_name)
- if not os.path.exists(project_folder):
- os.makedirs(project_folder)
-
- if model_type is None:
- model_type = "default"
-
- template_dir = os.path.join(STARTER_TEMPLATES, model_type)
-
- copy_template_files(template_dir, project_folder)
-
- # Replace placeholders in requirements.txt
- requirements_path = os.path.join(project_folder, "builder/requirements.txt")
- with open(requirements_path, "r", encoding="utf-8") as requirements_file:
- requirements_content = requirements_file.read()
-
- if "dev" in __version__:
- requirements_content = requirements_content.replace(
- "<>", "git+https://github.com/runpod/runpod-python.git"
- )
- else:
- requirements_content = requirements_content.replace(
- "<>", f"runpod=={__version__}"
- )
-
- with open(requirements_path, "w", encoding="utf-8") as requirements_file:
- requirements_file.write(requirements_content)
-
- # If there's a model_name, replace placeholders in handler.py
- if model_name:
- handler_path = os.path.join(project_name, "src/handler.py")
- with open(handler_path, "r", encoding="utf-8") as file:
- handler_content = file.read()
- handler_content = handler_content.replace("<>", model_name)
-
- with open(handler_path, "w", encoding="utf-8") as file:
- file.write(handler_content)
- else:
- project_folder = os.getcwd()
-
- project_uuid = str(uuid.uuid4())[:8]
-
- toml_config = document()
- toml_config.add(comment("Runpod Project Configuration"))
- toml_config.add(nl())
- toml_config.add("title", project_name)
-
- project_table = table()
- project_table.add("uuid", project_uuid)
- project_table.add("name", project_name)
- project_table.add("base_image", BASE_DOCKER_IMAGE.format(cuda_version=cuda_version))
- project_table.add("gpu_types", GPU_TYPES)
- project_table.add("gpu_count", 1)
- project_table.add("storage_id", runpod_volume_id)
- project_table.add("volume_mount_path", "/runpod-volume")
- project_table.add("ports", "8080/http, 22/tcp")
- project_table.add("container_disk_size_gb", 10)
- project_table.add("env_vars", ENV_VARS)
- toml_config.add("project", project_table)
-
- template_table = table()
- template_table.add("model_type", str(model_type))
- template_table.add("model_name", str(model_name))
- toml_config.add("template", template_table)
-
- runtime_table = table()
- runtime_table.add("python_version", python_version)
- runtime_table.add("handler_path", "src/handler.py")
- runtime_table.add("requirements_path", "builder/requirements.txt")
- toml_config.add("runtime", runtime_table)
-
- with open(
- os.path.join(project_folder, "runpod.toml"), "w", encoding="UTF-8"
- ) as config_file:
- tomlkit.dump(toml_config, config_file)
-
-
-# ------------------------------- Start Project ------------------------------ #
-def start_project(): # pylint: disable=too-many-locals, too-many-branches
- """
- Start the project development environment from runpod.toml
- - Check if the project pod already exists.
-
- - If the project pod does not exist:
- # SSH into the pod and create a project folder within the volume
- # Create a folder in the volume for the project that matches the project uuid
- # Create a folder in the project folder that matches the project name
- # Copy the project files into the project folder
- # crate a virtual environment using the python version specified in the project config
- # install the requirements.txt file
-
- - If the project pod does exist:
-
- """
- config = load_project_config() # Load runpod.toml
-
- for config_item in config["project"]:
- print(f' - {config_item}: {config["project"][config_item]}')
- print("")
-
- project_pod_id = get_project_pod(config["project"]["uuid"])
-
- # Check if the project pod already exists, if not create it.
- if not project_pod_id:
- project_pod_id = _launch_dev_pod()
-
- if project_pod_id is None:
- return
-
- with SSHConnection(project_pod_id) as ssh_conn:
-
- project_path_uuid = (
- f'{config["project"]["volume_mount_path"]}/{config["project"]["uuid"]}'
- )
- project_path_uuid_dev = os.path.join(project_path_uuid, "dev")
- project_path_uuid_prod = os.path.join(project_path_uuid, "prod")
- remote_project_path = os.path.join(
- project_path_uuid_dev, config["project"]["name"]
- )
-
- # Create the project folder on the pod
- print(
- f"Checking pod project folder: {remote_project_path} on pod {project_pod_id}"
- )
- ssh_conn.run_commands(
- [f"mkdir -p {remote_project_path} {project_path_uuid_prod}"]
- )
-
- # Copy local files to the pod project folder
- print(f"Syncing files to pod {project_pod_id}")
- ssh_conn.rsync(os.getcwd(), project_path_uuid_dev)
-
- # Create the virtual environment
- venv_path = os.path.join(project_path_uuid_dev, "venv")
- print(
- f"Activating Python virtual environment: {venv_path} on pod {project_pod_id}"
- )
- commands = [
- f'python{config["runtime"]["python_version"]} -m virtualenv {venv_path}',
- f"source {venv_path}/bin/activate &&"
- f"cd {remote_project_path} &&"
- "python -m pip install --upgrade pip &&"
- f'python -m pip install -v --requirement {config["runtime"]["requirements_path"]}',
- ]
- ssh_conn.run_commands(commands)
-
- # Start the watcher and then start the API development server
- sync_directory(ssh_conn, os.getcwd(), project_path_uuid_dev)
-
- project_name = config["project"]["name"]
- pip_req_path = os.path.join(
- remote_project_path, config["runtime"]["requirements_path"]
- )
- handler_path = os.path.join(
- remote_project_path, config["runtime"]["handler_path"]
- )
-
- launch_api_server = [
- f"""
- pkill inotify
-
- function force_kill {{
- kill $1 2>/dev/null
- sleep 1
-
- if ps -p $1 > /dev/null; then
- echo "Graceful kill failed, attempting SIGKILL..."
- kill -9 $1 2>/dev/null
- sleep 1
-
- if ps -p $1 > /dev/null; then
- echo "Failed to kill process with PID: $1"
- exit 1
- else
- echo "Killed process with PID: $1 using SIGKILL"
- fi
-
- else
- echo "Killed process with PID: $1"
- fi
- }}
-
- function cleanup {{
- echo "Cleaning up..."
- force_kill $last_pid
- }}
-
- trap cleanup EXIT SIGINT
-
- if source {project_path_uuid_dev}/venv/bin/activate; then
- echo -e "- Activated virtual environment."
- else
- echo "Failed to activate virtual environment."
- exit 1
- fi
-
- if cd {project_path_uuid_dev}/{project_name}; then
- echo -e "- Changed to project directory."
- else
- echo "Failed to change directory."
- exit 1
- fi
-
- exclude_pattern='(__pycache__|\\.pyc$)'
- function update_exclude_pattern {{
- exclude_pattern='(__pycache__|\\.pyc$)'
- if [[ -f .runpodignore ]]; then
- while IFS= read -r line; do
- line=$(echo "$line" | tr -d '[:space:]')
- [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue # Skip comments and empty lines
- exclude_pattern="${{exclude_pattern}}|(${{line}})"
- done < .runpodignore
- echo -e "- Ignoring files matching pattern: $exclude_pattern"
- fi
- }}
- update_exclude_pattern
-
- # Start the API server in the background, and save the PID
- python {handler_path} --rp_serve_api --rp_api_host="0.0.0.0" --rp_api_port=8080 --rp_api_concurrency=1 &
- last_pid=$!
-
- echo -e "- Started API server with PID: $last_pid" && echo ""
- echo "Connect to the API server at:"
- echo "> https://$RUNPOD_POD_ID-8080.proxy.runpod.net" && echo ""
-
- while true; do
- if changed_file=$(inotifywait -q -r -e modify,create,delete --exclude "$exclude_pattern" {remote_project_path} --format '%w%f'); then
- echo "Detected changes in: $changed_file"
- else
- echo "Failed to detect changes."
- exit 1
- fi
-
- force_kill $last_pid
-
- if [[ $changed_file == *"requirements"* ]]; then
- echo "Installing new requirements..."
- python -m pip install --upgrade pip && python -m pip install -r {pip_req_path}
- fi
-
- if [[ $changed_file == *".runpodignore"* ]]; then
- update_exclude_pattern
- fi
-
- python {handler_path} --rp_serve_api --rp_api_host="0.0.0.0" --rp_api_port=8080 --rp_api_concurrency=1 &
- last_pid=$!
-
- echo "Restarted API server with PID: $last_pid"
- done
- """
- ]
-
- print("")
- print("Starting project development endpoint...")
- ssh_conn.run_commands(launch_api_server)
-
-
-# ------------------------------ Deploy Project ------------------------------ #
-def create_project_endpoint():
- """Create a project endpoint.
- - Move code in dev to prod folder
- - TODO: git commit the diff from current state to new state
- - Create a serverless template for the project
- - Create a new endpoint using the template
- """
- config = load_project_config()
- project_pod_id = get_project_pod(config["project"]["uuid"])
-
- # Check if the project pod already exists, if not create it.
- if not project_pod_id:
- project_pod_id = _launch_dev_pod()
-
- if project_pod_id is None:
- return None
-
- with SSHConnection(project_pod_id) as ssh_conn:
- project_path_uuid = (
- f'{config["project"]["volume_mount_path"]}/{config["project"]["uuid"]}'
- )
- project_path_uuid_prod = os.path.join(project_path_uuid, "prod")
- remote_project_path = os.path.join(
- project_path_uuid_prod, config["project"]["name"]
- )
-
- # Copy local files to the pod project folder
- ssh_conn.run_commands([f"mkdir -p {remote_project_path}"])
- print(f"Syncing files to pod {project_pod_id} prod")
- ssh_conn.rsync(os.getcwd(), project_path_uuid_prod)
-
- # Create the virtual environment
- venv_path = os.path.join(project_path_uuid_prod, "venv")
- print(
- f"Activating Python virtual environment: {venv_path} on pod {project_pod_id}"
- )
- commands = [
- f'python{config["runtime"]["python_version"]} -m venv {venv_path}',
- f"source {venv_path}/bin/activate &&"
- f"cd {remote_project_path} &&"
- "python -m pip install --upgrade pip &&"
- f'python -m pip install -v --requirement {config["runtime"]["requirements_path"]}',
- ]
- ssh_conn.run_commands(commands)
- ssh_conn.close()
-
- environment_variables = {}
- for variable in config["project"]["env_vars"]:
- environment_variables[variable] = config["project"]["env_vars"][variable]
-
- # Construct the docker start command
- activate_cmd = (
- f'. /runpod-volume/{config["project"]["uuid"]}/prod/venv/bin/activate'
- )
- python_cmd = f'python -u /runpod-volume/{config["project"]["uuid"]}/prod/{config["project"]["name"]}/{config["runtime"]["handler_path"]}' # pylint: disable=line-too-long
- docker_start_cmd = 'bash -c "' + activate_cmd + " && " + python_cmd + '"'
-
- project_endpoint_template = create_template(
- name=f'{config["project"]["name"]}-endpoint | {config["project"]["uuid"]} | {datetime.now()}', # pylint: disable=line-too-long
- image_name=config["project"]["base_image"],
- container_disk_in_gb=config["project"]["container_disk_size_gb"],
- docker_start_cmd=docker_start_cmd,
- env=environment_variables,
- is_serverless=True,
- )
-
- deployed_endpoint = get_project_endpoint(config["project"]["uuid"])
- if not deployed_endpoint:
- deployed_endpoint = create_endpoint(
- name=f'{config["project"]["name"]}-endpoint | {config["project"]["uuid"]}',
- template_id=project_endpoint_template["id"],
- network_volume_id=config["project"]["storage_id"],
- )
- else:
- deployed_endpoint = update_endpoint_template(
- endpoint_id=deployed_endpoint["id"],
- template_id=project_endpoint_template["id"],
- )
-
- # does user want to tear down and recreate workers immediately?
-
- return deployed_endpoint["id"]
diff --git a/runpod/cli/groups/project/helpers.py b/runpod/cli/groups/project/helpers.py
deleted file mode 100644
index be7c7d98..00000000
--- a/runpod/cli/groups/project/helpers.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Helper functions for project group commands."""
-
-import os
-import re
-import shutil
-
-import click
-import tomlkit
-
-from runpod import create_pod
-from runpod import error as rp_error
-from runpod import get_endpoints, get_pods
-
-
-def validate_project_name(name):
- """
- Validate the project name.
- """
- match = re.search(r"[<>:\"/\\|?*\s]", name)
- if match:
- raise click.BadParameter(
- f"Project name contains an invalid character: '{match.group()}'."
- )
- return name
-
-
-def get_project_pod(project_id: str):
- """Check if a project pod exists.
- Return the pod_id if it exists, else return None.
- """
- for pod in get_pods():
- if project_id in pod["name"]:
- return pod["id"]
-
- return None
-
-
-def get_project_endpoint(project_id: str):
- """Check if a project endpoint exists.
- Return the endpoint if it exists, else return None.
- """
- for endpoint in get_endpoints():
- if project_id in endpoint["name"]:
- return endpoint
-
- return None
-
-
-def copy_template_files(template_dir, destination):
- """Copy the template files to the destination directory."""
- for item in os.listdir(template_dir):
- source_item = os.path.join(template_dir, item)
- destination_item = os.path.join(destination, item)
- if os.path.isdir(source_item):
- shutil.copytree(source_item, destination_item)
- else:
- shutil.copy2(source_item, destination_item)
-
-
-def attempt_pod_launch(config, environment_variables):
- """Attempt to launch a pod with the given configuration."""
- for gpu_type in config["project"].get("gpu_types", []):
- print(f"Trying to get a pod with {gpu_type}... ", end="")
- try:
- created_pod = create_pod(
- f'{config["project"]["name"]}-dev ({config["project"]["uuid"]})',
- config["project"]["base_image"],
- gpu_type,
- gpu_count=int(config["project"]["gpu_count"]),
- support_public_ip=True,
- ports=f'{config["project"]["ports"]}',
- network_volume_id=f'{config["project"]["storage_id"]}',
- volume_mount_path=f'{config["project"]["volume_mount_path"]}',
- container_disk_in_gb=int(config["project"]["container_disk_size_gb"]),
- env=environment_variables,
- )
- print("Success!")
- return created_pod
- except rp_error.QueryError:
- print("Unavailable.")
- return None
-
-
-def load_project_config():
- """Load the project config file."""
- project_config_file = os.path.join(os.getcwd(), "runpod.toml")
- if not os.path.exists(project_config_file):
- raise FileNotFoundError("runpod.toml not found in the current directory.")
- with open(project_config_file, "r", encoding="UTF-8") as config_file:
- return tomlkit.load(config_file)
diff --git a/runpod/cli/groups/project/starter_templates/default/.runpodignore b/runpod/cli/groups/project/starter_templates/default/.runpodignore
deleted file mode 100644
index fef4dcc0..00000000
--- a/runpod/cli/groups/project/starter_templates/default/.runpodignore
+++ /dev/null
@@ -1,2 +0,0 @@
-# Similar to .gitignore
-# Matches will not be synced to the development pod or cause the development pod to reload.
diff --git a/runpod/cli/groups/project/starter_templates/default/builder/requirements.txt b/runpod/cli/groups/project/starter_templates/default/builder/requirements.txt
deleted file mode 100644
index 8fa962d6..00000000
--- a/runpod/cli/groups/project/starter_templates/default/builder/requirements.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-# Required Python packages get listed here, one per line.
-# Reccomended to lock the version number to avoid unexpected changes.
-
-# You can also install packages from a git repository, e.g.:
-# git+https://github.com/runpod/runpod-python.git
-# To learn more, see https://pip.pypa.io/en/stable/reference/requirements-file-format/
-
-<>
diff --git a/runpod/cli/groups/project/starter_templates/default/src/handler.py b/runpod/cli/groups/project/starter_templates/default/src/handler.py
deleted file mode 100644
index bd26d7ec..00000000
--- a/runpod/cli/groups/project/starter_templates/default/src/handler.py
+++ /dev/null
@@ -1,15 +0,0 @@
-""" A template for a handler file. """
-
-import runpod
-
-
-def handler(job):
- """
- This is the handler function for the job.
- """
- job_input = job["input"]
- name = job_input.get("name", "World")
- return f"Hello, {name}!"
-
-
-runpod.serverless.start({"handler": handler})
diff --git a/runpod/cli/groups/project/starter_templates/llama2/builder/requirements.txt b/runpod/cli/groups/project/starter_templates/llama2/builder/requirements.txt
deleted file mode 100644
index 5a94046a..00000000
--- a/runpod/cli/groups/project/starter_templates/llama2/builder/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# List your python dependencies here. See https://pip.pypa.io/en/stable/user_guide/#requirements-files
-
-runpod==1.2.0
-transformers>=4.33.0
diff --git a/runpod/cli/groups/project/starter_templates/llama2/src/handler.py b/runpod/cli/groups/project/starter_templates/llama2/src/handler.py
deleted file mode 100644
index 00048abc..00000000
--- a/runpod/cli/groups/project/starter_templates/llama2/src/handler.py
+++ /dev/null
@@ -1,42 +0,0 @@
-""" A template for a Llama2 handler file. """
-
-# pylint: skip-file
-# ruff: noqa
-
-
-from transformers import HfApi
-
-import runpod
-
-SELECTED_MODEL = "<>"
-
-
-def get_model_framework(model_name):
- api = HfApi()
- model_files = api.model_info(model_name).files
-
- # Check the files associated with the model
- if "pytorch_model.bin" in model_files:
- return "PyTorch"
- elif "tf_model.h5" in model_files:
- return "TensorFlow"
- else:
- return "Unknown"
-
-
-def prepare_inputs(text, **kwargs):
- # Filter kwargs based on what the tokenizer accepts
- filtered_args = {k: v for k, v in kwargs.items() if k in valid_args}
-
- inputs = tokenizer(text, return_tensors="pt", **filtered_args)
- return inputs
-
-
-def handle_request(text, **input_args):
- inputs = prepare_inputs(text, **input_args)
- with torch.no_grad():
- outputs = model(**inputs)
- return process_outputs(outputs)
-
-
-runpod.serverless.start({"handler": handle_request})
diff --git a/runpod/cli/groups/ssh/commands.py b/runpod/cli/groups/ssh/commands.py
index bf0d19ab..1c6e4376 100644
--- a/runpod/cli/groups/ssh/commands.py
+++ b/runpod/cli/groups/ssh/commands.py
@@ -8,12 +8,46 @@
from .functions import generate_ssh_key_pair, get_user_pub_keys
-@click.group("ssh", help="Manage and configure SSH keys for secure access to pods.")
+class SSHGroup(click.Group):
+ """dispatches unknown first arguments to connect.
+
+ `rp ssh ` opens a terminal on the pod, exactly like plain
+ `ssh `; named subcommands (add, list, connect) still resolve
+ normally.
+ """
+
+ def resolve_command(self, ctx, args):
+ try:
+ return super().resolve_command(ctx, args)
+ except click.UsageError:
+ return "connect", self.commands["connect"], args
+
+
+@click.group(
+ "ssh",
+ cls=SSHGroup,
+ help="SSH into a pod (rp ssh POD_ID) and manage the keys pods trust.",
+ invoke_without_command=False,
+)
def ssh_cli():
- """Manage and configure SSH keys."""
+ """SSH into pods and manage account SSH keys."""
+
+
+@ssh_cli.command("connect", hidden=True)
+@click.argument("pod_id")
+def connect(pod_id):
+ """Open an interactive terminal on a pod."""
+ from runpod.cli.utils import ssh_cmd
+
+ click.echo(f"Connecting to pod {pod_id}...")
+ try:
+ ssh = ssh_cmd.SSHConnection(pod_id)
+ except (ValueError, TimeoutError) as exc:
+ raise click.ClickException(str(exc)) from exc
+ ssh.launch_terminal()
-@ssh_cli.command("list-keys")
+@ssh_cli.command("list")
def list_keys():
"""
Lists the SSH keys for the current user.
@@ -25,7 +59,7 @@ def list_keys():
click.echo(table)
-@ssh_cli.command("add-key")
+@ssh_cli.command("add")
@click.option("--key", default=None, help="The public key to add.")
@click.option(
"--key-file", default=None, help="The file containing the public key to add."
diff --git a/runpod/cli/utils/rp_info.py b/runpod/cli/utils/rp_info.py
index 545f7916..6464bfb6 100644
--- a/runpod/cli/utils/rp_info.py
+++ b/runpod/cli/utils/rp_info.py
@@ -18,6 +18,8 @@ def get_pod_ssh_ip_port(pod_id, timeout=300):
while time.time() - start_time < timeout and (pod_ip is None or pod_port is None):
pod = get_pod(pod_id)
+ if not pod:
+ raise ValueError(f"pod '{pod_id}' not found")
desired_status = pod.get("desiredStatus", None)
runtime = pod.get("runtime", None)
diff --git a/runpod/cli/utils/rp_sync.py b/runpod/cli/utils/rp_sync.py
deleted file mode 100644
index fa7fe290..00000000
--- a/runpod/cli/utils/rp_sync.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""
-Watches a directory for changes and syncs them to a remote directory.
-"""
-
-import threading
-import time
-
-from watchdog.events import FileSystemEventHandler
-from watchdog.observers.polling import PollingObserver as Observer
-
-from runpod.cli import STOP_EVENT
-
-from .rp_runpodignore import get_ignore_list, should_ignore
-
-
-class WatcherHandler(FileSystemEventHandler):
- """Watches a directory for changes and syncs them to a remote directory."""
-
- def __init__(self, action_function, local_path):
- self.action_function = action_function
- self.local_path = local_path
-
- self.ignore_list = get_ignore_list()
- self.debouncer = None
-
- def on_any_event(self, event):
- """Called on any event."""
- if event.is_directory or should_ignore(event.src_path, self.ignore_list):
- return
-
- if self.debouncer is not None:
- self.debouncer.cancel() # Cancel any existing timer
-
- # Start a new timer that will call the action function after 1 second
- self.debouncer = threading.Timer(0.5, self.action_function)
- self.debouncer.start()
-
-
-def start_watcher(action_function, local_path):
- """
- Starts the watcher.
- """
- event_handler = WatcherHandler(action_function, local_path)
- observer = Observer()
- observer.schedule(event_handler, local_path, recursive=True)
- observer.start()
-
- try:
- while not STOP_EVENT.is_set():
- time.sleep(0.5)
- finally:
- observer.stop()
- observer.join()
-
-
-def sync_directory(ssh_client, local_path, remote_path):
- """
- Syncs a local directory to a remote directory.
- """
-
- def sync():
- print("Syncing files...")
- ssh_client.rsync(local_path, remote_path, quiet=True)
-
- threading.Thread(target=start_watcher, daemon=True, args=(sync, local_path)).start()
-
- return sync # For testing purposes
diff --git a/runpod/rp_cli/__init__.py b/runpod/rp_cli/__init__.py
new file mode 100644
index 00000000..de9fc580
--- /dev/null
+++ b/runpod/rp_cli/__init__.py
@@ -0,0 +1 @@
+"""the `rp` command line interface."""
diff --git a/runpod/rp_cli/console.py b/runpod/rp_cli/console.py
new file mode 100644
index 00000000..8b766a12
--- /dev/null
+++ b/runpod/rp_cli/console.py
@@ -0,0 +1,597 @@
+"""console rendering for the rp cli.
+
+live, animated lifecycle output: spinner-driven phase tracking for
+deploys (with byte-level upload progress), per-resource provisioning
+animation for dev sessions, and color-coded resource badges. engine
+modules stay renderer-free; they emit duck-typed events and this
+module turns them into pixels.
+"""
+
+import time
+from typing import Dict, Iterable, List, Optional, Tuple
+
+from rich.console import Console
+from rich.progress import (
+ Progress,
+ SpinnerColumn,
+ TaskID,
+ TextColumn,
+)
+from rich.spinner import SPINNERS
+from rich.text import Text
+from rich.theme import Theme
+
+# -- brand ----------------------------------------------------------------
+
+ACCENT = "#673de6" # runpod purple
+ACCENT_LIGHT = "#a78bfa"
+OK = "#34d399"
+ERR = "#f87171"
+WARN = "#fbbf24"
+DIM = "grey58"
+
+RUNPOD_SAND_SPINNER = "runpod_sand"
+RUNPOD_SAND_SPINNER_FRAMES = [
+ "⠁",
+ "⠂",
+ "⠄",
+ "⡀",
+ "⡈",
+ "⡐",
+ "⡠",
+ "⣀",
+ "⣁",
+ "⣂",
+ "⣄",
+ "⣌",
+ "⣔",
+ "⣤",
+ "⣥",
+ "⣦",
+ "⣮",
+ "⣶",
+ "⣷",
+ "⣿",
+ "⡿",
+ "⠿",
+ "⢟",
+ "⠟",
+ "⡛",
+ "⠛",
+ "⠫",
+ "⢋",
+ "⠋",
+ "⠍",
+ "⡉",
+ "⠉",
+ "⠑",
+ "⠡",
+ "⢁",
+]
+SPINNERS[RUNPOD_SAND_SPINNER] = {
+ "interval": 1000 / 16,
+ "frames": RUNPOD_SAND_SPINNER_FRAMES,
+}
+
+KIND_STYLES: Dict[str, str] = {
+ "queue": "bold cyan",
+ "api": "bold magenta",
+ "task": "bold yellow",
+}
+
+theme = Theme(
+ {
+ "accent": ACCENT,
+ "accent.light": ACCENT_LIGHT,
+ "ok": OK,
+ "err": ERR,
+ "warn": WARN,
+ "dim": DIM,
+ "progress.elapsed": DIM,
+ }
+)
+
+console = Console(highlight=False, theme=theme)
+
+
+def _spinner_column(finished_text: str = "[ok]✓[/ok]") -> SpinnerColumn:
+ return SpinnerColumn(
+ RUNPOD_SAND_SPINNER,
+ style=ACCENT,
+ finished_text=finished_text,
+ )
+
+
+CONSOLE_URL = "https://console.runpod.io/serverless/user/endpoint"
+
+
+def endpoint_url(endpoint_id: str) -> str:
+ return f"{CONSOLE_URL}/{endpoint_id}?tab=overview"
+
+
+def endpoint_link(endpoint_id: str) -> str:
+ """short clickable endpoint reference (osc8 hyperlink)."""
+ return (
+ f"[link={endpoint_url(endpoint_id)}]"
+ f"[accent.light]{endpoint_id} ↗[/accent.light][/link]"
+ )
+
+
+
+
+_name_width: int = 0
+
+
+def set_name_width(names: Iterable[str]) -> None:
+ global _name_width
+ _name_width = max((len(n) for n in names), default=0)
+
+
+def _padded(name: str) -> str:
+ return f"{name:<{_name_width}}" if _name_width else name
+
+
+def _pipe(name: str) -> str:
+ return f"[accent.light]{_padded(name)}[/accent.light] [dim]│[/dim]"
+
+
+def kind_badge(kind: str) -> str:
+ style = KIND_STYLES.get(kind, "bold white")
+ return f"[{style}]{kind:<5}[/{style}]"
+
+
+# -- generic lines ---------------------------------------------------------
+
+
+def info(message: str) -> None:
+ console.print(f"{message}")
+
+
+def success(message: str) -> None:
+ console.print(f"[ok]✓[/ok] {message}")
+
+
+def error(message: str) -> None:
+ console.print(f"[err]✗[/err] {message}")
+
+
+def warn(message: str) -> None:
+ console.print(f"[warn]![/warn] {message}")
+
+
+def rule(title: str = "") -> None:
+ console.rule(f"[accent]{title}[/accent]" if title else None, style="dim")
+
+
+# -- request lifecycle -----------------------------------------------------
+
+
+def request_started(name: str, label: str = "") -> None:
+ console.print(
+ f"[bold white]CALL[/bold white] [accent.light]/{name}[/accent.light]"
+ f" [dim]{label}[/dim]"
+ )
+
+
+def request_completed(name: str, elapsed_s: float) -> None:
+ console.print(
+ f"[ok]✓[/ok] {_padded(name)} [dim]{elapsed_s:.1f}s[/dim]"
+ )
+
+
+def request_failed(name: str, elapsed_s: float, err: Optional[str] = None) -> None:
+ console.print(
+ f"[err]✗[/err] {_padded(name)} [dim]{elapsed_s:.1f}s[/dim]"
+ )
+ if err:
+ console.print(f" [err dim]{err}[/err dim]")
+
+
+# -- banners ---------------------------------------------------------------
+
+
+def dev_banner(app_names: Iterable[str], module: str) -> None:
+ console.print(
+ f"[bold white]dev[/bold white] [dim]{', '.join(app_names)} in {module}[/dim]"
+ )
+
+
+def deploy_plan(apps: Iterable[Tuple[str, str, int]]) -> None:
+ """upfront summary for multi-app deploys: (name, source, n_resources)."""
+ apps = list(apps)
+ console.print(
+ f"[bold white]deploy[/bold white] [dim]{len(apps)} apps[/dim]"
+ )
+ width = max(len(name) for name, _, _ in apps)
+ for name, source, count in apps:
+ detail = f"{source}, " if source else ""
+ console.print(
+ f" [white]{name:<{width}}[/white] "
+ f"[dim]{detail}{count} resource{'s' if count != 1 else ''}[/dim]"
+ )
+
+
+def deploy_banner(
+ app_name: str,
+ env: str,
+ resources: Iterable[Tuple[str, str]],
+ source: str = "",
+) -> None:
+ """resources: (name, kind) pairs."""
+ names = ", ".join(name for name, _ in resources) or "(no resources)"
+ origin = f" from {source}" if source else ""
+ console.print(
+ f"[bold white]deploy[/bold white] [white]{app_name}[/white] "
+ f"[dim]→ {env}{origin}, {names}[/dim]"
+ )
+
+
+def resources_table(rows: Iterable[tuple]) -> None:
+ """rows of (name, kind, hardware, endpoint_id)."""
+ rows = list(rows)
+ if not rows:
+ return
+ w_name = max(len(r[0]) for r in rows)
+ w_hw = max(len(r[2]) for r in rows)
+ for name, kind, hardware, endpoint_id in rows:
+ tail = (
+ "[dim]per-call[/dim]"
+ if not endpoint_id or endpoint_id == "per-call"
+ else endpoint_link(endpoint_id)
+ )
+ console.print(
+ f" [white]{name:<{w_name}}[/white]"
+ f" {kind_badge(kind)}"
+ f" [dim]{hardware:<{w_hw}}[/dim]"
+ f" {tail}"
+ )
+
+
+def reload_banner(module: str = "") -> None:
+ console.print()
+ detail = f" [dim]{module} changed[/dim]" if module else ""
+ console.print(f" [accent]↻[/accent] [white]reloading[/white]{detail}")
+
+
+# -- live deploy phases ------------------------------------------------------
+
+_PHASE_LABELS = {
+ "vendor": "resolving dependencies",
+ "package": "packaging artifact",
+ "upload": "uploading build",
+ "endpoints": "reconciling endpoints",
+}
+
+
+_BAR_WIDTH = 22
+
+
+def _bar(fraction: float) -> str:
+ """slim one-line progress bar."""
+ fraction = min(max(fraction, 0.0), 1.0)
+ filled = int(fraction * _BAR_WIDTH)
+ return (
+ f"[accent]{'━' * filled}[/accent]"
+ f"[grey30]{'━' * (_BAR_WIDTH - filled)}[/grey30]"
+ )
+
+
+class DeployEvents:
+ """deploy_app event sink: animated phase list with live progress.
+
+ each phase renders a spinner while active and collapses to a ✓ with
+ a short summary when the next begins. vendoring streams package
+ names as pip resolves them; upload renders a slim in-line bar.
+ """
+
+ def __init__(self) -> None:
+ self._progress = Progress(
+ _spinner_column(),
+ TextColumn("[white]{task.description}[/white]"),
+ TextColumn("{task.fields[detail]}"),
+ console=console,
+ transient=False,
+ )
+ self._current: Optional[TaskID] = None
+ self._current_name = ""
+ self._started = False
+ self._package_count = 0
+ self._upload_total = 0
+
+ def _ensure_started(self) -> None:
+ if not self._started:
+ self._progress.start()
+ self._started = True
+
+ def _finish_current(self) -> None:
+ if self._current is None:
+ return
+ # collapse the live detail into a short summary
+ if self._current_name == "vendor" and self._package_count:
+ summary = f"{self._package_count} packages"
+ elif self._current_name == "upload" and self._upload_total:
+ summary = f"{self._upload_total / 1048576:.1f} MB"
+ else:
+ summary = ""
+ self._progress.update(
+ self._current,
+ detail=f"[dim]{summary}[/dim]",
+ total=1,
+ completed=1,
+ )
+ self._current = None
+
+ def phase(self, name: str, detail: str = "") -> None:
+ self._ensure_started()
+ self._finish_current()
+ self._current_name = name
+ self._current = self._progress.add_task(
+ _PHASE_LABELS.get(name, name),
+ detail=f"[dim]{detail}[/dim]" if detail else "",
+ total=None,
+ )
+
+ def vendor_progress(self, count: int, package: str) -> None:
+ if self._current is None:
+ return
+ self._package_count = count
+ self._progress.update(
+ self._current,
+ detail=f"[accent.light]{package}[/accent.light] [dim]({count})[/dim]",
+ )
+
+ def upload_progress(self, sent: int, total: int) -> None:
+ if self._current is None:
+ return
+ self._upload_total = total
+ mb = 1048576
+ self._progress.update(
+ self._current,
+ detail=(
+ f"{_bar(sent / total)} "
+ f"[dim]{sent / mb:.1f} / {total / mb:.1f} MB[/dim]"
+ ),
+ )
+
+ def endpoint_ready(self, name: str, endpoint_id: str) -> None:
+ # collected, not printed: endpoint urls render in the final
+ # summary after the progress display stops
+ self.endpoints: Dict[str, str] = getattr(self, "endpoints", {})
+ self.endpoints[name] = endpoint_id
+
+ def close(self) -> None:
+ self._finish_current()
+ if self._started:
+ self._progress.stop()
+ self._started = False
+
+
+# -- live dev provisioning ----------------------------------------------------
+
+
+class DevEvents:
+ """DevSession event sink.
+
+ provisioning renders transient spinner rows that vanish once the
+ session is up (the resource table is the durable record). request
+ lifecycle renders as a block: an accent dispatch line, a dim
+ indented worker feed, and a check/cross verdict.
+ """
+
+ def __init__(self) -> None:
+ self._progress: Optional[Progress] = None
+ self._tasks: Dict[str, TaskID] = {}
+
+ def session_starting(self) -> None:
+ self._progress = Progress(
+ _spinner_column(),
+ TextColumn("[white]{task.description}[/white]"),
+ TextColumn("[dim]{task.fields[detail]}[/dim]"),
+ console=console,
+ transient=True,
+ )
+ self._progress.start()
+
+ def session_started(self) -> None:
+ if self._progress is not None:
+ self._progress.stop()
+ self._progress = None
+ self._tasks.clear()
+
+ def _row(self, name: str, detail: str) -> None:
+ if self._progress is None:
+ info(f"{_pipe(name)} {detail}")
+ return
+ if name not in self._tasks:
+ self._tasks[name] = self._progress.add_task(
+ _padded(name), detail=detail, total=None
+ )
+ else:
+ self._progress.update(self._tasks[name], detail=detail)
+
+ def provisioning(self, name: str, kind: str, hardware: str) -> None:
+ self._row(name, f"provisioning {kind} on {hardware}")
+
+ def adopted(self, name: str, endpoint_id: str) -> None:
+ self._row(name, "adopting")
+
+ def ready(self, name: str, endpoint_id: str) -> None:
+ if self._progress is not None and name in self._tasks:
+ self._progress.update(
+ self._tasks[name], detail="ready", total=1, completed=1
+ )
+
+ def deleted(self, name: str) -> None:
+ console.print(f" [dim]− {name}[/dim]")
+
+ # -- refresh diff --
+
+ def resource_added(self, name: str, kind: str, hardware: str) -> None:
+ console.print(
+ f" [ok]+[/ok] [white]{name}[/white] "
+ f"[dim]{kind} on {hardware}[/dim]"
+ )
+
+ def resource_changed(self, name: str, fields: List[str]) -> None:
+ what = ", ".join(fields) if fields else "config"
+ console.print(
+ f" [warn]~[/warn] [white]{name}[/white] [dim]{what}[/dim]"
+ )
+
+ def resource_removed(self, name: str) -> None:
+ console.print(f" [err]−[/err] [white]{name}[/white]")
+
+ # -- request lifecycle (LiveTarget events) --
+ # next.js/wrangler-style event lines: leading status glyph, the
+ # function call in normal weight, everything else dim, timing at
+ # the end. function stdout renders as `name │ line` so concurrent
+ # calls interleave without losing attribution.
+
+ def dispatch(self, name: str, label: str = "") -> None:
+ detail = f" [dim]{label}[/dim]" if label else ""
+ console.print(f" [accent]→[/accent] [white]{name}()[/white]{detail}")
+
+ def worker_status(self, name: str, counts: Dict[str, int]) -> None:
+ from runpod.apps.monitor import format_worker_counts
+
+ console.print(
+ f" [dim]↳ {name}() waiting: {format_worker_counts(counts)}[/dim]"
+ )
+
+ def task_status(self, name: str, detail: str) -> None:
+ console.print(f" [dim]↳ {name}() {detail}[/dim]")
+
+ def volume_created(self, name: str, size: int, dc: str) -> None:
+ console.print(
+ f" [ok]+[/ok] [white]volume {name}[/white] [dim]{size}GB in {dc}[/dim]"
+ )
+
+ def worker_ready(self, name: str, worker_id: str) -> None:
+ worker = worker_id[:12] if worker_id else "pod"
+ console.print(
+ f" [accent]↳[/accent] [white]{name}()[/white] [dim]worker "
+ f"{worker} ready[/dim]"
+ )
+
+ def worker_log(self, name: str, line: str) -> None:
+ from rich.markup import escape
+
+ console.print(f" {_pipe(name)} {escape(line)}")
+
+ def request_completed(self, name: str, elapsed_s: float) -> None:
+ console.print(
+ f" [ok]✓[/ok] [white]{name}()[/white] "
+ f"[dim]in {_fmt_elapsed(elapsed_s)}[/dim]"
+ )
+
+ def request_failed(self, name: str, elapsed_s: float) -> None:
+ console.print(
+ f" [err]✗[/err] [white]{name}()[/white] "
+ f"[dim]failed in {_fmt_elapsed(elapsed_s)}[/dim]"
+ )
+
+
+class CleanupEvents:
+ """session teardown sink: one spinner line with delete progress."""
+
+ def __init__(self) -> None:
+ self._progress: Optional[Progress] = None
+ self._task: Optional[TaskID] = None
+ self._total = 0
+ self._done = 0
+
+ def cleanup_started(self, total: int) -> None:
+ self._total = total
+ if total == 0:
+ return
+ console.print()
+ self._progress = Progress(
+ _spinner_column(),
+ TextColumn("[white]{task.description}[/white]"),
+ TextColumn("[dim]{task.fields[detail]}[/dim]"),
+ console=console,
+ )
+ self._progress.start()
+ self._task = self._progress.add_task(
+ "cleaning up", detail="", total=total
+ )
+
+ def deleting(self, name: str) -> None:
+ if self._progress is not None and self._task is not None:
+ self._progress.update(
+ self._task, detail=f"{name} ({self._done}/{self._total})"
+ )
+
+ def deleted(self, name: str) -> None:
+ self._done += 1
+ if self._progress is not None and self._task is not None:
+ self._progress.update(
+ self._task,
+ completed=self._done,
+ detail=f"{name} ({self._done}/{self._total})",
+ )
+
+ def delete_failed(self, name: str) -> None:
+ self._done += 1
+ if self._progress is not None and self._task is not None:
+ self._progress.update(self._task, completed=self._done)
+ console.print(f" [warn]![/warn] [dim]could not delete {name}[/dim]")
+
+ def close(self) -> None:
+ if self._progress is not None and self._task is not None:
+ self._progress.update(
+ self._task,
+ completed=self._total,
+ detail=f"{self._done} deleted",
+ )
+ self._progress.stop()
+ self._progress = None
+
+
+class Timer:
+ """context timer for elapsed reporting."""
+
+ def __enter__(self) -> "Timer":
+ self._start = time.monotonic()
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.elapsed = time.monotonic() - self._start
+
+ @property
+ def so_far(self) -> float:
+ return time.monotonic() - self._start
+
+
+def _fmt_elapsed(elapsed: float) -> str:
+ if elapsed < 1:
+ return f"{elapsed * 1000:.0f}ms"
+ if elapsed < 120:
+ return f"{elapsed:.1f}s"
+ return f"{int(elapsed // 60)}m{int(elapsed % 60):02d}s"
+
+
+def entrypoint_header(fn_name: str = "") -> None:
+ console.print()
+ if fn_name:
+ console.print(
+ f" [accent]◆[/accent] [white]{fn_name}()[/white] "
+ f"[dim]local entrypoint[/dim]"
+ )
+
+
+def entrypoint_success(elapsed: float) -> None:
+ console.print()
+ console.print(
+ f" [ok]✓[/ok] [white]done[/white] [dim]in {_fmt_elapsed(elapsed)}, "
+ f"enter re-run, edit reload, ^C quit[/dim]"
+ )
+
+
+def entrypoint_failure(elapsed: float, err: str) -> None:
+ console.print()
+ console.print(f" [err]✗ {err}[/err]")
+ console.print(
+ f" [err]✗[/err] [white]failed[/white] [dim]in {_fmt_elapsed(elapsed)}, "
+ f"enter re-run, edit reload, ^C quit[/dim]"
+ )
diff --git a/runpod/rp_cli/main.py b/runpod/rp_cli/main.py
new file mode 100644
index 00000000..16e396ed
--- /dev/null
+++ b/runpod/rp_cli/main.py
@@ -0,0 +1,1018 @@
+"""the `rp` / `runpod` cli.
+
+a single plain-click command tree: app commands (deploy, dev, logs,
+login) and the pod/ssh/exec/config groups all hang off one root, so
+help, errors, and exit codes behave identically at every level. rich
+is used only for runtime output (progress, lifecycle lines), never for
+help rendering.
+"""
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+import click
+
+
+# commands whose output would be polluted by (or that manage) the
+# update notice
+_UPDATE_CHECK_EXCLUDED = frozenset({"dev", "update"})
+
+
+@click.group()
+@click.pass_context
+def cli(ctx):
+ """Runpod CLI: deploy and manage apps, endpoints, and pods."""
+ if ctx.invoked_subcommand not in _UPDATE_CHECK_EXCLUDED:
+ from runpod.rp_cli.update import start_background_check
+
+ start_background_check()
+
+
+def _fail(message: str) -> None:
+ raise click.ClickException(message)
+
+
+def _app_source(found, project_root: Path) -> str:
+ """the file an app was discovered in, relative to the project."""
+ source = getattr(found, "_source_file", None)
+ if source is None:
+ return ""
+ try:
+ return str(Path(source).resolve().relative_to(project_root))
+ except ValueError:
+ return str(source)
+
+
+# ------------------------------------------------------------------ init
+
+
+@cli.command()
+@click.argument("project_name", required=False)
+@click.option("--force", "-f", is_flag=True, help="Overwrite existing files.")
+def init(project_name, force):
+ """Create a new app project (PROJECT_NAME, or '.' for the cwd)."""
+ from runpod.apps.init import create_project, detect_conflicts
+ from runpod.rp_cli import console as ui
+
+ if project_name is None or project_name == ".":
+ project_dir = Path.cwd()
+ name = project_dir.name
+ else:
+ project_dir = Path(project_name)
+ name = project_name
+
+ conflicts = detect_conflicts(project_dir)
+ if conflicts and not force:
+ listing = ", ".join(conflicts)
+ _fail(
+ f"{listing} already exist{'s' if len(conflicts) == 1 else ''} "
+ f"in {project_dir}. pass --force to overwrite."
+ )
+
+ written = create_project(project_dir, name, overwrite=force)
+ ui.success(
+ f"initialized [white]{name}[/white] "
+ f"[dim]{len(written)} files[/dim]"
+ )
+ ui.console.print()
+ if project_dir != Path.cwd():
+ ui.console.print(f" [dim]cd {project_name}[/dim]")
+ ui.console.print(" [dim]rp login[/dim]")
+ ui.console.print(" [dim]rp dev main.py[/dim]")
+ ui.console.print()
+
+
+# ---------------------------------------------------------------- update
+
+
+@cli.command()
+@click.option(
+ "--version", "-V", "version_opt", default=None, help="Target version."
+)
+def update(version_opt):
+ """Update the runpod package to the latest (or a given) version."""
+ from runpod.rp_cli import console as ui
+ from runpod.rp_cli.update import (
+ compare_versions,
+ current_version,
+ fetch_pypi_metadata,
+ parse_version,
+ run_install,
+ )
+
+ current = current_version()
+ ui.console.print(f"current version: [white]{current}[/white]")
+
+ with ui.console.status("[dim]checking pypi ...[/dim]"):
+ try:
+ latest, releases = fetch_pypi_metadata()
+ except (ConnectionError, RuntimeError) as exc:
+ _fail(str(exc))
+
+ target = version_opt or latest
+ if target not in releases:
+ _fail(f"version '{target}' not found on pypi")
+ if current == target:
+ ui.console.print(f"already on [white]{target}[/white], nothing to do")
+ return
+ if (
+ current != "unknown"
+ and compare_versions(parse_version(target), parse_version(current)) < 0
+ ):
+ ui.warn(f"downgrading from {current} to {target}")
+
+ with ui.console.status(f"[dim]installing runpod {target} ...[/dim]"):
+ try:
+ run_install(target)
+ except Exception as exc: # noqa: BLE001 - surface installer errors cleanly
+ _fail(str(exc))
+ ui.success(f"updated to [white]{target}[/white]")
+
+
+# ---------------------------------------------------------------- deploy
+
+
+@cli.command()
+@click.argument("target", required=False, type=click.Path(path_type=Path))
+@click.option("--env", "-e", "env", default=None, help="Target environment name.")
+@click.option(
+ "--python-version",
+ default=None,
+ help="Worker python version for dependency wheels (3.10-3.14). "
+ "Defaults to the python running the cli.",
+)
+@click.option(
+ "--exclude",
+ default=None,
+ help="Comma-separated packages to exclude from the artifact "
+ "(they must then come from the worker image). Torch packages are "
+ "excluded automatically on builtin gpu images.",
+)
+@click.option(
+ "--build-only",
+ is_flag=True,
+ help="Build the artifact without deploying; writes "
+ "-artifact.tar.gz to the current directory.",
+)
+def deploy(target, env, python_version, exclude, build_only):
+ """Package and deploy all apps found in TARGET (default: cwd)."""
+ import logging
+
+ from runpod.apps.deploy import build_artifact, deploy_app
+ from runpod.apps.discovery import DiscoveryError, discover_apps
+ from runpod.rp_cli import console as ui
+
+ logging.getLogger("runpod.apps").setLevel(logging.WARNING)
+
+ if python_version is None:
+ from runpod.apps.images import (
+ DEFAULT_PYTHON_VERSION,
+ local_python_version,
+ )
+
+ try:
+ python_version = local_python_version()
+ except RuntimeError:
+ # deployed calls are plain json (no pickle compat needed),
+ # so an unsupported local interpreter falls back instead of
+ # failing like dev does
+ ui.warn(
+ f"local python has no runtime image; building for "
+ f"{DEFAULT_PYTHON_VERSION} (override with --python-version)"
+ )
+ python_version = DEFAULT_PYTHON_VERSION
+
+ target = (target or Path.cwd()).resolve()
+ if not target.exists():
+ _fail(f"{target} does not exist")
+
+ project_root = target if target.is_dir() else target.parent
+
+ try:
+ apps = discover_apps(target)
+ except DiscoveryError as exc:
+ _fail(str(exc))
+
+ if not apps:
+ _fail(
+ "no runpod.App found. define one with:\n\n"
+ " from runpod import App\n"
+ ' app = App("my-app")'
+ )
+
+ if build_only:
+ for found in apps:
+ ui.set_name_width(list(found.resources))
+ events = ui.DeployEvents()
+ try:
+ artifact = build_artifact(
+ found,
+ project_root,
+ python_version=python_version,
+ exclude=exclude.split(",") if exclude else None,
+ events=events,
+ output=Path.cwd() / f"{found.name}-artifact.tar.gz",
+ )
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ finally:
+ events.close()
+ size_mb = artifact.stat().st_size / (1024 * 1024)
+ ui.success(
+ f"built [white]{found.name}[/white] "
+ f"[dim]{artifact.name} ({size_mb:.1f} MB)[/dim]"
+ )
+ return
+
+ if len(apps) > 1:
+ ui.deploy_plan(
+ [
+ (
+ found.name,
+ _app_source(found, project_root),
+ len(found.resources),
+ )
+ for found in apps
+ ]
+ )
+
+ for index, found in enumerate(apps):
+ if index or len(apps) > 1:
+ ui.console.print()
+ ui.set_name_width(list(found.resources))
+ ui.deploy_banner(
+ found.name,
+ env or found.env,
+ [(h.spec.name, h.spec.kind.value) for h in found.resources.values()],
+ source=_app_source(found, project_root),
+ )
+ events = ui.DeployEvents()
+ with ui.Timer() as t:
+ try:
+ result = asyncio.run(
+ deploy_app(
+ found,
+ project_root,
+ env_name=env,
+ python_version=python_version,
+ exclude=exclude.split(",") if exclude else None,
+ events=events,
+ )
+ )
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ finally:
+ events.close()
+ ui.success(
+ f"[bold white]{result.app_name}/{env or found.env}[/bold white] "
+ f"is live [dim]{t.elapsed:.1f}s[/dim]"
+ )
+ if result.endpoints:
+ ui.console.print()
+ w = max(len(n) for n in result.endpoints)
+ for name, endpoint_id in sorted(result.endpoints.items()):
+ ui.console.print(
+ f" [white]{name:<{w}}[/white] "
+ f"{ui.endpoint_link(endpoint_id)}"
+ )
+ ui.console.print()
+
+
+# ------------------------------------------------------------------- dev
+
+
+@cli.command()
+@click.argument("module", type=click.Path(exists=True, path_type=Path))
+@click.option(
+ "--once",
+ is_flag=True,
+ help="Run the entrypoint once, tear down, and exit (for scripts/CI).",
+)
+def dev(module, once):
+ """Start an interactive dev session for MODULE.
+
+ Provisions temporary live endpoints, runs the module's
+ @runpod.local_entrypoint, watches for file changes (re-scanning and
+ refreshing endpoints so requests run fresh code), and deletes the
+ endpoints on exit.
+ """
+ import logging
+
+ from runpod.apps.dev import DevSession
+ from runpod.apps.discovery import DiscoveryError, discover_apps
+ from runpod.apps.entrypoint import get_entrypoint, run_entrypoint
+ from runpod.apps.watch import FileWatcher
+ from runpod.rp_cli import console as ui
+
+ logging.getLogger("runpod.apps").setLevel(logging.WARNING)
+
+ module = module.resolve()
+ if not module.is_file():
+ _fail(f"{module} is not a file")
+
+ os.environ["RUNPOD_DEV_SESSION"] = "1"
+
+ def _scan():
+ # each rescan registers entrypoints anew; clearing first keeps
+ # get_entrypoint() reflecting the current file, not stale runs
+ from runpod.apps.entrypoint import _clear_entrypoints
+
+ _clear_entrypoints()
+ try:
+ apps = discover_apps(module)
+ except DiscoveryError as exc:
+ _fail(str(exc))
+ if not apps:
+ _fail("no runpod.App found in module")
+ entrypoint = get_entrypoint()
+ if entrypoint is None:
+ _fail(
+ "no @runpod.local_entrypoint found. define one:\n\n"
+ " @runpod.local_entrypoint\n"
+ " async def main(): ..."
+ )
+ return apps, entrypoint
+
+ apps, entrypoint = _scan()
+
+ def _all_resource_names(app_list) -> list:
+ return [
+ handle.spec.name
+ for a in app_list
+ for handle in a.resources.values()
+ ]
+
+ def _table_rows(session: "DevSession") -> list:
+ rows = []
+ for a in session.apps:
+ for handle in a.resources.values():
+ spec = handle.spec
+ hardware = ",".join(spec.cpu or spec.gpu or ["any"])
+ endpoint_id = session._endpoints.get(
+ f"dev-{a.name}-{spec.name}", ""
+ ) or ("per-call" if spec.kind.value == "task" else "")
+ rows.append((spec.name, spec.kind.value, hardware, endpoint_id))
+ return rows
+
+ # daemon stdin reader: a blocked readline must never keep the
+ # process alive after ctrl-c (executor threads are joined at
+ # loop shutdown; a daemon thread is not)
+ import queue as _queue
+ import threading
+
+ stdin_lines: "_queue.Queue[str]" = _queue.Queue()
+
+ def _stdin_reader() -> None:
+ for line in sys.stdin:
+ stdin_lines.put(line)
+ stdin_lines.put("") # eof marker
+
+ threading.Thread(target=_stdin_reader, daemon=True).start()
+
+ async def _wait_for_rerun(watcher: "FileWatcher") -> str:
+ """race the enter key against a file change."""
+ eof = False
+ while True:
+ if watcher.changed():
+ return "changed"
+ if not eof:
+ try:
+ line = stdin_lines.get_nowait()
+ if line == "":
+ # piped stdin / ci: no interactive re-runs,
+ # keep watching files only
+ eof = True
+ else:
+ return "enter"
+ except _queue.Empty:
+ # no keypress this tick; fall through to the sleep
+ pass
+ await asyncio.sleep(0.5)
+
+ async def _run_entrypoint_cancellable(fn) -> None:
+ """drive the entrypoint on a daemon thread.
+
+ the entrypoint is user code full of blocking .remote() calls;
+ running it inline would pin the main loop and make ctrl-c
+ undeliverable (asyncio's sigint handler cancels the main task,
+ which needs an await point). a daemon thread keeps the loop
+ free, and cancellation simply abandons the in-flight call.
+ """
+ loop = asyncio.get_running_loop()
+ done: asyncio.Future = loop.create_future()
+
+ def _finish(exc: "BaseException | None") -> None:
+ if done.done():
+ return
+ if exc is None:
+ done.set_result(None)
+ else:
+ done.set_exception(exc)
+
+ def _runner() -> None:
+ try:
+ run_entrypoint(fn)
+ # BaseException on purpose: SystemExit/KeyboardInterrupt from
+ # the entrypoint must reach the loop, not die on this thread
+ except BaseException as exc: # noqa: BLE001 - marshalled to the loop
+ # bind the exception now: `as exc` is unbound once the
+ # except block exits, before the loop callback runs
+ loop.call_soon_threadsafe(_finish, exc)
+ else:
+ loop.call_soon_threadsafe(_finish, None)
+
+ threading.Thread(target=_runner, daemon=True).start()
+ await done
+
+ async def _session() -> int:
+ nonlocal apps, entrypoint
+ ui.set_name_width(_all_resource_names(apps))
+ session = DevSession(apps, events=ui.DevEvents())
+ watcher = FileWatcher([module.parent])
+
+ ui.dev_banner([a.name for a in apps], str(module.name))
+ await session.start()
+ ui.resources_table(_table_rows(session))
+
+ try:
+ while True:
+ ui.entrypoint_header(getattr(entrypoint, "__name__", ""))
+ with ui.Timer() as t:
+ try:
+ await _run_entrypoint_cancellable(entrypoint)
+ ui.entrypoint_success(t.so_far)
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc: # noqa: BLE001 - dev loop survives user errors
+ ui.entrypoint_failure(t.so_far, str(exc))
+ if once:
+ return 1
+ if once:
+ return 0
+ reason = await _wait_for_rerun(watcher)
+ if reason == "changed":
+ ui.reload_banner(str(module.name))
+ apps, entrypoint = _scan()
+ ui.set_name_width(_all_resource_names(apps))
+ await session.refresh(apps)
+ finally:
+ events = ui.CleanupEvents()
+ await session.stop(events=events)
+ events.close()
+
+ try:
+ sys.exit(asyncio.run(_session()))
+ except (KeyboardInterrupt, EOFError, asyncio.CancelledError):
+ ui.console.print()
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+
+# ------------------------------------------------------------------ logs
+
+
+@cli.command()
+@click.argument("pod_id")
+@click.option("--follow", "-f", is_flag=True, help="Stream logs as they arrive.")
+@click.option(
+ "--type",
+ "log_type",
+ default="all",
+ show_default=True,
+ type=click.Choice(["all", "container", "system"]),
+ help="Log source.",
+)
+@click.option("--tail", default=100, show_default=True, help="Lines of backfill.")
+def logs(pod_id, follow, log_type, tail):
+ """Show a pod's container and system logs."""
+ from runpod.apps.logs import pod_logs, stream_pod_logs
+
+ async def _run():
+ if follow:
+ async for entry in stream_pod_logs(
+ pod_id, log_type=log_type, tail=tail
+ ):
+ source = entry.get("source", "?")
+ click.echo(f"[{source}] {entry.get('line', '')}")
+ else:
+ logs_data = await pod_logs(pod_id, log_type=log_type)
+ for source in ("system", "container"):
+ for line in logs_data.get(source) or []:
+ click.echo(f"[{source}] {line}")
+
+ try:
+ asyncio.run(_run())
+ except KeyboardInterrupt:
+ # ctrl-c ends the log follow; not an error
+ pass
+
+
+# ----------------------------------------------------------------- login
+
+
+@cli.command()
+@click.option(
+ "--no-open", is_flag=True, help="Print the url instead of opening a browser."
+)
+@click.option(
+ "--api-key",
+ "api_key_opt",
+ default=None,
+ help="Store this API key directly (skips the browser flow).",
+)
+def login(no_open, api_key_opt):
+ """Authenticate with Runpod and store the API key.
+
+ Opens the Runpod console for browser approval by default; pass
+ --api-key to store a key directly.
+ """
+ from runpod.apps.auth import LoginError, browser_login
+ from runpod.cli.groups.config.functions import set_credentials
+ from runpod.rp_cli import console as ui
+
+ if api_key_opt:
+ try:
+ set_credentials(api_key_opt, overwrite=True)
+ except ValueError as exc:
+ _fail(str(exc))
+ ui.success("credentials saved to [dim]~/.runpod/config.toml[/dim]")
+ return
+
+ def _show_url(url: str) -> None:
+ ui.console.print()
+ ui.console.print(" [white]authorize in your browser:[/white]")
+ ui.console.print(f" [accent.light][link={url}]{url}[/link][/accent.light]")
+ ui.console.print()
+ if not no_open:
+ click.launch(url)
+
+ try:
+ with ui.console.status("[dim]waiting for approval ...[/dim]"):
+ api_key = asyncio.run(browser_login(on_url=_show_url))
+ set_credentials(api_key, overwrite=True)
+ except (LoginError, ValueError) as exc:
+ _fail(str(exc))
+ ui.success("logged in, credentials saved to [dim]~/.runpod/config.toml[/dim]")
+
+
+# ---------------------------------------------------------- app / env
+
+
+def _fmt_ts(value) -> str:
+ if not value:
+ return "-"
+ text = str(value)
+ return text[:10] if len(text) >= 10 else text
+
+
+@cli.group()
+def app():
+ """Manage deployed apps."""
+
+
+@app.command(name="list")
+def app_list():
+ """List all apps and their environments."""
+ from runpod.apps.manage import list_apps
+ from runpod.rp_cli import console as ui
+
+ try:
+ apps = asyncio.run(list_apps())
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+ if not apps:
+ ui.console.print("\n no apps deployed. run [white]rp deploy[/white]\n")
+ return
+
+ width = max(len(a["name"]) for a in apps)
+ ui.console.print()
+ for entry in apps:
+ envs = entry.get("flashEnvironments") or []
+ names = ", ".join(e["name"] for e in envs) or "-"
+ ui.console.print(
+ f" [white]{entry['name']:<{width}}[/white] [dim]{names}[/dim]"
+ )
+ ui.console.print()
+
+
+@app.command(name="get")
+@click.argument("app_name")
+def app_get(app_name):
+ """Show one app with its environments."""
+ from runpod.apps.manage import get_app
+ from runpod.rp_cli import console as ui
+
+ try:
+ entry = asyncio.run(get_app(app_name))
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+ envs = entry.get("flashEnvironments") or []
+ ui.console.print()
+ ui.console.print(f" [white]{entry['name']}[/white]")
+ if envs:
+ width = max(len(e["name"]) for e in envs)
+ for env_entry in envs:
+ build = (env_entry.get("activeBuildId") or "no build")[:12]
+ ui.console.print(
+ f" [white]{env_entry['name']:<{width}}[/white] [dim]{build}[/dim]"
+ )
+ ui.console.print()
+
+
+@app.command(name="delete")
+@click.argument("app_name")
+@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
+def app_delete(app_name, yes):
+ """Delete an app, undeploying every environment in it."""
+ from runpod.apps.manage import delete_app
+ from runpod.rp_cli import console as ui
+
+ if not yes:
+ click.confirm(
+ f"delete app '{app_name}' and all its environments?", abort=True
+ )
+
+ events = ui.CleanupEvents()
+ try:
+ result = asyncio.run(delete_app(app_name, events=events))
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ finally:
+ events.close()
+
+ if result.failures:
+ for failure in result.failures:
+ ui.error(failure)
+ raise click.ClickException("undeploy incomplete; app kept")
+ ui.success(
+ f"deleted [white]{app_name}[/white] "
+ f"[dim]{result.endpoints_deleted} endpoints removed[/dim]"
+ )
+
+
+@cli.group()
+def registry():
+ """Manage container registry credentials (private images)."""
+
+
+@registry.command(name="add")
+@click.argument("name", required=False)
+@click.option("--username", default=None, help="Registry username (prompted if omitted).")
+@click.option("--password", default=None, help="Registry password/token (prompted if omitted).")
+def registry_add(name, username, password):
+ """Create a registry credential. Reference it with registry_auth=NAME."""
+ from runpod.apps.api import AppsApiClient
+ from runpod.rp_cli import console as ui
+
+ if name is None:
+ name = click.prompt("name")
+ if username is None:
+ username = click.prompt("username")
+ if password is None:
+ password = click.prompt("password", hide_input=True)
+
+ try:
+ asyncio.run(
+ AppsApiClient().create_registry_auth(name, username, password)
+ )
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ ui.success(
+ f"registry credential [white]{name}[/white] created, "
+ f'[dim]use registry_auth="{name}"[/dim]'
+ )
+
+
+@registry.command(name="list")
+def registry_list():
+ """List registry credential names."""
+ from runpod.apps.api import AppsApiClient
+ from runpod.rp_cli import console as ui
+
+ try:
+ creds = asyncio.run(AppsApiClient().list_registry_auths())
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+ if not creds:
+ ui.console.print(
+ "\n no registry credentials. run [white]rp registry add [/white]\n"
+ )
+ return
+ ui.console.print()
+ for entry in sorted(creds, key=lambda c: c["name"]):
+ ui.console.print(f" [white]{entry['name']}[/white] [dim]{entry['id']}[/dim]")
+ ui.console.print()
+
+
+@registry.command(name="delete")
+@click.argument("name")
+@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
+def registry_delete(name, yes):
+ """Delete a registry credential by name."""
+ from runpod.apps.api import AppsApiClient
+ from runpod.rp_cli import console as ui
+
+ async def _delete():
+ client = AppsApiClient()
+ creds = await client.list_registry_auths()
+ match = next((c for c in creds if c["name"] == name), None)
+ if match is None:
+ _fail(f"no registry credential named '{name}'")
+ await client.delete_registry_auth(match["id"])
+
+ if not yes:
+ click.confirm(f"delete registry credential '{name}'?", abort=True)
+ try:
+ asyncio.run(_delete())
+ except click.ClickException:
+ raise
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ ui.success(f"deleted registry credential [white]{name}[/white]")
+
+
+@cli.group()
+def secret():
+ """Manage platform secrets (encrypted env values)."""
+
+
+@secret.command(name="add")
+@click.argument("name", required=False)
+@click.option("--value", default=None, help="Secret value (prompted if omitted).")
+@click.option("--description", default="", help="Optional description.")
+def secret_add(name, value, description):
+ """Create a secret. Reference it with runpod.Secret(NAME).
+
+ Prompts for anything not provided: bare `rp secret add` asks for
+ name and value, `rp secret add NAME` asks for just the value.
+ """
+ from runpod.apps.api import AppsApiClient
+ from runpod.rp_cli import console as ui
+
+ if name is None:
+ name = click.prompt("name")
+ if value is None:
+ value = click.prompt("value", hide_input=True)
+
+ try:
+ asyncio.run(AppsApiClient().create_secret(name, value, description))
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ ui.success(
+ f"secret [white]{name}[/white] created, "
+ f"[dim]use runpod.Secret(\"{name}\")[/dim]"
+ )
+
+
+@secret.command(name="list")
+def secret_list():
+ """List secret names (values are never shown)."""
+ from runpod.apps.api import AppsApiClient
+ from runpod.rp_cli import console as ui
+
+ try:
+ secrets = asyncio.run(AppsApiClient().list_secrets())
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+ if not secrets:
+ ui.console.print("\n no secrets. run [white]rp secret add [/white]\n")
+ return
+ ui.console.print()
+ width = max(len(s["name"]) for s in secrets)
+ for entry in sorted(secrets, key=lambda s: s["name"]):
+ description = entry.get("description") or ""
+ ui.console.print(
+ f" [white]{entry['name']:<{width}}[/white] [dim]{description}[/dim]"
+ )
+ ui.console.print()
+
+
+@secret.command(name="delete")
+@click.argument("name")
+@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
+def secret_delete(name, yes):
+ """Delete a secret by name."""
+ from runpod.apps.api import AppsApiClient
+ from runpod.rp_cli import console as ui
+
+ async def _delete():
+ client = AppsApiClient()
+ secrets = await client.list_secrets()
+ match = next((s for s in secrets if s["name"] == name), None)
+ if match is None:
+ _fail(f"no secret named '{name}'")
+ await client.delete_secret(match["id"])
+
+ if not yes:
+ click.confirm(f"delete secret '{name}'?", abort=True)
+ try:
+ asyncio.run(_delete())
+ except click.ClickException:
+ raise
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ ui.success(f"deleted secret [white]{name}[/white]")
+
+
+@cli.group()
+def env():
+ """Manage app environments."""
+
+
+def _resolve_app_name(app_name) -> str:
+ """--app flag or the single app discovered in cwd."""
+ if app_name:
+ return app_name
+ from runpod.apps.discovery import DiscoveryError, discover_apps
+
+ try:
+ apps = discover_apps(Path.cwd())
+ except DiscoveryError:
+ apps = []
+ if len(apps) == 1:
+ return apps[0].name
+ _fail("pass --app (no unique app found in the current directory)")
+
+
+@env.command(name="list")
+@click.option("--app", "-a", "app_name", default=None, help="App name.")
+def env_list(app_name):
+ """List environments for an app."""
+ from runpod.apps.manage import get_app
+ from runpod.rp_cli import console as ui
+
+ name = _resolve_app_name(app_name)
+ try:
+ entry = asyncio.run(get_app(name))
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+ envs = entry.get("flashEnvironments") or []
+ if not envs:
+ ui.console.print(
+ f"\n no environments in [white]{name}[/white]. run [white]rp deploy[/white]\n"
+ )
+ return
+ ui.console.print()
+ width = max(len(e["name"]) for e in envs)
+ for env_entry in envs:
+ build = (env_entry.get("activeBuildId") or "-")[:12]
+ ui.console.print(
+ f" [white]{env_entry['name']:<{width}}[/white] "
+ f"[dim]{build}, {_fmt_ts(env_entry.get('createdAt'))}[/dim]"
+ )
+ ui.console.print()
+
+
+@env.command(name="get")
+@click.argument("env_name")
+@click.option("--app", "-a", "app_name", default=None, help="App name.")
+def env_get(env_name, app_name):
+ """Show an environment with its endpoints."""
+ from runpod.apps.manage import get_environment
+ from runpod.rp_cli import console as ui
+
+ name = _resolve_app_name(app_name)
+ try:
+ entry = asyncio.run(get_environment(name, env_name))
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+
+ ui.console.print()
+ ui.console.print(
+ f" [white]{name}/{entry['name']}[/white] "
+ f"[dim]build {(entry.get('activeBuildId') or '-')[:12]}[/dim]"
+ )
+ endpoints = entry.get("endpoints") or []
+ if endpoints:
+ width = max(len(e["name"]) for e in endpoints)
+ for endpoint in endpoints:
+ ui.console.print(
+ f" [white]{endpoint['name']:<{width}}[/white] "
+ f"{ui.endpoint_link(endpoint['id'])}"
+ )
+ ui.console.print()
+
+
+@env.command(name="add")
+@click.argument("env_name")
+@click.option("--app", "-a", "app_name", default=None, help="App name.")
+def env_add(env_name, app_name):
+ """Create a new environment in an app."""
+ from runpod.apps.api import AppsApiClient
+ from runpod.apps.manage import get_app
+ from runpod.rp_cli import console as ui
+
+ name = _resolve_app_name(app_name)
+
+ async def _create():
+ client = AppsApiClient()
+ entry = await get_app(name, api=client)
+ return await client.create_environment(entry["id"], env_name)
+
+ try:
+ asyncio.run(_create())
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ ui.success(f"created [white]{name}/{env_name}[/white]")
+
+
+@env.command(name="delete")
+@click.argument("env_name")
+@click.option("--app", "-a", "app_name", default=None, help="App name.")
+@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
+def env_delete(env_name, app_name, yes):
+ """Undeploy and delete an environment."""
+ from runpod.apps.manage import undeploy_environment
+ from runpod.rp_cli import console as ui
+
+ name = _resolve_app_name(app_name)
+ if not yes:
+ click.confirm(
+ f"undeploy and delete '{name}/{env_name}'?", abort=True
+ )
+
+ events = ui.CleanupEvents()
+ try:
+ result = asyncio.run(
+ undeploy_environment(name, env_name, events=events)
+ )
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ finally:
+ events.close()
+
+ if result.failures:
+ for failure in result.failures:
+ ui.error(failure)
+ raise click.ClickException("undeploy incomplete; environment kept")
+ ui.success(
+ f"deleted [white]{name}/{env_name}[/white] "
+ f"[dim]{result.endpoints_deleted} endpoints removed[/dim]"
+ )
+
+
+@cli.command()
+@click.option("--app", "-a", "app_name", default=None, help="App name.")
+@click.option("--env", "-e", "env_name", default="default", show_default=True)
+@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
+def undeploy(app_name, env_name, yes):
+ """Tear down a deployed environment's endpoints.
+
+ Deletes the environment's endpoints and the environment itself;
+ the app and its build history remain.
+ """
+ from runpod.apps.manage import undeploy_environment
+ from runpod.rp_cli import console as ui
+
+ name = _resolve_app_name(app_name)
+ if not yes:
+ click.confirm(f"undeploy '{name}/{env_name}'?", abort=True)
+
+ events = ui.CleanupEvents()
+ try:
+ result = asyncio.run(
+ undeploy_environment(name, env_name, events=events)
+ )
+ except Exception as exc: # noqa: BLE001 - surface engine errors cleanly
+ raise click.ClickException(str(exc)) from exc
+ finally:
+ events.close()
+
+ if result.failures:
+ for failure in result.failures:
+ ui.error(failure)
+ raise click.ClickException("undeploy incomplete")
+ ui.success(
+ f"undeployed [white]{name}/{env_name}[/white] "
+ f"[dim]{result.endpoints_deleted} endpoints removed[/dim]"
+ )
+
+
+# -------------------------------------------------------- legacy groups
+
+
+def _mount_groups() -> None:
+ from runpod.cli.groups.pod.commands import pod_cli
+ from runpod.cli.groups.ssh.commands import ssh_cli
+
+ for command in (ssh_cli, pod_cli):
+ cli.add_command(command)
+
+
+_mount_groups()
+
+
+def run() -> None:
+ """console-script entry point."""
+ cli()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/runpod/rp_cli/update.py b/runpod/rp_cli/update.py
new file mode 100644
index 00000000..5097bd9e
--- /dev/null
+++ b/runpod/rp_cli/update.py
@@ -0,0 +1,223 @@
+"""self-update for the rp cli and a passive new-version notice.
+
+`rp update` installs the latest (or a pinned) runpod release from pypi
+using uv when available, pip otherwise. every other command starts a
+daemon thread that checks pypi at most once per day (cached under
+~/.runpod) and prints a one-line notice at exit when a newer version
+exists. the check never blocks or crashes a command.
+"""
+
+from __future__ import annotations
+
+import atexit
+import json
+import shutil
+import subprocess
+import sys
+import threading
+import urllib.error
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional, Set, Tuple
+
+PYPI_URL = "https://pypi.org/pypi/runpod/json"
+INSTALL_TIMEOUT_SECONDS = 120
+CACHE_PATH = Path.home() / ".runpod" / "update_check.json"
+CHECK_INTERVAL_HOURS = 24
+
+_newer_version: Optional[str] = None
+_check_done = threading.Event()
+_started = False
+_start_lock = threading.Lock()
+
+
+def current_version() -> str:
+ try:
+ from runpod import __version__
+
+ return __version__
+ except Exception: # noqa: BLE001 - version must never break the cli
+ return "unknown"
+
+
+def parse_version(version: str) -> Tuple[int, ...]:
+ """leading numeric release segments of a version string.
+
+ dev/local suffixes (e.g. 1.8.0.dev3) are ignored so a dev build of
+ the next release does not count as older than the current one.
+ """
+ parts = []
+ for part in version.split("."):
+ if not part.isdigit():
+ break
+ parts.append(int(part))
+ return tuple(parts)
+
+
+def compare_versions(a: Tuple[int, ...], b: Tuple[int, ...]) -> int:
+ """negative when a < b, zero when equal, positive when a > b."""
+ width = max(len(a), len(b))
+ a_padded = a + (0,) * (width - len(a))
+ b_padded = b + (0,) * (width - len(b))
+ return (a_padded > b_padded) - (a_padded < b_padded)
+
+
+def fetch_pypi_metadata() -> Tuple[str, Set[str]]:
+ """(latest version, all release versions) from pypi."""
+ try:
+ with urllib.request.urlopen(PYPI_URL, timeout=15) as resp: # noqa: S310
+ data = json.loads(resp.read().decode())
+ except urllib.error.URLError as exc:
+ if isinstance(exc, urllib.error.HTTPError):
+ raise RuntimeError(
+ f"pypi returned HTTP {exc.code}; try again later"
+ ) from exc
+ raise ConnectionError(
+ "could not reach pypi; check your network connection"
+ ) from exc
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ raise RuntimeError("pypi returned an unexpected response") from exc
+
+ try:
+ latest = data["info"]["version"]
+ except (KeyError, TypeError) as exc:
+ raise RuntimeError("pypi response missing version info") from exc
+ return latest, set(data.get("releases", {}).keys())
+
+
+def install_command(version: str) -> list:
+ """the installer invocation targeting the running interpreter.
+
+ `uv pip install` is used only with an explicit --python pointing at
+ this interpreter: bare uv resolves against an activated venv, which
+ is not necessarily where this cli runs from.
+ """
+ package_spec = f"runpod=={version}"
+ if shutil.which("uv"):
+ return [
+ "uv",
+ "pip",
+ "install",
+ "--python",
+ sys.executable,
+ package_spec,
+ "--quiet",
+ ]
+ return [sys.executable, "-m", "pip", "install", package_spec, "--quiet"]
+
+
+def run_install(version: str) -> None:
+ cmd = install_command(version)
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=INSTALL_TIMEOUT_SECONDS,
+ )
+ if result.returncode != 0:
+ installer = "uv" if cmd[0] == "uv" else "pip"
+ raise RuntimeError(
+ f"{installer} install failed (exit {result.returncode}): "
+ f"{result.stderr.strip()}"
+ )
+
+
+# -- passive background check -------------------------------------------
+
+
+def _read_cache() -> Optional[dict]:
+ try:
+ return json.loads(CACHE_PATH.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError, ValueError):
+ return None
+
+
+def _write_cache(latest_version: str) -> None:
+ try:
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ CACHE_PATH.write_text(
+ json.dumps(
+ {
+ "last_checked_utc": datetime.now(
+ timezone.utc
+ ).isoformat(),
+ "latest_version": latest_version,
+ }
+ ),
+ encoding="utf-8",
+ )
+ except OSError:
+ # cache write is best-effort; the check reruns next invocation
+ pass
+
+
+def _cache_fresh(cache: dict) -> bool:
+ try:
+ last_checked = datetime.fromisoformat(cache["last_checked_utc"])
+ elapsed = (
+ datetime.now(timezone.utc) - last_checked
+ ).total_seconds() / 3600
+ return elapsed < CHECK_INTERVAL_HOURS
+ except (KeyError, ValueError, TypeError):
+ return False
+
+
+def _run_check() -> None:
+ global _newer_version # noqa: PLW0603
+ try:
+ current = current_version()
+ if current == "unknown":
+ return
+
+ cache = _read_cache()
+ latest = None
+ if cache and _cache_fresh(cache):
+ latest = cache.get("latest_version") or None
+ if not latest:
+ latest, _ = fetch_pypi_metadata()
+ _write_cache(latest)
+ if not latest:
+ return
+
+ if compare_versions(parse_version(latest), parse_version(current)) > 0:
+ _newer_version = latest
+ except Exception: # noqa: BLE001 - the check must never crash the cli
+ pass
+ finally:
+ _check_done.set()
+
+
+def _print_update_notice() -> None:
+ # plain text: atexit runs after rich teardown
+ if not _check_done.is_set():
+ return
+ if _newer_version:
+ print(
+ f"\na new version of runpod is available: {_newer_version}\n"
+ " run 'rp update' to upgrade.",
+ file=sys.stderr,
+ )
+
+
+def _is_interactive() -> bool:
+ for stream in (sys.stderr, sys.stdout):
+ try:
+ if stream is not None and stream.isatty():
+ return True
+ except Exception: # noqa: BLE001
+ pass
+ return False
+
+
+def start_background_check() -> None:
+ """spawn the daemon check thread once per process (ttys only)."""
+ global _started # noqa: PLW0603
+ with _start_lock:
+ if _started:
+ return
+ _started = True
+ if not _is_interactive():
+ return
+ atexit.register(_print_update_notice)
+ threading.Thread(target=_run_check, daemon=True).start()
diff --git a/runpod/runtimes/README.md b/runpod/runtimes/README.md
new file mode 100644
index 00000000..d2675e3c
--- /dev/null
+++ b/runpod/runtimes/README.md
@@ -0,0 +1,75 @@
+# runtimes
+
+worker runtime scripts and the docker images built from them. images
+are built and pushed by the `runtimes` github actions workflow.
+
+## cold-start model
+
+deployed workers do no dependency resolution. `rp deploy` vendors the
+full runtime environment (the runpod package plus every python
+dependency, resolved for the worker platform) into the build artifact
+under `env/`. at cold start the bootstrap:
+
+1. **locate** the app tree: a host-provided pre-unpacked directory if
+ available, else extract the artifact tarball once (marker file
+ short-circuits warm restarts)
+2. **attach** `env/` and the source tree to the worker's PYTHONPATH,
+ vendored env first so it wins over image packages
+3. **verify** size-excluded packages (torch and friends) exist in the
+ image, installing only if genuinely absent (loudly, as a fallback)
+4. **serve** exec the worker runtime
+
+torch-family packages are excluded from artifacts by size and expected
+from the gpu worker images; the exclusion list is recorded in the
+manifest as `excludedPackages`.
+
+if any phase fails, the worker does not crash-loop silently: queue
+workers answer jobs with a structured `BootstrapError`, api workers
+answer http requests with it.
+
+live mode (`rp dev`) has no artifact; source arrives per request and
+the bootstrap only ensures the runtime package is importable (a no-op
+on the baked images).
+
+## images
+
+every runtime kind ships a cpu and a gpu variant per python version.
+gpu variants build on `runpod/gpu-base:pyX.Y-{tag}` (python:X.Y-slim
+plus the torch family), which preinstalls exactly the packages the
+build auto-excludes from artifacts. the two lists must stay in sync:
+`SIZE_PROHIBITIVE_PACKAGES` in `runpod/apps/build.py` and the pip
+install in `gpu-base/Dockerfile`.
+
+| image | base | use |
+|---|---|---|
+| `runpod/gpu-base:py{3.10-3.14}-{tag}` | `python:X.Y-slim` + torch | shared gpu base |
+| `runpod/queue:py{3.10-3.14}-{tag}` | `python:X.Y-slim` | cpu queue endpoints |
+| `runpod/queue-gpu:py{3.10-3.14}-{tag}` | gpu-base | gpu queue endpoints |
+| `runpod/api:py{3.10-3.14}-{tag}` | `python:X.Y-slim` | cpu api endpoints |
+| `runpod/api-gpu:py{3.10-3.14}-{tag}` | gpu-base | gpu api endpoints |
+| `runpod/task:py{3.10-3.14}-{tag}` | `python:X.Y-slim` | cpu tasks |
+| `runpod/task-gpu:py{3.10-3.14}-{tag}` | gpu-base | gpu tasks |
+
+image selection lives in `runpod.apps.images`; dev sessions and tasks
+pick the variant matching the local python so pickled payloads stay
+compatible.
+
+custom images work everywhere: the sdk injects `bootstrap.py` (queue/
+api) or `runner.py` (task) base64-encoded in an env var and boots it
+via `dockerArgs`. both scripts are stdlib-only, so any image with a
+python3 binary works. for deployed resources the vendored env then
+provides everything else, including the runpod package itself. builds
+targeting custom images vendor the torch family too (no auto
+exclusion), since only the builtin gpu images guarantee it.
+
+## task
+
+single-shot task runner for `@app.task` pods. one pod runs one
+function: the sdk deploys a pod, waits for `/ping`, posts a
+`FunctionRequest` to `/execute` (or `/submit` for spawn), collects the
+result, and terminates the pod.
+
+the wire protocol (`FunctionRequest` / `FunctionResponse`) is defined
+in `runpod.apps.protocol`; the runner and sdk must stay in sync with
+it. `runner.py` deliberately avoids importing the runpod package so it
+can run standalone on arbitrary images.
diff --git a/runpod/runtimes/__init__.py b/runpod/runtimes/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/runpod/runtimes/api/Dockerfile b/runpod/runtimes/api/Dockerfile
new file mode 100644
index 00000000..ded53a68
--- /dev/null
+++ b/runpod/runtimes/api/Dockerfile
@@ -0,0 +1,29 @@
+# api (load-balanced) worker image. BASE_IMAGE is python:X.Y-slim for
+# cpu or runpod/gpu-base:pyX.Y-* for gpu (adds the torch family).
+ARG BASE_IMAGE=python:3.12-slim
+FROM ${BASE_IMAGE}
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ PYTHONUNBUFFERED=1 \
+ RUNPOD_RUNTIME_KIND=api
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git ca-certificates \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# dependency layer: cached until requirements.txt changes, so code-only
+# edits skip the full dependency install
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir -r /tmp/requirements.txt cloudpickle "uvicorn>=0.30" \
+ && rm /tmp/requirements.txt
+
+# install the runpod package (worker loop + runtimes) from the build
+# context. the context has no .git, so setuptools-scm needs a pretend
+# version
+COPY . /src
+RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD=0.0.0.dev0 \
+ pip install --no-cache-dir --no-deps /src && rm -rf /src
+
+EXPOSE 80
+CMD ["python", "-m", "runpod.runtimes.bootstrap"]
diff --git a/runpod/runtimes/api/__init__.py b/runpod/runtimes/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/runpod/runtimes/api/server.py b/runpod/runtimes/api/server.py
new file mode 100644
index 00000000..e1d0c42c
--- /dev/null
+++ b/runpod/runtimes/api/server.py
@@ -0,0 +1,321 @@
+"""generic api (load-balanced) server for app endpoints.
+
+serves an asgi app on the port runpod's load balancer routes to
+(PORT env, default 80), with /ping kept healthy for LB health checks.
+
+two serving modes, chosen at startup:
+
+deployed mode (rp deploy):
+ the build artifact is unpacked at RUNPOD_APP_DIR and
+ FLASH_RESOURCE_NAME identifies this resource. the server imports
+ the user's module, finds the ApiHandle, and builds the asgi app:
+ - class-based api: instantiate the class, run its @init method
+ before /ping reports healthy, mount each @get/@post route
+ - asgi factory: call the factory, serve what it returns
+
+live mode (rp dev):
+ no artifact. the client pushes the user's module source to
+ /_runpod/sync (once per source change); the matching api class is
+ materialized from it and serves the real routes, so dev requests
+ hit the same handlers a deployed worker would. /execute remains
+ for FunctionRequest payloads.
+"""
+
+import importlib
+import inspect
+import json
+import logging
+import os
+import sys
+from typing import Any, Optional
+
+log = logging.getLogger("runpod.runtimes.api")
+
+APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app")
+MANIFEST_NAME = "runpod_manifest.json"
+PORT = int(os.environ.get("PORT", "80"))
+
+
+def _resource_name() -> str:
+ return os.environ.get("FLASH_RESOURCE_NAME") or os.environ.get(
+ "RUNPOD_RESOURCE_NAME", ""
+ )
+
+
+def _is_deployed() -> bool:
+ return bool(_resource_name()) and os.path.isfile(
+ os.path.join(APP_DIR, MANIFEST_NAME)
+ )
+
+
+def _load_api_handle():
+ """import the user's module and return the ApiHandle for this resource."""
+ with open(os.path.join(APP_DIR, MANIFEST_NAME)) as f:
+ manifest = json.load(f)
+
+ name = _resource_name()
+ entry = next(
+ (r for r in manifest.get("resources", []) if r.get("name") == name),
+ None,
+ )
+ if entry is None:
+ raise RuntimeError(
+ f"resource '{name}' not in manifest "
+ f"(has: {[r.get('name') for r in manifest.get('resources', [])]})"
+ )
+
+ if APP_DIR not in sys.path:
+ sys.path.insert(0, APP_DIR)
+ module = importlib.import_module(entry["module"])
+
+ from runpod.apps.handles import ApiHandle
+
+ for attr in vars(module).values():
+ if isinstance(attr, ApiHandle) and attr.spec.name == name:
+ return attr
+ raise RuntimeError(
+ f"no @app.api handle named '{name}' found in module '{entry['module']}'"
+ )
+
+
+async def _maybe_await(value: Any) -> Any:
+ if inspect.isawaitable(value):
+ return await value
+ return value
+
+
+def _build_class_app(handle) -> Any:
+ """construct a fastapi app from an ApiHandle's decorated class.
+
+ the class is instantiated once per worker; @init runs before /ping
+ reports healthy so the LB only routes to ready workers.
+ """
+ from contextlib import asynccontextmanager
+
+ from fastapi import FastAPI, Request
+
+ cls = handle._cls
+ instance = cls()
+ ready = {"ok": False}
+
+ @asynccontextmanager
+ async def lifespan(_app):
+ if handle._init_name:
+ await _maybe_await(getattr(instance, handle._init_name)())
+ ready["ok"] = True
+ yield
+
+ app = FastAPI(title=handle.spec.name, lifespan=lifespan)
+
+ @app.get("/ping")
+ async def ping():
+ from fastapi.responses import JSONResponse
+
+ if not ready["ok"]:
+ return JSONResponse({"status": "initializing"}, status_code=204)
+ return {"status": "healthy"}
+
+ _mount_routes(app, handle, instance)
+ return app
+
+
+def _mount_routes(app: Any, handle, instance) -> None:
+ """add each @get/@post route of an ApiHandle's class to a fastapi app."""
+ from fastapi import Request
+
+ for route in handle.spec.routes:
+ method = getattr(route, "method", None) or route["method"]
+ path = getattr(route, "path", None) or route["path"]
+ handler_name = (
+ getattr(route, "handler_name", None) or route["handler"]
+ )
+ bound = getattr(instance, handler_name)
+
+ def make_endpoint(fn):
+ async def endpoint(request: Request):
+ body = None
+ if request.method in ("POST", "PUT", "PATCH", "DELETE"):
+ try:
+ body = await request.json()
+ except Exception: # noqa: BLE001 - empty/non-json body
+ body = None
+ if body is not None:
+ return await _maybe_await(fn(body))
+ return await _maybe_await(fn())
+
+ return endpoint
+
+ app.add_api_route(
+ path, make_endpoint(bound), methods=[method], name=handler_name
+ )
+
+
+def _build_factory_app(handle) -> Any:
+ """call the user's asgi factory and ensure /ping exists.
+
+ fastapi/starlette apps get a /ping route added when missing; other
+ asgi callables are wrapped so /ping is answered before delegating.
+ """
+ app = handle._asgi_factory()
+
+ routes = getattr(app, "routes", None)
+ if routes is not None and hasattr(app, "get"):
+ if not any(getattr(r, "path", None) == "/ping" for r in routes):
+
+ @app.get("/ping")
+ async def ping():
+ return {"status": "healthy"}
+
+ return app
+
+ async def with_ping(scope, receive, send):
+ if scope["type"] == "http" and scope["path"] == "/ping":
+ body = b'{"status": "healthy"}'
+ await send(
+ {
+ "type": "http.response.start",
+ "status": 200,
+ "headers": [
+ (b"content-type", b"application/json"),
+ (b"content-length", str(len(body)).encode()),
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": body})
+ return
+ await app(scope, receive, send)
+
+ return with_ping
+
+
+def _install_live_dependencies(request: dict) -> None:
+ """install a live resource's dependencies before its module runs.
+
+ dev workers boot on the bare runtime image; deployed workers get
+ dependencies baked into the build artifact, so live mode installs
+ them at sync time to match.
+ """
+ from runpod.runtimes.executor import _install, _install_system
+
+ error = _install_system(request.get("system_dependencies"))
+ if error:
+ raise RuntimeError(error)
+ error = _install(request.get("dependencies"), "dependencies")
+ if error:
+ raise RuntimeError(error)
+
+
+async def _materialize_live_api(source: str, resource: str) -> Any:
+ """exec shipped module source and build the api app for a resource.
+
+ @init runs here, before the app is swapped in, so the first
+ routed request already sees initialized state.
+ """
+ from fastapi import FastAPI
+
+ from runpod.apps.handles import ApiHandle
+ from runpod.runtimes.executor import _materialize_source
+
+ path = _materialize_source(source)
+ namespace: dict = {"__name__": "__runpod_live__", "__file__": path}
+ exec(compile(source, path, "exec"), namespace) # noqa: S102
+
+ handle = None
+ for value in namespace.values():
+ if isinstance(value, ApiHandle) and value.spec.name == resource:
+ handle = value
+ break
+ if handle is None:
+ raise RuntimeError(
+ f"no @app.api resource named '{resource}' in shipped module"
+ )
+ if handle._cls is None:
+ return _build_factory_app(handle)
+
+ instance = handle._cls()
+ if handle._init_name:
+ await _maybe_await(getattr(instance, handle._init_name)())
+ app = FastAPI(title=f"{resource} (live)")
+ _mount_routes(app, handle, instance)
+ return app
+
+
+class _LiveDispatcher:
+ """asgi front for dev sessions.
+
+ the client pushes module source to /_runpod/sync; the api app is
+ materialized from it (rebuilt when the source hash changes) and
+ serves every route. /ping and /execute always work.
+ """
+
+ def __init__(self):
+ self._core = self._build_core()
+ self._inner: Any = None
+ self._hash: Optional[str] = None
+
+ def _build_core(self) -> Any:
+ import hashlib
+
+ from fastapi import FastAPI
+
+ app = FastAPI(title="runpod-live-api")
+
+ @app.get("/ping")
+ async def ping():
+ return {"status": "healthy"}
+
+ @app.post("/execute")
+ async def execute(request: dict):
+ from runpod.runtimes.executor import execute_request
+
+ return execute_request(request.get("input", request))
+
+ @app.post("/_runpod/sync")
+ async def sync(request: dict):
+ source = request.get("source") or ""
+ resource = request.get("resource") or ""
+ digest = hashlib.sha256(source.encode()).hexdigest()
+ if digest != self._hash:
+ _install_live_dependencies(request)
+ self._inner = await _materialize_live_api(source, resource)
+ self._hash = digest
+ return {"status": "synced", "hash": digest}
+
+ return app
+
+ async def __call__(self, scope, receive, send):
+ if scope["type"] != "http":
+ return await self._core(scope, receive, send)
+ path = scope.get("path", "")
+ if path in ("/ping", "/execute", "/_runpod/sync") or self._inner is None:
+ return await self._core(scope, receive, send)
+ return await self._inner(scope, receive, send)
+
+
+def _build_live_app() -> Any:
+ return _LiveDispatcher()
+
+
+def build_app() -> Any:
+ if _is_deployed():
+ handle = _load_api_handle()
+ if handle._cls is not None:
+ return _build_class_app(handle)
+ return _build_factory_app(handle)
+ return _build_live_app()
+
+
+def main() -> None:
+ import uvicorn
+
+ uvicorn.run(
+ build_app(),
+ host="0.0.0.0",
+ port=PORT,
+ timeout_keep_alive=600,
+ log_level="info",
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/runpod/runtimes/bootstrap.py b/runpod/runtimes/bootstrap.py
new file mode 100644
index 00000000..1ba19a77
--- /dev/null
+++ b/runpod/runtimes/bootstrap.py
@@ -0,0 +1,443 @@
+"""stdlib-only entrypoint for app workers.
+
+cold starts do no dependency resolution: the build artifact ships a
+vendored environment (env/ inside the artifact) containing the runpod
+runtime and every python dependency, resolved at deploy time for the
+worker platform. this entrypoint only has to make that environment
+visible and exec the worker.
+
+deployed mode (FLASH_RESOURCE_NAME set):
+ locate find the app tree: a pre-unpacked directory if the host
+ provides one, else extract the artifact tarball once
+ attach put {app} and {app}/env at the front of PYTHONPATH
+ verify packages the build excluded by size (torch and friends)
+ must come from the image; genuinely absent ones are
+ installed as a fallback, loudly
+ system apt packages from the resource's system_dependencies;
+ these cannot ride in the artifact, so this is the one
+ install that legitimately remains at cold start
+ serve exec the worker runtime from the vendored environment
+
+live mode (no resource name, `rp dev`):
+ no artifact exists; source arrives per request. the runtime images
+ bake the runpod package in, custom images get it installed here.
+ serve exec the worker runtime
+
+if any phase fails the worker does not crash-loop silently: a minimal
+job-take loop answers every job with a structured BootstrapError so
+the failure surfaces in job responses (queue endpoints), or an http
+server answers every request with the error (api endpoints).
+
+stdlib only: this file runs on the image's python before any
+environment exists. it is baked into the runtime images as CMD and
+injected into custom images via RUNPOD_BOOTSTRAP_B64 + dockerArgs.
+"""
+
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import tarfile
+import time
+import urllib.request
+
+ARTIFACT_PATH = os.environ.get(
+ "FLASH_BUILD_ARTIFACT_PATH", "/root/.runpod/artifact.tar.gz"
+)
+# a host-provided, already-unpacked app tree; when present, unpacking
+# is skipped entirely
+PREBUILT_APP_DIR = os.environ.get("RUNPOD_PREBUILT_APP_DIR", "")
+APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app")
+ENV_SUBDIR = "env"
+ARTIFACT_WAIT_SECONDS = int(os.environ.get("RUNPOD_ARTIFACT_WAIT", "300"))
+MANIFEST_NAME = "runpod_manifest.json"
+UNPACK_MARKER = ".rp-unpacked"
+
+WORKER_MODULES = {
+ "queue": "runpod.runtimes.queue.worker",
+ "api": "runpod.runtimes.api.server",
+}
+
+
+def _log(message):
+ sys.stderr.write(f"[bootstrap] {message}\n")
+ sys.stderr.flush()
+
+
+class PhaseError(Exception):
+ def __init__(self, phase, detail):
+ self.phase = phase
+ self.detail = detail
+ super().__init__(f"{phase}: {detail}")
+
+
+def _runtime_kind():
+ return os.environ.get("RUNPOD_RUNTIME_KIND", "queue")
+
+
+def _resource_name():
+ return os.environ.get("FLASH_RESOURCE_NAME") or os.environ.get(
+ "RUNPOD_RESOURCE_NAME", ""
+ )
+
+
+# ---------------------------------------------------------------- locate
+
+
+def _locate():
+ """return the app tree root, unpacking the artifact if needed."""
+ if PREBUILT_APP_DIR and os.path.isdir(PREBUILT_APP_DIR):
+ _log(f"using pre-unpacked app tree at {PREBUILT_APP_DIR}")
+ return PREBUILT_APP_DIR
+
+ marker = os.path.join(APP_DIR, UNPACK_MARKER)
+ if os.path.isfile(marker):
+ _log(f"app tree already unpacked at {APP_DIR}")
+ return APP_DIR
+
+ deadline = time.monotonic() + ARTIFACT_WAIT_SECONDS
+ while not os.path.isfile(ARTIFACT_PATH):
+ if time.monotonic() >= deadline:
+ raise PhaseError(
+ "locate",
+ f"artifact not found at {ARTIFACT_PATH} after "
+ f"{ARTIFACT_WAIT_SECONDS}s; is this endpoint part of an "
+ f"app deployment?",
+ )
+ time.sleep(2)
+
+ os.makedirs(APP_DIR, exist_ok=True)
+ target = os.path.realpath(APP_DIR)
+ started = time.monotonic()
+ try:
+ with tarfile.open(ARTIFACT_PATH, mode="r:*") as tar:
+ if hasattr(tarfile, "data_filter"):
+ # 3.12+: the data filter rejects traversal, devices,
+ # and absolute paths
+ tar.extractall(path=APP_DIR, filter="data")
+ else:
+ members = tar.getmembers()
+ for member in members:
+ dest = os.path.realpath(
+ os.path.join(APP_DIR, member.name)
+ )
+ if not dest.startswith(target + os.sep) and dest != target:
+ raise PhaseError(
+ "locate", f"unsafe tar member path: {member.name}"
+ )
+ # links can point outside and later members write
+ # through them; artifacts never contain links
+ if member.issym() or member.islnk():
+ raise PhaseError(
+ "locate",
+ f"unsafe tar link member: {member.name}",
+ )
+ if not (member.isfile() or member.isdir()):
+ raise PhaseError(
+ "locate",
+ f"unsupported tar member type: {member.name}",
+ )
+ tar.extractall(path=APP_DIR, members=members) # noqa: S202 - members validated above
+ except (OSError, tarfile.TarError) as exc:
+ raise PhaseError("locate", f"failed to extract artifact: {exc}")
+
+ with open(marker, "w") as f:
+ f.write(str(time.time()))
+ _log(
+ f"artifact extracted to {APP_DIR} "
+ f"in {time.monotonic() - started:.1f}s"
+ )
+ return APP_DIR
+
+
+# ---------------------------------------------------------------- attach
+
+
+def _attach(app_dir):
+ """paths that must lead sys.path in the worker process.
+
+ the vendored env comes first so the artifact's runpod (matched to
+ the client that built it) wins over anything baked into the image.
+ """
+ env_dir = os.path.join(app_dir, ENV_SUBDIR)
+ paths = [app_dir]
+ if os.path.isdir(env_dir):
+ paths.insert(0, env_dir)
+ else:
+ _log(
+ f"artifact has no vendored env at {env_dir}; "
+ f"falling back to image packages"
+ )
+ return paths
+
+
+def _manifest(app_dir):
+ path = os.path.join(app_dir, MANIFEST_NAME)
+ if not os.path.isfile(path):
+ raise PhaseError("attach", f"manifest not found at {path}")
+ with open(path) as f:
+ return json.load(f)
+
+
+def _worker_importable(paths):
+ """can the worker module be imported with these paths leading?"""
+ module = WORKER_MODULES[_runtime_kind()]
+ probe = (
+ "import sys; sys.path[:0] = sys.argv[1:]; "
+ f"import importlib; importlib.import_module({module!r})"
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", probe, *paths],
+ capture_output=True,
+ text=True,
+ )
+ return result.returncode == 0, result.stderr[-2000:]
+
+
+# ---------------------------------------------------------------- verify
+
+
+def _pip_install(packages, phase):
+ env = dict(os.environ)
+ # tarball specs have no .git for setuptools-scm version inference
+ env.setdefault("SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD", "0.0.0.dev0")
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "-q", "--upgrade", *packages],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if result.returncode != 0:
+ raise PhaseError(phase, result.stderr[-3000:])
+
+
+def _resource_entry(manifest):
+ name = _resource_name()
+ for entry in manifest.get("resources", []):
+ if entry.get("name") == name:
+ return entry
+ return {}
+
+
+def _install_system(manifest):
+ """apt packages cannot be vendored; install this resource's
+ system_dependencies at cold start."""
+ packages = _resource_entry(manifest).get("systemDependencies") or []
+ if not packages:
+ return
+ import shutil
+
+ if shutil.which("apt-get") is None:
+ raise PhaseError(
+ "system",
+ f"system dependencies {packages} requested but apt-get is "
+ f"not available in this image; use a debian-based image or "
+ f"bake them in",
+ )
+ env = dict(os.environ, DEBIAN_FRONTEND="noninteractive")
+ _log(f"installing system dependencies: {packages}")
+ update = subprocess.run(
+ ["apt-get", "update", "-qq"], capture_output=True, text=True, env=env
+ )
+ if update.returncode != 0:
+ raise PhaseError("system", f"apt-get update failed: {update.stderr[-2000:]}")
+ result = subprocess.run(
+ ["apt-get", "install", "-y", "-qq", "--no-install-recommends", *packages],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if result.returncode != 0:
+ raise PhaseError(
+ "system",
+ f"failed to install system dependencies {packages}: "
+ f"{result.stderr[-2000:]}",
+ )
+
+
+def _verify_excluded(manifest):
+ """packages the build excluded must come from the image.
+
+ a miss means the image was swapped for one without them; install as
+ a fallback so the worker still comes up, but say so loudly because
+ it re-adds the cold-start cost the exclusion existed to avoid.
+ """
+ excluded = manifest.get("excludedPackages") or []
+ missing = [
+ name
+ for name in excluded
+ if importlib.util.find_spec(name.replace("-", "_")) is None
+ ]
+ if not missing:
+ return
+ _log(
+ f"WARNING: excluded packages {missing} are not in this image; "
+ f"installing at cold start. use an image that provides them "
+ f"(e.g. runpod/pytorch) to avoid this cost."
+ )
+ _pip_install(missing, "verify")
+
+
+# ---------------------------------------------------------------- serve
+
+
+def _serve(paths):
+ """exec the worker runtime with the environment paths leading."""
+ module = WORKER_MODULES[_runtime_kind()]
+ env = dict(os.environ)
+ existing = env.get("PYTHONPATH", "")
+ env["PYTHONPATH"] = os.pathsep.join(
+ paths + ([existing] if existing else [])
+ )
+ if paths:
+ env["RUNPOD_APP_DIR"] = paths[-1]
+ os.execve(
+ sys.executable,
+ [sys.executable, "-m", module],
+ env,
+ )
+
+
+# ------------------------------------------------------------- live mode
+
+
+def _ensure_runtime_installed():
+ """live mode on a custom image: the runtime must be importable.
+
+ baked runtime images make this a no-op. --upgrade matters: the
+ image may carry an older runpod without the runtimes modules, and
+ a plain install would no-op against it.
+ """
+ ok, _ = _worker_importable([])
+ if ok:
+ return
+ spec = os.environ.get("RUNPOD_PACKAGE_SPEC", "runpod")
+ _log(f"worker runtime not in image, installing {spec}")
+ packages = [spec, "cloudpickle"]
+ if _runtime_kind() == "api":
+ packages.append("uvicorn>=0.30")
+ _pip_install(packages, "runtime")
+ ok, err = _worker_importable([])
+ if not ok:
+ raise PhaseError(
+ "runtime",
+ f"installed {spec} but the worker runtime is still not "
+ f"importable: {err}. set RUNPOD_PACKAGE_SPEC to a version "
+ f"that includes runpod.runtimes.",
+ )
+
+
+# ----------------------------------------------------------- error surface
+
+
+def _error_payload(error):
+ return {
+ "error_type": "BootstrapError",
+ "error_message": (
+ f"worker bootstrap failed during '{error.phase}': {error.detail}"
+ ),
+ }
+
+
+def _queue_error_loop(error):
+ """answer queue jobs with the bootstrap error."""
+ get_url = os.environ.get("RUNPOD_WEBHOOK_GET_JOB", "")
+ post_url = os.environ.get("RUNPOD_WEBHOOK_POST_OUTPUT", "")
+ api_key = os.environ.get("RUNPOD_AI_API_KEY", "")
+ if not get_url or not post_url:
+ _log("no job webhook env; exiting with error")
+ sys.exit(1)
+
+ payload = json.dumps({"error": json.dumps(_error_payload(error))}).encode()
+
+ _log("starting error-reporting loop")
+ while True:
+ try:
+ req = urllib.request.Request(
+ get_url, headers={"Authorization": api_key}
+ )
+ with urllib.request.urlopen(req, timeout=90) as resp:
+ if resp.status != 200:
+ time.sleep(2)
+ continue
+ job = json.loads(resp.read() or b"{}")
+ job_id = job.get("id")
+ if not job_id:
+ continue
+ done = urllib.request.Request(
+ post_url.replace("$ID", job_id),
+ data=payload,
+ headers={
+ "Authorization": api_key,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ method="POST",
+ )
+ urllib.request.urlopen(done, timeout=30)
+ _log(f"reported bootstrap error for job {job_id}")
+ except Exception as exc: # noqa: BLE001 - loop must survive
+ _log(f"error loop: {exc}")
+ time.sleep(5)
+
+
+def _api_error_server(error):
+ """answer every http request with the bootstrap error."""
+ from http.server import BaseHTTPRequestHandler, HTTPServer
+
+ body = json.dumps(_error_payload(error)).encode()
+ port = int(os.environ.get("PORT", "80"))
+
+ class Handler(BaseHTTPRequestHandler):
+ def _respond(self):
+ self.send_response(500)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _respond
+
+ def log_message(self, *args):
+ pass
+
+ _log(f"starting error-reporting http server on :{port}")
+ HTTPServer(("0.0.0.0", port), Handler).serve_forever()
+
+
+def _report_error(error):
+ _log(f"FAILED {error}")
+ if _runtime_kind() == "api":
+ _api_error_server(error)
+ else:
+ _queue_error_loop(error)
+
+
+# ------------------------------------------------------------------ main
+
+
+def main():
+ try:
+ if _resource_name():
+ app_dir = _locate()
+ paths = _attach(app_dir)
+ manifest = _manifest(app_dir)
+ _verify_excluded(manifest)
+ _install_system(manifest)
+ ok, err = _worker_importable(paths)
+ if not ok:
+ raise PhaseError(
+ "attach",
+ f"worker runtime not importable from the artifact "
+ f"env or the image: {err}",
+ )
+ _serve(paths)
+ else:
+ _ensure_runtime_installed()
+ _serve([])
+ except PhaseError as exc:
+ _report_error(exc)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/runpod/runtimes/executor.py b/runpod/runtimes/executor.py
new file mode 100644
index 00000000..bb1c7b62
--- /dev/null
+++ b/runpod/runtimes/executor.py
@@ -0,0 +1,291 @@
+"""shared FunctionRequest execution engine.
+
+runs one function from shipped source: installs dependencies, execs the
+module, resolves the function, and serializes the result per the
+request's serialization_format. every runtime that executes live
+requests (task pods, queue workers in dev mode, api servers) drives
+this same engine.
+
+stdlib-only (cloudpickle optional, required only for the cloudpickle
+serialization format) so it can ship as part of a single-file bootstrap
+onto any python image.
+"""
+
+import base64
+import inspect
+import io
+import json
+import os
+import shutil
+import subprocess
+import sys
+import traceback
+from contextlib import redirect_stdout
+
+
+def _load_cloudpickle(install: bool = False):
+ try:
+ import cloudpickle
+
+ return cloudpickle
+ except ImportError:
+ if not install:
+ return None
+ # bare images (custom image= without the runtime baked in) may lack
+ # cloudpickle; install it on demand the same way dependencies are
+ if _install(["cloudpickle"], "cloudpickle"):
+ return None
+ import cloudpickle
+
+ return cloudpickle
+
+
+def _install(packages, label):
+ if not packages:
+ return None
+ result = subprocess.run(
+ [sys.executable, "-m", "pip", "install", "-q", *packages],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ return f"failed to install {label}: {result.stderr[-2000:]}"
+ return None
+
+
+_apt_updated = False
+
+
+def _install_system(packages):
+ """install apt packages; requires a debian-family image with root."""
+ global _apt_updated
+ if not packages:
+ return None
+ if shutil.which("apt-get") is None:
+ return (
+ f"system dependencies {packages} requested but apt-get is not "
+ f"available in this image; use a debian-based image or bake "
+ f"them in"
+ )
+ env = dict(os.environ, DEBIAN_FRONTEND="noninteractive")
+ if not _apt_updated:
+ update = subprocess.run(
+ ["apt-get", "update", "-qq"], capture_output=True, text=True, env=env
+ )
+ if update.returncode != 0:
+ return f"apt-get update failed: {update.stderr[-2000:]}"
+ _apt_updated = True
+ result = subprocess.run(
+ ["apt-get", "install", "-y", "-qq", "--no-install-recommends", *packages],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if result.returncode != 0:
+ return (
+ f"failed to install system dependencies {packages}: "
+ f"{result.stderr[-2000:]}"
+ )
+ return None
+
+
+def _deserialize_args(request):
+ fmt = request.get("serialization_format", "cloudpickle")
+ args = request.get("args") or []
+ kwargs = request.get("kwargs") or {}
+ if fmt == "json":
+ return list(args), dict(kwargs)
+ cloudpickle = _load_cloudpickle(install=True)
+ if cloudpickle is None:
+ raise RuntimeError("cloudpickle not available for argument deserialization")
+ args = [cloudpickle.loads(base64.b64decode(a)) for a in args]
+ kwargs = {k: cloudpickle.loads(base64.b64decode(v)) for k, v in kwargs.items()}
+ return args, kwargs
+
+
+def _serialize_result(result, fmt):
+ if fmt == "json":
+ # json-format requests promise json on the wire; failing here
+ # keeps dev behavior identical to a deployed endpoint
+ try:
+ json.dumps(result)
+ except (TypeError, ValueError) as exc:
+ raise TypeError(
+ f"return value must be json-serializable "
+ f"(this request uses the json wire format): {exc}"
+ ) from exc
+ return {"json_result": result}
+ cloudpickle = _load_cloudpickle(install=True)
+ if cloudpickle is None:
+ return {"json_result": result}
+ return {"result": base64.b64encode(cloudpickle.dumps(result)).decode("utf-8")}
+
+
+def resolve_request(request):
+ """prepare one FunctionRequest for execution.
+
+ installs dependencies, execs the shipped source, and resolves the
+ target function. returns ((fn, args, kwargs), None) on success or
+ (None, response_dict) on failure.
+ """
+ error = _install_system(request.get("system_dependencies"))
+ if error:
+ return None, {"success": False, "error": error}
+ error = _install(request.get("dependencies"), "dependencies")
+ if error:
+ return None, {"success": False, "error": error}
+
+ function_name = request.get("function_name")
+ function_code = request.get("function_code")
+ if not function_name or not function_code:
+ return None, {
+ "success": False,
+ "error": "function_name and function_code are required",
+ }
+
+ # exec mirrors deployed-mode module import: the code is the
+ # user's full module, so __name__ is set like an import would
+ # (main guards stay inert) and decorated handles resolve to
+ # their wrapped functions. the source is written to a real
+ # file first so inspect.getsource works inside the function
+ # (nested .remote() calls re-extract sibling source)
+ source_path = _materialize_source(function_code)
+ namespace = {"__name__": "__runpod_live__", "__file__": source_path}
+ code_obj = compile(function_code, source_path, "exec")
+ exec(code_obj, namespace) # noqa: S102 - that is the job
+ if function_name not in namespace:
+ return None, {
+ "success": False,
+ "error": f"function '{function_name}' not found in provided code",
+ }
+ fn = namespace[function_name]
+ fn = getattr(fn, "_fn", fn)
+
+ args, kwargs = _deserialize_args(request)
+ return (fn, args, kwargs), None
+
+
+def serialize_chunk(chunk, request):
+ """wrap one generator chunk in the response envelope.
+
+ the __stream__ marker lets clients tell a stream of chunks apart
+ from a single aggregated function response.
+ """
+ response = {"success": True, "__stream__": True}
+ response.update(
+ _serialize_result(
+ chunk, request.get("serialization_format", "cloudpickle")
+ )
+ )
+ return response
+
+
+def execute_request(request):
+ """run one FunctionRequest to completion, returning a response dict."""
+ stdout_io = io.StringIO()
+ try:
+ prepared, error_response = resolve_request(request)
+ if error_response is not None:
+ return error_response
+ fn, args, kwargs = prepared
+
+ # stdout is teed: captured for the job response and written
+ # through to the real stdout so container logs (and live log
+ # streaming in dev sessions) carry the function's prints.
+ # sys.stderr carries the runner's own request logs from other
+ # threads, which must not leak into job output
+ with redirect_stdout(_Tee(stdout_io, sys.__stdout__)):
+ result = fn(*args, **kwargs)
+ if hasattr(result, "__await__"):
+ result = _run_awaitable(result)
+ # generators aggregate so live .remote() matches the
+ # deployed worker's return_aggregate_stream output
+ elif inspect.isgenerator(result):
+ result = list(result)
+ elif inspect.isasyncgen(result):
+ result = _run_awaitable(_drain_async_gen(result))
+
+ response = {"success": True, "stdout": stdout_io.getvalue()}
+ response.update(
+ _serialize_result(
+ result, request.get("serialization_format", "cloudpickle")
+ )
+ )
+ return response
+ except Exception: # noqa: BLE001 - all failures go over the wire
+ return {
+ "success": False,
+ "error": traceback.format_exc(),
+ "stdout": stdout_io.getvalue(),
+ }
+
+
+def _materialize_source(function_code):
+ """persist request source to a stable path for inspect/linecache."""
+ import hashlib
+ import tempfile
+
+ digest = hashlib.sha256(function_code.encode()).hexdigest()[:16]
+ path = os.path.join(
+ tempfile.gettempdir(), f"runpod_live_{digest}.py"
+ )
+ if not os.path.exists(path):
+ with open(path, "w") as f:
+ f.write(function_code)
+ return path
+
+
+class _Tee(io.TextIOBase):
+ """write-through to multiple streams, flushing eagerly so log
+ followers see lines as they happen."""
+
+ def __init__(self, *streams):
+ self._streams = [s for s in streams if s is not None]
+
+ def write(self, s):
+ for stream in self._streams:
+ try:
+ stream.write(s)
+ stream.flush()
+ except (ValueError, OSError):
+ # one sink failing (closed pipe) must not lose the
+ # write on the other sinks
+ pass
+ return len(s)
+
+ def flush(self):
+ for stream in self._streams:
+ try:
+ stream.flush()
+ except (ValueError, OSError):
+ # closed sinks are skipped, same as write
+ pass
+
+
+def _run_awaitable(awaitable):
+ """drive an awaitable to completion from sync code.
+
+ task pods run this on plain threads, but queue workers in live mode
+ call execute_request from inside the serverless event loop, where
+ asyncio.run would raise; a private loop on a helper thread covers
+ both.
+ """
+ import asyncio
+
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(_await(awaitable))
+
+ import concurrent.futures
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+ return pool.submit(asyncio.run, _await(awaitable)).result()
+
+
+async def _drain_async_gen(agen):
+ return [chunk async for chunk in agen]
+
+
+async def _await(awaitable):
+ return await awaitable
diff --git a/runpod/runtimes/gpu-base/Dockerfile b/runpod/runtimes/gpu-base/Dockerfile
new file mode 100644
index 00000000..5bc0d2c9
--- /dev/null
+++ b/runpod/runtimes/gpu-base/Dockerfile
@@ -0,0 +1,29 @@
+# shared gpu base for the runtime images: python:X.Y-slim plus the
+# torch family. this preinstalled set must stay in sync with
+# SIZE_PROHIBITIVE_PACKAGES in runpod/apps/build.py: deploys targeting
+# builtin gpu images exclude exactly these packages from artifacts and
+# expect the image to provide them. torch cuda wheels bundle the cuda
+# runtime; the host injects the driver.
+ARG PYTHON_VERSION=3.12
+FROM python:${PYTHON_VERSION}-slim
+
+# nvidia container runtime keys on these to inject the host driver;
+# without them torch sees no gpu even on a gpu machine
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ PYTHONUNBUFFERED=1 \
+ NVIDIA_VISIBLE_DEVICES=all \
+ NVIDIA_DRIVER_CAPABILITIES=compute,utility
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# cu128 wheels: default pypi torch is cu130, which needs 580+ drivers
+# that much of the fleet does not have yet. cu128 runs on 12.8+ drivers.
+# pinned + extra-index (not index-url): the cuda index lacks common
+# deps like typing-extensions, and the pin prevents drifting back to
+# pypi's cu130 build.
+RUN pip install --no-cache-dir \
+ "torch==2.9.1+cu128" "torchvision==0.24.1+cu128" "torchaudio==2.9.1+cu128" \
+ --extra-index-url https://download.pytorch.org/whl/cu128
diff --git a/runpod/runtimes/queue/Dockerfile b/runpod/runtimes/queue/Dockerfile
new file mode 100644
index 00000000..bbaecee6
--- /dev/null
+++ b/runpod/runtimes/queue/Dockerfile
@@ -0,0 +1,28 @@
+# queue worker image. BASE_IMAGE is python:X.Y-slim for cpu or
+# runpod/gpu-base:pyX.Y-* for gpu (adds the torch family).
+ARG BASE_IMAGE=python:3.12-slim
+FROM ${BASE_IMAGE}
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ PYTHONUNBUFFERED=1 \
+ RUNPOD_RUNTIME_KIND=queue
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git ca-certificates \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# dependency layer: cached until requirements.txt changes, so code-only
+# edits skip the full dependency install
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir -r /tmp/requirements.txt cloudpickle \
+ && rm /tmp/requirements.txt
+
+# install the runpod package (worker loop + runtimes) from the build
+# context. the context has no .git, so setuptools-scm needs a pretend
+# version
+COPY . /src
+RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD=0.0.0.dev0 \
+ pip install --no-cache-dir --no-deps /src && rm -rf /src
+
+CMD ["python", "-m", "runpod.runtimes.bootstrap"]
diff --git a/runpod/runtimes/queue/__init__.py b/runpod/runtimes/queue/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/runpod/runtimes/queue/worker.py b/runpod/runtimes/queue/worker.py
new file mode 100644
index 00000000..72f2857b
--- /dev/null
+++ b/runpod/runtimes/queue/worker.py
@@ -0,0 +1,192 @@
+"""generic queue worker for app endpoints.
+
+two serving modes, chosen at startup:
+
+deployed mode (rp deploy):
+ the build artifact is unpacked at RUNPOD_APP_DIR and
+ FLASH_RESOURCE_NAME identifies this resource. the worker imports
+ the user's module, resolves the decorated function from the
+ manifest, runs its @init hook, and serves jobs by calling the
+ function body directly. job input is plain kwargs; source never
+ travels with requests.
+
+live mode (rp dev):
+ no artifact. each job carries a FunctionRequest (source +
+ serialized args) which is executed via the shared engine in
+ runpod.runtimes.executor.
+"""
+
+import importlib
+import inspect
+import json
+import os
+import sys
+
+import runpod
+
+APP_DIR = os.environ.get("RUNPOD_APP_DIR", "/app")
+MANIFEST_NAME = "runpod_manifest.json"
+
+
+def _resource_name() -> str:
+ return os.environ.get("FLASH_RESOURCE_NAME") or os.environ.get(
+ "RUNPOD_RESOURCE_NAME", ""
+ )
+
+
+def _is_deployed() -> bool:
+ return bool(_resource_name()) and os.path.isfile(
+ os.path.join(APP_DIR, MANIFEST_NAME)
+ )
+
+
+def _load_deployed_handle():
+ """import the user's module and return the FunctionHandle for this
+ resource."""
+ with open(os.path.join(APP_DIR, MANIFEST_NAME)) as f:
+ manifest = json.load(f)
+
+ name = _resource_name()
+ entry = next(
+ (r for r in manifest.get("resources", []) if r.get("name") == name),
+ None,
+ )
+ if entry is None:
+ raise RuntimeError(
+ f"resource '{name}' not in manifest "
+ f"(has: {[r.get('name') for r in manifest.get('resources', [])]})"
+ )
+
+ if APP_DIR not in sys.path:
+ sys.path.insert(0, APP_DIR)
+ module = importlib.import_module(entry["module"])
+
+ from runpod.apps.handles import FunctionHandle
+
+ for attr in vars(module).values():
+ if (
+ isinstance(attr, FunctionHandle)
+ and attr.spec.name == name
+ ):
+ return attr
+ raise RuntimeError(
+ f"no @app.queue/@app.task handle named '{name}' found in "
+ f"module '{entry['module']}'"
+ )
+
+
+def _job_kwargs(job: dict) -> dict:
+ body = dict(job.get("input") or {})
+ body.pop("__empty", None)
+ return body
+
+
+def _make_deployed_handler(handle):
+ """serverless handler that calls the user function directly.
+
+ generator functions become generator handlers so the serverless
+ core streams partial outputs to /stream as they are yielded.
+ """
+ fn = handle._fn
+
+ if inspect.isasyncgenfunction(fn):
+
+ async def async_gen_handler(job: dict):
+ async for chunk in fn(**_job_kwargs(job)):
+ yield chunk
+
+ return async_gen_handler
+
+ if inspect.isgeneratorfunction(fn):
+
+ def gen_handler(job: dict):
+ yield from fn(**_job_kwargs(job))
+
+ return gen_handler
+
+ async def handler(job: dict) -> dict:
+ result = fn(**_job_kwargs(job))
+ if inspect.isawaitable(result):
+ result = await result
+ return result
+
+ return handler
+
+
+def _run_init(handle) -> None:
+ """run the resource's @init hook before serving."""
+ init_fn = getattr(handle, "_init_fn", None)
+ if init_fn is None:
+ return
+ result = init_fn()
+ if inspect.isawaitable(result):
+ import asyncio
+
+ asyncio.run(result)
+
+
+async def _live_handler(job: dict):
+ """execute a FunctionRequest, streaming when the function is a
+ generator so dev sessions behave exactly like deployed workers."""
+ from runpod.runtimes.executor import (
+ execute_request,
+ resolve_request,
+ serialize_chunk,
+ )
+
+ request = job.get("input") or {}
+ prepared, error_response = resolve_request(request)
+ if error_response is not None:
+ yield error_response
+ return
+ fn, args, kwargs = prepared
+
+ if inspect.isasyncgenfunction(fn):
+ async for chunk in fn(*args, **kwargs):
+ yield serialize_chunk(chunk, request)
+ return
+ if inspect.isgeneratorfunction(fn):
+ for chunk in fn(*args, **kwargs):
+ yield serialize_chunk(chunk, request)
+ return
+
+ yield execute_request(request)
+
+
+def _max_concurrency() -> int:
+ """jobs one worker may run at once (RUNPOD_MAX_CONCURRENCY, min 1)."""
+ try:
+ return max(1, int(os.environ.get("RUNPOD_MAX_CONCURRENCY", "1")))
+ except ValueError:
+ return 1
+
+
+def _worker_config(handler) -> dict:
+ config: dict = {"handler": handler}
+ from runpod.serverless.modules.rp_handler import is_generator
+
+ if is_generator(handler):
+ # generator jobs stream partials and still finish with a full
+ # output, so .remote()/result() work alongside .stream()
+ config["return_aggregate_stream"] = True
+ concurrency = _max_concurrency()
+ if concurrency > 1:
+ config["concurrency_modifier"] = lambda _current: concurrency
+ return config
+
+
+def main() -> None:
+ if _is_deployed():
+ handle = _load_deployed_handle()
+ _run_init(handle)
+ runpod.serverless.start(
+ _worker_config(_make_deployed_handler(handle))
+ )
+ else:
+ # the live handler is a generator so it can stream; aggregation
+ # keeps .remote() working for plain functions and generators alike
+ runpod.serverless.start(_worker_config(_live_handler))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/runpod/runtimes/task/Dockerfile b/runpod/runtimes/task/Dockerfile
new file mode 100644
index 00000000..1e981d9e
--- /dev/null
+++ b/runpod/runtimes/task/Dockerfile
@@ -0,0 +1,28 @@
+# task runner image. BASE_IMAGE is python:X.Y-slim for cpu or
+# runpod/gpu-base:pyX.Y-* for gpu (adds the torch family).
+ARG BASE_IMAGE=python:3.12-slim
+FROM ${BASE_IMAGE}
+
+ENV DEBIAN_FRONTEND=noninteractive \
+ TZ=Etc/UTC \
+ PYTHONUNBUFFERED=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git ca-certificates \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+# dependency layer: cached until requirements.txt changes, so code-only
+# edits skip the full dependency install
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir -r /tmp/requirements.txt cloudpickle \
+ && rm /tmp/requirements.txt
+
+# install the runpod package: task payloads carry the user's full module
+# source, which imports runpod (decorators, nested .remote calls).
+# the context has no .git, so setuptools-scm needs a pretend version
+COPY . /src
+RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_RUNPOD=0.0.0.dev0 \
+ pip install --no-cache-dir --no-deps /src && rm -rf /src
+
+EXPOSE 8080
+CMD ["python", "-m", "runpod.runtimes.task.runner"]
diff --git a/runpod/runtimes/task/__init__.py b/runpod/runtimes/task/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/runpod/runtimes/task/runner.py b/runpod/runtimes/task/runner.py
new file mode 100644
index 00000000..0b3e6d9c
--- /dev/null
+++ b/runpod/runtimes/task/runner.py
@@ -0,0 +1,218 @@
+"""single-shot task runner: the process a task pod boots into.
+
+a minimal http server speaking the FunctionRequest/FunctionResponse
+protocol, driving the shared execution engine in
+runpod.runtimes.executor.
+
+endpoints:
+ GET /ping readiness probe (unauthenticated)
+ POST /execute run a function, block, return the response
+ POST /submit start a function in the background, return immediately
+ GET /result status/result of the submitted job
+
+auth: every endpoint except /ping requires
+ Authorization: Bearer $RUNPOD_TASK_TOKEN
+
+one pod runs one function; the client terminates the pod after
+collecting the result.
+
+ships to bare images as a single file: the sdk concatenates the
+executor source above this module, so the import below is satisfied
+either way.
+"""
+
+import json
+import os
+import sys
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+try:
+ from runpod.runtimes.executor import execute_request
+except ImportError:
+ # single-file bootstrap: the executor source is concatenated above
+ # this module and its names are already in scope
+ pass
+
+PORT = int(os.environ.get("RUNPOD_TASK_PORT", "8080"))
+TOKEN = os.environ.get("RUNPOD_TASK_TOKEN", "")
+
+# watchdog: self-terminate when the client is clearly gone. RUNNING
+# jobs are never killed (terminateAfter is the runaway backstop);
+# NONE means the client died before submitting, DONE means the result
+# sat uncollected. normal flows poll every ~2s, so these never fire
+# for a live client.
+IDLE_TIMEOUT = float(os.environ.get("RUNPOD_TASK_IDLE_TIMEOUT", "600"))
+WATCHDOG_INTERVAL = 15.0
+
+# single background job slot for /submit + /result
+_job_lock = threading.Lock()
+_job_state = {"status": "NONE", "response": None}
+_last_contact = {"ts": None} # set at server start
+# inline /execute requests in flight; the watchdog must not terminate
+# the pod while one runs (long executes outlive the idle timeout)
+_inline_executions = {"count": 0}
+
+
+def _touch_contact():
+ import time
+
+ _last_contact["ts"] = time.time()
+
+
+def _should_self_terminate(status, last_contact, now, idle_timeout, inline=0):
+ """the watchdog decision: kill only provably-abandoned pods."""
+ if status == "RUNNING" or inline > 0:
+ return False
+ if last_contact is None:
+ return False
+ return (now - last_contact) > idle_timeout
+
+
+def _terminate_self():
+ """terminate this pod via the injected pod-scoped api key.
+
+ every pod carries a RUNPOD_API_KEY scoped to itself; podTerminate
+ with it removes the pod entirely. exiting the process is the
+ fallback (stops the workload; terminateAfter finishes the job).
+ """
+ import json as _json
+ import urllib.request
+
+ pod_id = os.environ.get("RUNPOD_POD_ID")
+ api_key = os.environ.get("RUNPOD_API_KEY")
+ if pod_id and api_key:
+ try:
+ api_base = os.environ.get(
+ "RUNPOD_API_BASE_URL", "https://api.runpod.io"
+ )
+ payload = _json.dumps(
+ {
+ "query": (
+ "mutation podTerminate($input: PodTerminateInput!) "
+ "{ podTerminate(input: $input) }"
+ ),
+ "variables": {"input": {"podId": pod_id}},
+ }
+ ).encode()
+ request = urllib.request.Request(
+ f"{api_base}/graphql",
+ data=payload,
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {api_key}",
+ },
+ )
+ urllib.request.urlopen(request, timeout=30) # noqa: S310
+ sys.stderr.write("[task-runner] self-terminated (abandoned)\n")
+ except Exception: # noqa: BLE001 - fall through to process exit
+ pass
+ os._exit(0)
+
+
+def _watchdog():
+ import time
+
+ while True:
+ time.sleep(WATCHDOG_INTERVAL)
+ if _should_self_terminate(
+ _job_state["status"],
+ _last_contact["ts"],
+ time.time(),
+ IDLE_TIMEOUT,
+ inline=_inline_executions["count"],
+ ):
+ _terminate_self()
+
+
+class Handler(BaseHTTPRequestHandler):
+ def _send(self, code, payload):
+ body = json.dumps(payload).encode("utf-8")
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _authed(self):
+ header = self.headers.get("Authorization", "")
+ authed = TOKEN and header == f"Bearer {TOKEN}"
+ if authed:
+ # any authenticated contact proves the client is alive
+ _touch_contact()
+ return authed
+
+ def _read_request(self):
+ length = int(self.headers.get("Content-Length", "0"))
+ return json.loads(self.rfile.read(length) or b"{}")
+
+ def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler api
+ if self.path == "/ping":
+ self._send(200, {"ready": True})
+ return
+ if self.path == "/result":
+ if not self._authed():
+ self._send(401, {"error": "unauthorized"})
+ return
+ with _job_lock:
+ self._send(
+ 200,
+ {
+ "status": _job_state["status"],
+ "response": _job_state["response"],
+ },
+ )
+ return
+ self._send(404, {"error": "not found"})
+
+ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler api
+ if not self._authed():
+ self._send(401, {"error": "unauthorized"})
+ return
+ if self.path == "/execute":
+ with _job_lock:
+ _inline_executions["count"] += 1
+ try:
+ self._send(200, execute_request(self._read_request()))
+ finally:
+ with _job_lock:
+ _inline_executions["count"] -= 1
+ return
+ if self.path == "/submit":
+ request = self._read_request()
+ with _job_lock:
+ if _job_state["status"] == "RUNNING":
+ self._send(409, {"error": "a job is already running"})
+ return
+ _job_state["status"] = "RUNNING"
+ _job_state["response"] = None
+
+ def run():
+ response = execute_request(request)
+ with _job_lock:
+ _job_state["status"] = "DONE"
+ _job_state["response"] = response
+
+ threading.Thread(target=run, daemon=True).start()
+ self._send(200, {"status": "RUNNING"})
+ return
+ self._send(404, {"error": "not found"})
+
+ def log_message(self, format, *args): # noqa: A002 - stdlib signature
+ # request logging is noise in container logs (dev sessions
+ # stream them as the function's output)
+ pass
+
+
+def main() -> None:
+ if not TOKEN:
+ sys.stderr.write("[task-runner] RUNPOD_TASK_TOKEN not set, exiting\n")
+ sys.exit(1)
+ _touch_contact()
+ threading.Thread(target=_watchdog, daemon=True).start()
+ server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
+ server.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/runpod/serverless/utils/rp_upload.py b/runpod/serverless/utils/rp_upload.py
index d4e9a015..407400fb 100644
--- a/runpod/serverless/utils/rp_upload.py
+++ b/runpod/serverless/utils/rp_upload.py
@@ -19,9 +19,16 @@
from boto3.s3.transfer import TransferConfig
from botocore.client import BaseClient
+# configured on this logger only: basicConfig would install a root
+# handler for the entire process at import time
logger = logging.getLogger("runpod upload utility")
FMT = "%(filename)-20s:%(lineno)-4d %(asctime)s %(message)s"
-logging.basicConfig(level=logging.INFO, format=FMT, handlers=[logging.StreamHandler()])
+if not logger.handlers:
+ _handler = logging.StreamHandler()
+ _handler.setFormatter(logging.Formatter(FMT))
+ logger.addHandler(_handler)
+ logger.setLevel(logging.INFO)
+ logger.propagate = False
def _import_boto3_dependencies():
diff --git a/setup.py b/setup.py
index 6fbfa776..7f8a076e 100644
--- a/setup.py
+++ b/setup.py
@@ -64,7 +64,12 @@
"serverless/binaries/README.md",
]
},
- entry_points={"console_scripts": ["runpod = runpod.cli.entry:runpod_cli"]},
+ entry_points={
+ "console_scripts": [
+ "runpod = runpod.rp_cli.main:run",
+ "rp = runpod.rp_cli.main:run",
+ ]
+ },
keywords=[
"runpod",
"ai",
diff --git a/tests/e2e/apps_live_smoke.py b/tests/e2e/apps_live_smoke.py
new file mode 100644
index 00000000..e7395830
--- /dev/null
+++ b/tests/e2e/apps_live_smoke.py
@@ -0,0 +1,54 @@
+"""manual e2e smoke test for the apps live path.
+
+provisions a real cpu dev endpoint, executes a function remotely via
+the live source-per-request protocol, and tears the endpoint down.
+requires credentials in ~/.runpod/config.toml or RUNPOD_API_KEY.
+
+run directly, not via pytest:
+ python tests/e2e/apps_live_smoke.py
+"""
+
+import asyncio
+import os
+import sys
+
+os.environ["RUNPOD_DEV_SESSION"] = "1"
+
+from runpod.apps import App # noqa: E402
+from runpod.apps.dev import DevSession # noqa: E402
+
+app = App("apps-e2e-v2")
+
+
+@app.queue(name="smoke", cpu="cpu3c-1-2", workers=(0, 1))
+def smoke(x: int, y: int):
+ return {"sum": x + y, "python": True}
+
+
+async def main() -> int:
+ session = DevSession([app])
+ print("provisioning dev endpoint ...")
+ await session.start()
+ try:
+ print("invoking smoke.remote(2, 3) ...")
+ result = await smoke.remote.aio(2, 3)
+ print(f"result: {result}")
+ assert result == {"sum": 5, "python": True}, f"unexpected result: {result}"
+
+ print("refreshing session (generation bump, workers recreated) ...")
+ await session.refresh([app])
+ assert session.generation == 2
+
+ print("invoking smoke.remote(10, 20) post-refresh ...")
+ result = await smoke.remote.aio(10, 20)
+ print(f"result: {result}")
+ assert result == {"sum": 30, "python": True}, f"unexpected result: {result}"
+ print("PASS")
+ return 0
+ finally:
+ print("cleaning up ...")
+ await session.stop()
+
+
+if __name__ == "__main__":
+ sys.exit(asyncio.run(main()))
diff --git a/tests/e2e/examples/01_hello_world.py b/tests/e2e/examples/01_hello_world.py
new file mode 100644
index 00000000..fc14acaa
--- /dev/null
+++ b/tests/e2e/examples/01_hello_world.py
@@ -0,0 +1,26 @@
+"""the smallest possible app: one queue function, one remote call.
+
+ rp dev tests/e2e/examples/01_hello_world.py --once
+"""
+
+import runpod
+from runpod import App
+
+app = App("ex-hello")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def hello(name: str):
+ print(f"saying hello to {name}")
+ return f"hello {name}"
+
+
+@runpod.local_entrypoint
+def main():
+ result = hello.remote("world")
+ print("result:", result)
+ assert result == "hello world"
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/02_invocation_styles.py b/tests/e2e/examples/02_invocation_styles.py
new file mode 100644
index 00000000..9aeb5219
--- /dev/null
+++ b/tests/e2e/examples/02_invocation_styles.py
@@ -0,0 +1,41 @@
+"""every way to call a function: remote, async, spawn, local.
+
+ rp dev tests/e2e/examples/02_invocation_styles.py --once
+"""
+
+import asyncio
+
+import runpod
+from runpod import App
+
+app = App("ex-invoke")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def square(x: int):
+ return x * x
+
+
+@runpod.local_entrypoint
+def main():
+ # sync remote call, blocks for the result
+ assert square.remote(4) == 16
+
+ # async variant, for use inside event loops
+ async def go():
+ return await square.remote.aio(5)
+
+ assert asyncio.run(go()) == 25
+
+ # fire and forget -> collect later
+ job = square.spawn(6)
+ assert job.result() == 36
+
+ # run in this process, no cloud involved
+ assert square.local(7) == 49
+
+ print("all four invocation styles ok")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/03_gpu.py b/tests/e2e/examples/03_gpu.py
new file mode 100644
index 00000000..055cd9e7
--- /dev/null
+++ b/tests/e2e/examples/03_gpu.py
@@ -0,0 +1,37 @@
+"""gpu selection: shorthand names resolve to real devices.
+
+ rp dev tests/e2e/examples/03_gpu.py --once
+"""
+
+import runpod
+from runpod import App
+
+app = App("ex-gpu")
+
+
+@app.queue(gpu="4090", env={"MODE": "example"})
+def cuda_info(prompt: str):
+ import os
+
+ import torch
+
+ print(f"prompt: {prompt}")
+ return {
+ "cuda": torch.cuda.is_available(),
+ "device": torch.cuda.get_device_name(0),
+ "torch": str(torch.__version__),
+ "mode": os.environ.get("MODE"),
+ }
+
+
+@runpod.local_entrypoint
+def main():
+ info = cuda_info.remote("hello gpu")
+ print("info:", info)
+ assert info["cuda"] is True
+ assert "4090" in info["device"]
+ assert info["mode"] == "example"
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/04_dependencies.py b/tests/e2e/examples/04_dependencies.py
new file mode 100644
index 00000000..4c06728e
--- /dev/null
+++ b/tests/e2e/examples/04_dependencies.py
@@ -0,0 +1,38 @@
+"""pip and apt dependencies, available on the worker without a custom image.
+
+ rp dev tests/e2e/examples/04_dependencies.py --once
+"""
+
+import runpod
+from runpod import App
+
+app = App("ex-deps")
+
+
+@app.queue(cpu="cpu3c-1-2", dependencies=["pyfiglet"])
+def banner(word: str):
+ import pyfiglet
+
+ art = pyfiglet.figlet_format(word)
+ print(art)
+ return len(art)
+
+
+@app.queue(cpu="cpu3c-1-2", system_dependencies=["jq"])
+def jq_version():
+ import subprocess
+
+ out = subprocess.run(["jq", "--version"], capture_output=True, text=True)
+ return out.stdout.strip()
+
+
+@runpod.local_entrypoint
+def main():
+ assert banner.remote("hi") > 0
+ version = jq_version.remote()
+ print("jq:", version)
+ assert version.startswith("jq-")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/05_api_service.py b/tests/e2e/examples/05_api_service.py
new file mode 100644
index 00000000..b918d3e2
--- /dev/null
+++ b/tests/e2e/examples/05_api_service.py
@@ -0,0 +1,45 @@
+"""a load-balanced http service from a class with route markers.
+
+ rp dev tests/e2e/examples/05_api_service.py --once
+"""
+
+import runpod
+from runpod import App, get, init, post
+
+app = App("ex-api")
+
+
+@app.api(cpu="cpu3c-1-2")
+class Counter:
+ @init
+ def setup(self):
+ # runs once per worker before it takes traffic
+ self.count = 0
+ print("counter initialized")
+
+ @post("/bump")
+ async def bump(self, body: dict):
+ self.count += body.get("by", 1)
+ return {"count": self.count}
+
+ @get("/value")
+ async def value(self):
+ return {"count": self.count}
+
+
+@runpod.local_entrypoint
+def main():
+ first = Counter.post("/bump", {"by": 3})
+ print("bump:", first)
+ assert first["count"] == 3
+
+ second = Counter.post("/bump", {"by": 2})
+ assert second["count"] == 5
+
+ value = Counter.get("/value")
+ print("value:", value)
+ assert value["count"] == 5
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/06_tasks.py b/tests/e2e/examples/06_tasks.py
new file mode 100644
index 00000000..af12fb34
--- /dev/null
+++ b/tests/e2e/examples/06_tasks.py
@@ -0,0 +1,35 @@
+"""tasks: one ephemeral pod per call, terminated when done.
+
+ rp dev tests/e2e/examples/06_tasks.py --once
+"""
+
+import runpod
+from runpod import App
+
+app = App("ex-tasks")
+
+
+@app.task(cpu="cpu3c-1-2")
+def multiply(x: int, y: int):
+ print(f"multiplying {x} * {y} on a dedicated pod")
+ return x * y
+
+
+@app.task(gpu="4090")
+def gpu_task():
+ import torch
+
+ print("checking cuda on a task pod")
+ return {"cuda": torch.cuda.is_available(), "torch": str(torch.__version__)}
+
+
+@runpod.local_entrypoint
+def main():
+ assert multiply.remote(6, 7) == 42
+ info = gpu_task.remote()
+ print("gpu task:", info)
+ assert info["cuda"] is True
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/07_pipelines.py b/tests/e2e/examples/07_pipelines.py
new file mode 100644
index 00000000..0fa0b801
--- /dev/null
+++ b/tests/e2e/examples/07_pipelines.py
@@ -0,0 +1,41 @@
+"""functions calling functions: workers invoke sibling resources remotely.
+
+ rp dev tests/e2e/examples/07_pipelines.py --once
+"""
+
+import runpod
+from runpod import App
+
+app = App("ex-pipeline")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def tokenize(text: str):
+ tokens = text.split()
+ print(f"tokenized into {len(tokens)} tokens")
+ return tokens
+
+
+@app.queue(cpu="cpu3c-1-2")
+def count(tokens: list):
+ return len(tokens)
+
+
+@app.queue(cpu="cpu3c-1-2")
+def pipeline(text: str):
+ # nested .remote() calls run on the sibling resources
+ tokens = tokenize.remote(text)
+ total = count.remote(tokens)
+ return {"tokens": tokens, "total": total}
+
+
+@runpod.local_entrypoint
+def main():
+ result = pipeline.remote("the quick brown fox")
+ print("pipeline:", result)
+ assert result["total"] == 4
+ assert result["tokens"][0] == "the"
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/08_volumes.py b/tests/e2e/examples/08_volumes.py
new file mode 100644
index 00000000..106acdab
--- /dev/null
+++ b/tests/e2e/examples/08_volumes.py
@@ -0,0 +1,43 @@
+"""shared network volume: one task writes, another reads.
+
+the volume is created on first use; placement co-locates both tasks
+in the volume's datacenter automatically.
+
+ rp dev tests/e2e/examples/08_volumes.py --once
+"""
+
+import runpod
+from runpod import App, Volume
+
+app = App("ex-volumes")
+
+scratch = Volume("ex-scratch", size=10)
+
+
+@app.task(cpu="cpu3c-1-2", volume=scratch)
+def write(content: str):
+ target = scratch.path / "message.txt"
+ target.write_text(content)
+ print(f"wrote {len(content)} bytes to {target}")
+ return str(target)
+
+
+@app.task(cpu="cpu3c-1-2", volume=scratch)
+def read():
+ target = scratch.path / "message.txt"
+ content = target.read_text()
+ print(f"read {len(content)} bytes from {target}")
+ return content
+
+
+@runpod.local_entrypoint
+def main():
+ message = "hello from the other pod"
+ write.remote(message)
+ result = read.remote()
+ print("read back:", result)
+ assert result == message
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/09_secrets.py b/tests/e2e/examples/09_secrets.py
new file mode 100644
index 00000000..f1717048
--- /dev/null
+++ b/tests/e2e/examples/09_secrets.py
@@ -0,0 +1,37 @@
+"""platform secrets injected as env vars, decrypted only on the worker.
+
+requires the secret to exist first:
+
+ rp secret add ex-demo-secret --value "s3cret"
+ rp dev tests/e2e/examples/09_secrets.py --once
+"""
+
+import runpod
+from runpod import App, Secret
+
+app = App("ex-secrets")
+
+
+@app.queue(cpu="cpu3c-1-2", env={"DEMO_TOKEN": Secret("ex-demo-secret"), "MODE": "example"})
+def peek():
+ import os
+
+ token = os.environ.get("DEMO_TOKEN", "")
+ # never print secrets; report shape only
+ return {
+ "mode": os.environ.get("MODE"),
+ "token_present": bool(token),
+ "token_length": len(token),
+ }
+
+
+@runpod.local_entrypoint
+def main():
+ result = peek.remote()
+ print("peek:", result)
+ assert result["token_present"] is True
+ assert result["mode"] == "example"
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/10_cached_models.py b/tests/e2e/examples/10_cached_models.py
new file mode 100644
index 00000000..95aae550
--- /dev/null
+++ b/tests/e2e/examples/10_cached_models.py
@@ -0,0 +1,37 @@
+"""platform-cached model weights: staged on the host before the worker starts.
+
+ rp dev tests/e2e/examples/10_cached_models.py --once
+"""
+
+import runpod
+from runpod import App, Model
+
+app = App("ex-models")
+
+tiny = Model("sshleifer/tiny-gpt2")
+
+
+@app.queue(gpu="4090", model=tiny, dependencies=["transformers"])
+def generate(prompt: str):
+ # weights are already on disk; no download happens here
+ from transformers import AutoModelForCausalLM, AutoTokenizer
+
+ tokenizer = AutoTokenizer.from_pretrained(str(tiny.path))
+ model = AutoModelForCausalLM.from_pretrained(str(tiny.path))
+ inputs = tokenizer(prompt, return_tensors="pt")
+ out = model.generate(**inputs, max_new_tokens=8)
+ text = tokenizer.decode(out[0])
+ print(f"generated: {text!r}")
+ return {"path": str(tiny.path), "text": text}
+
+
+@runpod.local_entrypoint
+def main():
+ result = generate.remote("hello")
+ print("result:", result)
+ assert result["path"].startswith("/runpod/model-store/huggingface/")
+ assert result["text"]
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/11_train_eval.py b/tests/e2e/examples/11_train_eval.py
new file mode 100644
index 00000000..022210a6
--- /dev/null
+++ b/tests/e2e/examples/11_train_eval.py
@@ -0,0 +1,73 @@
+"""a real workflow: train on one gpu pod, save to a volume, eval on another.
+
+ rp dev tests/e2e/examples/11_train_eval.py --once
+"""
+
+import runpod
+from runpod import App, Volume
+
+app = App("ex-train-eval")
+
+models = Volume("ex-models", size=10)
+
+
+@app.task(gpu="4090", volume=models)
+def train(steps: int = 200):
+ import json
+
+ import torch
+ import torch.nn as nn
+
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ torch.manual_seed(0)
+
+ # tiny regression problem: y = 3x + 1 with noise
+ x = torch.randn(2048, 1, device=device)
+ y = 3 * x + 1 + torch.randn_like(x) * 0.05
+
+ model = nn.Linear(1, 1).to(device)
+ opt = torch.optim.SGD(model.parameters(), lr=0.05)
+ for step in range(1, steps + 1):
+ loss = ((model(x) - y) ** 2).mean()
+ opt.zero_grad()
+ loss.backward()
+ opt.step()
+ if step % 50 == 0:
+ print(f"step {step}/{steps} loss={loss.item():.5f}")
+
+ run_dir = models.path / "linear-run"
+ run_dir.mkdir(parents=True, exist_ok=True)
+ torch.save(model.state_dict(), run_dir / "model.pt")
+ (run_dir / "meta.json").write_text(json.dumps({"steps": steps}))
+ print(f"saved to {run_dir}")
+ return {"checkpoint": "linear-run", "loss": round(loss.item(), 5)}
+
+
+@app.task(gpu="4090", volume=models)
+def evaluate(checkpoint: str):
+ import torch
+ import torch.nn as nn
+
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ model = nn.Linear(1, 1).to(device)
+ model.load_state_dict(
+ torch.load(models.path / checkpoint / "model.pt", map_location=device)
+ )
+ weight = model.weight.item()
+ bias = model.bias.item()
+ print(f"learned y = {weight:.3f}x + {bias:.3f}")
+ return {"weight": round(weight, 2), "bias": round(bias, 2)}
+
+
+@runpod.local_entrypoint
+def main():
+ out = train.remote()
+ print("train:", out)
+ fit = evaluate.remote(out["checkpoint"])
+ print("eval:", fit)
+ assert abs(fit["weight"] - 3.0) < 0.3
+ assert abs(fit["bias"] - 1.0) < 0.3
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/12_custom_image.py b/tests/e2e/examples/12_custom_image.py
new file mode 100644
index 00000000..15037d72
--- /dev/null
+++ b/tests/e2e/examples/12_custom_image.py
@@ -0,0 +1,28 @@
+"""a custom container image: the sdk bootstraps its runtime onto any
+image with python3.
+
+ rp dev tests/e2e/examples/12_custom_image.py --once
+"""
+
+import runpod
+from runpod import App
+
+app = App("ex-image")
+
+
+@app.queue(cpu="cpu3c-1-2", image="python:3.12-slim")
+def python_version():
+ import sys
+
+ return f"{sys.version_info.major}.{sys.version_info.minor}"
+
+
+@runpod.local_entrypoint
+def main():
+ version = python_version.remote()
+ print("worker python:", version)
+ assert version == "3.12"
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/13_streaming.py b/tests/e2e/examples/13_streaming.py
new file mode 100644
index 00000000..a2cb5215
--- /dev/null
+++ b/tests/e2e/examples/13_streaming.py
@@ -0,0 +1,56 @@
+"""streaming permutations: sync/async generators, every consumption style.
+
+ rp dev tests/e2e/examples/13_streaming.py --once
+"""
+
+import asyncio
+
+import runpod
+from runpod import App
+
+app = App("ex-stream")
+
+
+@app.queue(cpu="cpu3c-1-2")
+def count_up(n: int):
+ for i in range(n):
+ yield {"i": i}
+
+
+@app.queue(cpu="cpu3c-1-2")
+async def count_down(n: int):
+ for i in range(n, 0, -1):
+ yield i
+
+
+@runpod.local_entrypoint
+def main():
+ # sync iteration over a sync generator
+ chunks = list(count_up.stream(3))
+ assert chunks == [{"i": 0}, {"i": 1}, {"i": 2}], chunks
+
+ # async iteration over an async generator
+ async def consume():
+ return [c async for c in count_down.stream.aio(3)]
+
+ chunks = asyncio.run(consume())
+ assert chunks == [3, 2, 1], chunks
+
+ # .remote() on a generator aggregates every chunk
+ all_chunks = count_up.remote(2)
+ assert all_chunks == [{"i": 0}, {"i": 1}], all_chunks
+
+ # spawn -> reconnect-style streaming from the job handle
+ job = count_up.spawn(2)
+ chunks = list(job.stream())
+ assert chunks == [{"i": 0}, {"i": 1}], chunks
+ assert job.result() == [{"i": 0}, {"i": 1}]
+
+ # .local() still yields directly, no cloud involved
+ assert list(count_up.local(2)) == [{"i": 0}, {"i": 1}]
+
+ print("all streaming styles ok")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/e2e/examples/README.md b/tests/e2e/examples/README.md
new file mode 100644
index 00000000..d535d4f4
--- /dev/null
+++ b/tests/e2e/examples/README.md
@@ -0,0 +1,40 @@
+# e2e example suite
+
+self-asserting apps exercising every sdk feature against production.
+each file fails loudly when a result is wrong, so the set doubles as a
+release gate. every example carries both a `@runpod.local_entrypoint`
+and an `if __name__ == "__main__"` block, so the same `main()` runs in
+three modes:
+
+| mode | command | what it exercises |
+| ------ | -------------------- | --------------------------------------------- |
+| dev | `rp dev --once` | ephemeral endpoints, entrypoint, teardown |
+| deploy | `rp deploy ` | persistent endpoints for the app |
+| invoke | `python3 ` | `main()` against the deployed endpoints |
+
+`run_all.sh` runs every example through all three (the deploy phase does
+deploy -> invoke -> undeploy per file, leaving nothing standing):
+
+```bash
+./tests/e2e/examples/run_all.sh # everything, every mode
+./tests/e2e/examples/run_all.sh 01 07 # subset by prefix
+
+SKIP_DEV=1 ./tests/e2e/examples/run_all.sh # deploy/invoke only
+SKIP_DEPLOY=1 ./tests/e2e/examples/run_all.sh # dev only
+KEEP=1 ./tests/e2e/examples/run_all.sh # leave endpoints up
+```
+
+prerequisites:
+
+```bash
+rp login
+rp secret add ex-demo-secret --value anything # for 09_secrets
+```
+
+`12_custom_image` installs the runpod package at cold start; until the
+release with `runpod.runtimes` is on pypi, point the bootstrap at the
+branch tarball (plain https, not git+ — slim images have no git):
+
+```bash
+export RUNPOD_PACKAGE_SPEC=https://github.com/runpod/runpod-python/archive/refs/heads/feat/apps-sdk.tar.gz
+```
diff --git a/tests/e2e/examples/run_all.sh b/tests/e2e/examples/run_all.sh
new file mode 100755
index 00000000..61999155
--- /dev/null
+++ b/tests/e2e/examples/run_all.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+# run every example against prod as an end-to-end suite, in three modes.
+#
+# each example is a self-asserting app with both a
+# @runpod.local_entrypoint and an `if __name__ == "__main__"` block, so
+# the same main() runs under all three:
+#
+# dev rp dev --once ephemeral endpoints, run entrypoint, tear down
+# deploy rp deploy persistent endpoints for the app
+# invoke python3 run main() against the deployed endpoints
+#
+# the deploy phase runs deploy -> invoke -> undeploy per file, so no
+# endpoints are left standing. requires `rp login` and, for 09_secrets:
+#
+# rp secret add ex-demo-secret --value anything
+#
+# output streams live so you see the exact cli output.
+#
+# usage:
+# ./run_all.sh # every example, every mode
+# ./run_all.sh 01 05 08 # subset by prefix
+#
+# env toggles:
+# SKIP_DEV=1 skip the rp dev phase
+# SKIP_DEPLOY=1 skip the rp deploy/python3 phase
+# KEEP=1 leave deployed endpoints up (skip undeploy)
+
+set -uo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+files=()
+for f in "$HERE"/[0-9][0-9]_*.py; do
+ name="$(basename "$f")"
+ if [ "$#" -eq 0 ]; then
+ files+=("$f")
+ else
+ for pre in "$@"; do
+ if [[ "$name" == "$pre"* ]]; then
+ files+=("$f")
+ break
+ fi
+ done
+ fi
+done
+
+if [ "${#files[@]}" -eq 0 ]; then
+ echo "no examples match: $*" >&2
+ exit 1
+fi
+
+passed=()
+failed=()
+
+app_name_of() {
+ sed -nE 's/.*App\("([^"]+)".*/\1/p' "$1" | head -1
+}
+
+# run one step; stream output live, record pass/fail as " ".
+run_step() {
+ local mode="$1" name="$2"
+ shift 2
+ echo "==================================================================="
+ echo ">>> [$mode] $name"
+ echo "==================================================================="
+ local start rc elapsed
+ start=$(date +%s)
+ "$@"
+ rc=$?
+ elapsed=$(( $(date +%s) - start ))
+ if [ "$rc" -eq 0 ]; then
+ echo "<<< PASS [$mode] $name (${elapsed}s)"
+ passed+=("$mode $name")
+ else
+ echo "<<< FAIL [$mode] $name (${elapsed}s, exit $rc)"
+ failed+=("$mode $name")
+ fi
+ echo
+ return "$rc"
+}
+
+echo "running ${#files[@]} examples"
+echo
+
+# ---------------------------------------------------------------- dev
+if [ -z "${SKIP_DEV:-}" ]; then
+ for f in "${files[@]}"; do
+ run_step dev "$(basename "$f")" rp dev "$f" --once
+ done
+fi
+
+# ------------------------------------------------- deploy -> invoke -> undeploy
+if [ -z "${SKIP_DEPLOY:-}" ]; then
+ for f in "${files[@]}"; do
+ name="$(basename "$f")"
+ app="$(app_name_of "$f")"
+
+ if run_step deploy "$name" rp deploy "$f"; then
+ run_step invoke "$name" python3 "$f"
+ if [ -z "${KEEP:-}" ] && [ -n "$app" ]; then
+ echo ">>> cleanup: rp undeploy -a $app -y"
+ rp undeploy -a "$app" -y || echo "!!! cleanup failed for $app" >&2
+ echo
+ fi
+ fi
+ done
+fi
+
+# ------------------------------------------------------------- summary
+total=$(( ${#passed[@]} + ${#failed[@]} ))
+echo "==================================================================="
+echo "${#passed[@]}/${total} steps passed"
+if [ "${#failed[@]}" -gt 0 ]; then
+ for s in "${failed[@]}"; do
+ echo " FAIL $s"
+ done
+fi
+
+[ "${#failed[@]}" -eq 0 ]
diff --git a/tests/e2e/matrix/main.py b/tests/e2e/matrix/main.py
new file mode 100644
index 00000000..070022bc
--- /dev/null
+++ b/tests/e2e/matrix/main.py
@@ -0,0 +1,133 @@
+"""e2e matrix app: every resource kind, cross-calls, deps, custom images.
+
+deployed by tests/e2e/matrix/run.py; each resource exercises a distinct
+permutation of the surface.
+"""
+
+import runpod
+from runpod import App
+
+app = App("e2e-matrix")
+
+
+# -- queue permutations ------------------------------------------------
+
+@app.queue(name="q-basic", cpu="cpu3c-1-2", workers=(0, 1))
+def q_basic(x: int):
+ return {"doubled": x * 2}
+
+
+@app.queue(
+ name="q-deps",
+ cpu="cpu3c-2-4",
+ workers=(0, 1),
+ dependencies=["pyfiglet"],
+ system_dependencies=["jq"],
+)
+def q_deps(word: str):
+ import shutil
+ import pyfiglet
+
+ return {
+ "art": pyfiglet.figlet_format(word).splitlines()[0],
+ "pyfiglet": pyfiglet.__version__,
+ "jq": shutil.which("jq") is not None,
+ }
+
+
+@app.queue(
+ name="q-custom",
+ cpu="cpu3c-2-4",
+ workers=(0, 1),
+ image="python:3.12-slim",
+ dependencies=["humanize"],
+)
+def q_custom(n: int):
+ import humanize
+
+ return {"human": humanize.intword(n)}
+
+
+@app.queue(name="q-gpu", gpu=runpod.GpuGroup.ADA_24, workers=(0, 1))
+def q_gpu():
+ import torch
+
+ return {
+ "torch": torch.__version__,
+ "cuda": torch.cuda.is_available(),
+ "device": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
+ }
+
+
+# -- streaming ---------------------------------------------------------
+
+@app.queue(name="q-stream", cpu="cpu3c-1-2", workers=(0, 1))
+def q_stream(n: int):
+ for i in range(n):
+ yield {"i": i}
+
+
+@app.queue(name="q-stream-async", cpu="cpu3c-1-2", workers=(0, 1))
+async def q_stream_async(n: int):
+ for i in range(n, 0, -1):
+ yield i
+
+
+# -- cross-calls -------------------------------------------------------
+
+@app.queue(name="q-caller", cpu="cpu3c-2-4", workers=(0, 1))
+async def q_caller(x: int):
+ """queue worker fanning out to another queue and a task."""
+ doubled = await q_basic.remote.aio(x)
+ product = await t_mul.remote.aio(x, 10)
+ return {"from_queue": doubled, "from_task": product}
+
+
+# -- tasks -------------------------------------------------------------
+
+@app.task(name="t-mul", cpu="cpu3c-1-2")
+def t_mul(a: int, b: int):
+ return {"product": a * b}
+
+
+@app.task(name="t-gen", cpu="cpu3c-1-2")
+def t_gen(n: int):
+ for i in range(n):
+ yield i * i
+
+
+@app.task(name="t-gpu", gpu=runpod.GpuGroup.ADA_24)
+def t_gpu(size: int):
+ import torch
+
+ m = torch.rand(size, size, device="cuda")
+ return {"trace": float(m.trace()), "device": torch.cuda.get_device_name(0)}
+
+
+# -- api (load-balanced) -----------------------------------------------
+
+@app.api(name="a-svc", cpu="cpu3c-2-4", workers=(1, 1))
+class Svc:
+ @runpod.init
+ def setup(self):
+ self.counter = 0
+ self.ready = True
+
+ @runpod.post("/bump")
+ def bump(self, body: dict):
+ self.counter += int(body.get("by", 1))
+ return {"counter": self.counter, "ready": self.ready}
+
+ @runpod.get("/stats")
+ def stats(self):
+ return {"counter": self.counter}
+
+ @runpod.get("/tokens")
+ def tokens(self):
+ from fastapi.responses import StreamingResponse
+
+ def gen():
+ for word in ("alpha", "beta", "gamma"):
+ yield word + "\n"
+
+ return StreamingResponse(gen(), media_type="text/plain")
diff --git a/tests/e2e/matrix/run.py b/tests/e2e/matrix/run.py
new file mode 100644
index 00000000..125710c5
--- /dev/null
+++ b/tests/e2e/matrix/run.py
@@ -0,0 +1,203 @@
+"""drive the deployed e2e matrix. run AFTER `rp deploy tests/e2e/matrix`.
+
+ RUNPOD_RUNTIME_TAG=dev python tests/e2e/matrix/run.py [--only q-basic,...]
+
+each check is independent; failures are collected, not fatal, so one
+broken permutation doesn't hide the rest.
+"""
+
+import argparse
+import asyncio
+import sys
+import time
+import traceback
+
+from main import ( # noqa: E402
+ Svc,
+ app,
+ q_basic,
+ q_caller,
+ q_custom,
+ q_deps,
+ q_gpu,
+ q_stream,
+ q_stream_async,
+ t_gen,
+ t_gpu,
+ t_mul,
+)
+
+
+async def check_q_basic():
+ r = await q_basic.remote.aio(21)
+ assert r == {"doubled": 42}, r
+ return r
+
+
+async def check_q_deps():
+ r = await q_deps.remote.aio("hi")
+ assert r["pyfiglet"], r
+ assert r["jq"] is True, r
+ return {k: r[k] for k in ("pyfiglet", "jq")}
+
+
+async def check_q_custom():
+ r = await q_custom.remote.aio(1_500_000)
+ assert r == {"human": "1.5 million"}, r
+ return r
+
+
+async def check_q_gpu():
+ r = await q_gpu.remote.aio()
+ assert r["cuda"] is True, r
+ return r
+
+
+async def check_q_caller():
+ r = await q_caller.remote.aio(3)
+ assert r["from_queue"] == {"doubled": 6}, r
+ assert r["from_task"] == {"product": 30}, r
+ return r
+
+
+async def check_t_mul():
+ r = await t_mul.remote.aio(6, 7)
+ assert r == {"product": 42}, r
+ return r
+
+
+async def check_t_gpu():
+ r = await t_gpu.remote.aio(64)
+ assert "device" in r, r
+ return r
+
+
+async def check_api():
+ r1 = await Svc.post.aio("/bump", {"by": 5})
+ assert r1["counter"] >= 5 and r1["ready"] is True, r1
+ r2 = await Svc.get.aio("/stats")
+ assert r2["counter"] >= 5, r2
+ return {"bump": r1, "stats": r2}
+
+
+async def check_api_streaming_response():
+ # api routes may stream; the client delivers the body intact
+ r = await Svc.get.aio("/tokens")
+ assert r == "alpha\nbeta\ngamma\n", repr(r)
+ return {"body": r}
+
+
+async def check_spawn():
+ job = await q_basic.spawn.aio(4)
+ r = await job.result.aio()
+ assert r == {"doubled": 8}, r
+ return r
+
+
+async def check_job_ops():
+ job = await q_basic.spawn.aio(5)
+ status = await job.status.aio()
+ assert status, status
+ r = await job.result.aio()
+ assert r == {"doubled": 10}, r
+ # reconnect by id and read the terminal status
+ reconnected = await q_basic.job.aio(job.id)
+ status = await reconnected.status.aio()
+ assert status == "COMPLETED", status
+ return {"id": job.id, "status": status}
+
+
+async def check_stream():
+ chunks = [c async for c in q_stream.stream.aio(3)]
+ assert chunks == [{"i": 0}, {"i": 1}, {"i": 2}], chunks
+ return chunks
+
+
+async def check_stream_async_gen():
+ chunks = [c async for c in q_stream_async.stream.aio(3)]
+ assert chunks == [3, 2, 1], chunks
+ return chunks
+
+
+async def check_stream_aggregate():
+ r = await q_stream.remote.aio(2)
+ assert r == [{"i": 0}, {"i": 1}], r
+ return r
+
+
+async def check_stream_from_job():
+ job = await q_stream.spawn.aio(2)
+ chunks = [c async for c in job.stream.aio()]
+ assert chunks == [{"i": 0}, {"i": 1}], chunks
+ return chunks
+
+
+async def check_task_generator_aggregates():
+ r = await t_gen.remote.aio(4)
+ assert r == [0, 1, 4, 9], r
+ return r
+
+
+async def check_task_stream_rejected():
+ from runpod.apps.errors import InvalidResourceError
+
+ try:
+ async for _ in t_gen.stream.aio(2):
+ pass
+ except InvalidResourceError as exc:
+ assert "do not stream" in str(exc), exc
+ return {"rejected": True}
+ raise AssertionError("task .stream() should raise InvalidResourceError")
+
+
+CHECKS = {
+ "q-basic": check_q_basic,
+ "q-deps": check_q_deps,
+ "q-custom": check_q_custom,
+ "q-gpu": check_q_gpu,
+ "q-caller": check_q_caller,
+ "t-mul": check_t_mul,
+ "t-gpu": check_t_gpu,
+ "api": check_api,
+ "spawn": check_spawn,
+ "job-ops": check_job_ops,
+ "stream": check_stream,
+ "stream-async-gen": check_stream_async_gen,
+ "stream-aggregate": check_stream_aggregate,
+ "stream-from-job": check_stream_from_job,
+ "task-gen-aggregate": check_task_generator_aggregates,
+ "task-stream-rejected": check_task_stream_rejected,
+ "api-streaming-response": check_api_streaming_response,
+}
+
+
+async def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--only", help="comma-separated check names")
+ args = parser.parse_args()
+
+ names = args.only.split(",") if args.only else list(CHECKS)
+ results, failures = {}, {}
+
+ for name in names:
+ started = time.monotonic()
+ print(f"--- {name} ...", flush=True)
+ try:
+ results[name] = await CHECKS[name]()
+ elapsed = time.monotonic() - started
+ print(f" PASS {elapsed:.1f}s {results[name]}", flush=True)
+ except Exception as exc: # noqa: BLE001 - collect and report
+ elapsed = time.monotonic() - started
+ failures[name] = exc
+ print(f" FAIL {elapsed:.1f}s {exc}", flush=True)
+ traceback.print_exc()
+
+ print(f"\n{len(results)}/{len(names)} passed")
+ if failures:
+ print(f"failed: {', '.join(failures)}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(asyncio.run(main()))
diff --git a/tests/e2e/task_pod_smoke.py b/tests/e2e/task_pod_smoke.py
new file mode 100644
index 00000000..32b62854
--- /dev/null
+++ b/tests/e2e/task_pod_smoke.py
@@ -0,0 +1,37 @@
+"""manual e2e smoke test for @app.task pod execution.
+
+deploys a real cpu pod via the env-injection bootstrap (python:3.12-slim
+so the test does not depend on the runpod/task images existing yet),
+executes a function, and terminates the pod.
+
+run directly, not via pytest:
+ python tests/e2e/task_pod_smoke.py
+"""
+
+import asyncio
+import sys
+
+from runpod.apps import App
+
+app = App("task-e2e")
+
+
+@app.task(name="smoke", cpu="cpu3c-1-2", image="python:3.12-slim")
+def smoke(x: int, y: int):
+ import platform
+
+ return {"product": x * y, "python": platform.python_version()}
+
+
+async def main() -> int:
+ print("running smoke.remote(6, 7) (deploys pod, executes, terminates) ...")
+ result = await smoke.remote.aio(6, 7)
+ print(f"result: {result}")
+ assert result["product"] == 42, f"unexpected result: {result}"
+ assert result["python"].startswith("3.12")
+ print("PASS")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(asyncio.run(main()))
diff --git a/tests/test_apps/__init__.py b/tests/test_apps/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_apps/test_api_client.py b/tests/test_apps/test_api_client.py
new file mode 100644
index 00000000..095ad90f
--- /dev/null
+++ b/tests/test_apps/test_api_client.py
@@ -0,0 +1,375 @@
+"""unit tests for the apps control-plane client."""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import aiohttp
+import pytest
+
+from runpod.apps.api import AppsApiClient
+from runpod.error import QueryError
+
+
+def _respond(data):
+ return AsyncMock(return_value={"data": data})
+
+
+def _client_with(data):
+ client = AppsApiClient(api_key="test-key")
+ return client, patch(
+ "runpod.apps.api.run_graphql_query_async", _respond(data)
+ )
+
+
+class TestExecuteRetry:
+ async def test_returns_data(self):
+ client, patcher = _client_with({"ok": 1})
+ with patcher:
+ assert await client._execute("query {}") == {"ok": 1}
+
+ async def test_retries_transport_errors(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = AsyncMock(
+ side_effect=[
+ aiohttp.ClientError("reset"),
+ OSError("dns"),
+ {"data": {"ok": 1}},
+ ]
+ )
+ with (
+ patch("runpod.apps.api.run_graphql_query_async", transport),
+ patch("asyncio.sleep", AsyncMock()),
+ ):
+ assert await client._execute("query {}") == {"ok": 1}
+ assert transport.await_count == 3
+
+ async def test_exhausted_retries_raise(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = AsyncMock(side_effect=aiohttp.ClientError("down"))
+ with (
+ patch("runpod.apps.api.run_graphql_query_async", transport),
+ patch("asyncio.sleep", AsyncMock()),
+ ):
+ with pytest.raises(aiohttp.ClientError):
+ await client._execute("query {}")
+ assert transport.await_count == 4
+
+ async def test_graphql_errors_propagate_immediately(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = AsyncMock(side_effect=QueryError("bad query", "query {}"))
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ with pytest.raises(QueryError):
+ await client._execute("query {}")
+ assert transport.await_count == 1
+
+
+class TestEndpoints:
+ async def test_save_endpoint(self):
+ client, patcher = _client_with(
+ {"saveEndpoint": {"id": "ep1", "name": "chat"}}
+ )
+ with patcher:
+ result = await client.save_endpoint({"name": "chat"})
+ assert result["id"] == "ep1"
+
+ async def test_delete_endpoint(self):
+ client, patcher = _client_with({"deleteEndpoint": True})
+ with patcher:
+ assert await client.delete_endpoint("ep1") is True
+
+ async def test_list_my_endpoints(self):
+ client, patcher = _client_with(
+ {"myself": {"endpoints": [{"id": "ep1"}]}}
+ )
+ with patcher:
+ assert await client.list_my_endpoints() == [{"id": "ep1"}]
+
+
+class TestTaskPods:
+ async def test_deploy_gpu_pod(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond(
+ {"podFindAndDeployOnDemand": {"id": "pod1", "desiredStatus": "RUNNING"}}
+ )
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ result = await client.deploy_task_pod(
+ {"gpuTypeIdList": ["NVIDIA GeForce RTX 4090"]}, is_cpu=False
+ )
+ assert result["id"] == "pod1"
+
+ async def test_deploy_cpu_pod_converts_instance_ids(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond({"deployCpuPod": {"id": "pod2"}})
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ result = await client.deploy_task_pod(
+ {"instanceIds": ["cpu3c-2-4", "cpu3g-2-8"]}, is_cpu=True
+ )
+ assert result["id"] == "pod2"
+ sent = transport.call_args[1]["variables"]["input"]
+ assert sent["instanceId"] == "cpu3c-2-4"
+ assert "instanceIds" not in sent
+
+ async def test_terminate_pod(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond({"podTerminate": None})
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ await client.terminate_pod("pod1")
+ sent = transport.call_args[1]["variables"]["input"]
+ assert sent == {"podId": "pod1"}
+
+
+class TestAppLifecycle:
+ async def test_get_app_by_name(self):
+ client, patcher = _client_with(
+ {"flashAppByName": {"id": "app1", "name": "demo"}}
+ )
+ with patcher:
+ app = await client.get_app_by_name("demo")
+ assert app["id"] == "app1"
+
+ async def test_create_app(self):
+ client, patcher = _client_with(
+ {"createFlashApp": {"id": "app1", "name": "demo"}}
+ )
+ with patcher:
+ assert (await client.create_app("demo"))["id"] == "app1"
+
+ async def test_create_environment(self):
+ client, patcher = _client_with(
+ {"createFlashEnvironment": {"id": "env1", "name": "prod"}}
+ )
+ with patcher:
+ result = await client.create_environment("app1", "prod")
+ assert result["id"] == "env1"
+
+ async def test_list_apps(self):
+ client, patcher = _client_with(
+ {"myself": {"flashApps": [{"id": "app1"}]}}
+ )
+ with patcher:
+ assert await client.list_apps() == [{"id": "app1"}]
+
+ async def test_list_apps_empty(self):
+ client, patcher = _client_with({"myself": {"flashApps": None}})
+ with patcher:
+ assert await client.list_apps() == []
+
+ async def test_delete_app(self):
+ client, patcher = _client_with({"deleteFlashApp": True})
+ with patcher:
+ assert await client.delete_app("app1") is True
+
+ async def test_delete_environment(self):
+ client, patcher = _client_with({"deleteFlashEnvironment": True})
+ with patcher:
+ assert await client.delete_environment("env1") is True
+
+ async def test_get_environment_by_name(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = AsyncMock(
+ side_effect=[
+ {"data": {"flashAppByName": {"id": "app1"}}},
+ {"data": {"flashEnvironmentByName": {"id": "env1"}}},
+ ]
+ )
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ env = await client.get_environment_by_name("demo", "prod")
+ assert env["id"] == "env1"
+
+ async def test_get_environment_missing_app(self):
+ client, patcher = _client_with({"flashAppByName": None})
+ with patcher:
+ assert await client.get_environment_by_name("demo", "prod") is None
+
+ async def test_get_environment_not_found(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = AsyncMock(
+ side_effect=[
+ {"data": {"flashAppByName": {"id": "app1"}}},
+ QueryError("environment not found", "query {}"),
+ ]
+ )
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ assert await client.get_environment_by_name("demo", "prod") is None
+
+
+class TestStock:
+ async def test_gpu_stock_status(self):
+ client, patcher = _client_with(
+ {"gpuTypes": [{"lowestPrice": {"stockStatus": "High"}}]}
+ )
+ with patcher:
+ status = await client.gpu_stock_status(
+ "NVIDIA GeForce RTX 4090", "US-KS-2"
+ )
+ assert status == "High"
+
+ async def test_gpu_stock_no_data(self):
+ client, patcher = _client_with({"gpuTypes": []})
+ with patcher:
+ assert (
+ await client.gpu_stock_status("X", "US-KS-2") is None
+ )
+
+ async def test_gpu_stock_pods_flag(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond({"gpuTypes": []})
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ await client.gpu_stock_status("X", "US-KS-2", pods=True)
+ sent = transport.call_args[1]["variables"]["lowestPriceInput"]
+ assert sent["includeAiApi"] is False
+
+ async def test_cpu_stock_status(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond(
+ {"cpuFlavors": [{"specifics": {"stockStatus": "Low"}}]}
+ )
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ status = await client.cpu_stock_status("cpu3c-2-4", "US-KS-2")
+ assert status == "Low"
+ sent = transport.call_args[1]["variables"]
+ assert sent["cpuFlavorInput"] == {"id": "cpu3c"}
+
+
+class TestVolumesRegistrySecrets:
+ async def test_list_network_volumes(self):
+ client, patcher = _client_with(
+ {"myself": {"networkVolumes": [{"id": "v1"}]}}
+ )
+ with patcher:
+ assert await client.list_network_volumes() == [{"id": "v1"}]
+
+ async def test_create_network_volume(self):
+ client, patcher = _client_with(
+ {"createNetworkVolume": {"id": "v1", "name": "data"}}
+ )
+ with patcher:
+ result = await client.create_network_volume("data", 10, "US-KS-2")
+ assert result["id"] == "v1"
+
+ async def test_registry_auth_crud(self):
+ client, patcher = _client_with(
+ {
+ "myself": {"containerRegistryCreds": [{"id": "r1"}]},
+ "saveRegistryAuth": {"id": "r1", "name": "dh"},
+ "deleteRegistryAuth": True,
+ }
+ )
+ with patcher:
+ assert await client.list_registry_auths() == [{"id": "r1"}]
+ assert (
+ await client.create_registry_auth("dh", "user", "pass")
+ )["id"] == "r1"
+ assert await client.delete_registry_auth("r1") is True
+
+ async def test_secret_crud(self):
+ client, patcher = _client_with(
+ {
+ "myself": {"secrets": [{"id": "s1"}]},
+ "secretCreate": {"id": "s1", "name": "tok"},
+ "secretDelete": True,
+ }
+ )
+ with patcher:
+ assert await client.list_secrets() == [{"id": "s1"}]
+ assert (await client.create_secret("tok", "v"))["id"] == "s1"
+ assert await client.delete_secret("s1") is True
+
+
+class TestAuthRequests:
+ async def test_create_auth_request_is_anonymous(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond(
+ {"createFlashAuthRequest": {"id": "req1", "status": "PENDING"}}
+ )
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ result = await client.create_auth_request()
+ assert result["id"] == "req1"
+ assert transport.call_args[1]["anonymous"] is True
+
+ async def test_status_poll_is_anonymous(self):
+ client = AppsApiClient(api_key="test-key")
+ transport = _respond(
+ {"flashAuthRequestStatus": {"id": "req1", "status": "APPROVED"}}
+ )
+ with patch("runpod.apps.api.run_graphql_query_async", transport):
+ result = await client.get_auth_request_status("req1")
+ assert result["status"] == "APPROVED"
+ assert transport.call_args[1]["anonymous"] is True
+
+
+class TestArtifacts:
+ async def test_prepare_artifact_upload(self):
+ client, patcher = _client_with(
+ {
+ "prepareFlashArtifactUpload": {
+ "uploadUrl": "https://s3",
+ "objectKey": "k",
+ }
+ }
+ )
+ with patcher:
+ result = await client.prepare_artifact_upload("app1", 123)
+ assert result["objectKey"] == "k"
+
+ async def test_finalize_artifact_upload(self):
+ client, patcher = _client_with(
+ {"finalizeFlashArtifactUpload": {"id": "b1", "manifest": {}}}
+ )
+ with patcher:
+ result = await client.finalize_artifact_upload("app1", "k", {})
+ assert result["id"] == "b1"
+
+ async def test_deploy_build(self):
+ client, patcher = _client_with(
+ {"deployBuildToEnvironment": {"id": "env1", "name": "prod"}}
+ )
+ with patcher:
+ result = await client.deploy_build("env1", "b1")
+ assert result["id"] == "env1"
+
+
+class TestUploadTarball:
+ async def test_upload_reports_progress(self, tmp_path):
+ tar = tmp_path / "app.tar.gz"
+ tar.write_bytes(b"x" * 2048)
+ client = AppsApiClient(api_key="test-key")
+ progress = MagicMock()
+
+ put = AsyncMock()
+ with patch.object(client, "_put_tarball", put):
+ await client.upload_tarball("https://s3", str(tar), progress)
+ put.assert_awaited_once()
+
+ # drain the reader to drive progress callbacks
+ reader = put.call_args[0][1]
+ async for _ in reader:
+ pass
+ progress.assert_called_with(2048, 2048)
+
+ async def test_upload_retries_then_succeeds(self, tmp_path):
+ tar = tmp_path / "app.tar.gz"
+ tar.write_bytes(b"x")
+ client = AppsApiClient(api_key="test-key")
+
+ put = AsyncMock(side_effect=[OSError("broken pipe"), None])
+ with (
+ patch.object(client, "_put_tarball", put),
+ patch("asyncio.sleep", AsyncMock()),
+ ):
+ await client.upload_tarball("https://s3", str(tar))
+ assert put.await_count == 2
+
+ async def test_upload_exhausted_raises(self, tmp_path):
+ tar = tmp_path / "app.tar.gz"
+ tar.write_bytes(b"x")
+ client = AppsApiClient(api_key="test-key")
+
+ put = AsyncMock(side_effect=aiohttp.ClientError("reset"))
+ with (
+ patch.object(client, "_put_tarball", put),
+ patch("asyncio.sleep", AsyncMock()),
+ ):
+ with pytest.raises(aiohttp.ClientError):
+ await client.upload_tarball("https://s3", str(tar))
+ assert put.await_count == 4
diff --git a/tests/test_apps/test_api_server.py b/tests/test_apps/test_api_server.py
new file mode 100644
index 00000000..10804879
--- /dev/null
+++ b/tests/test_apps/test_api_server.py
@@ -0,0 +1,263 @@
+"""tests for the api runtime server."""
+
+import json
+import textwrap
+
+import pytest
+
+pytest.importorskip("fastapi")
+from fastapi.testclient import TestClient
+
+import runpod
+from runpod.apps.app import _clear_registry
+from runpod.runtimes.api import server
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+def _write_project(tmp_path, monkeypatch, module="main_cls"):
+ (tmp_path / f"{module}.py").write_text(
+ textwrap.dedent(
+ """
+ import runpod
+ from runpod import App, init, get, post
+
+ app = App("api-test")
+
+ @app.api(name="inference", cpu="cpu3c-1-2")
+ class Inference:
+ @init
+ def setup(self):
+ self.model = "loaded"
+
+ @get("/health")
+ def health(self):
+ return {"model": self.model}
+
+ @post("/generate")
+ async def generate(self, body: dict):
+ return {"echo": body, "model": self.model}
+ """
+ )
+ )
+ manifest = {
+ "version": 1,
+ "app": "api-test",
+ "resources": [
+ {
+ "kind": "api",
+ "name": "inference",
+ "module": module,
+ "routes": [
+ {"method": "GET", "path": "/health", "handler": "health"},
+ {
+ "method": "POST",
+ "path": "/generate",
+ "handler": "generate",
+ },
+ ],
+ }
+ ],
+ }
+ (tmp_path / "runpod_manifest.json").write_text(json.dumps(manifest))
+ monkeypatch.setattr(server, "APP_DIR", str(tmp_path))
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "inference")
+
+
+class TestDeployedClassApi:
+ def test_routes_and_init(self, tmp_path, monkeypatch):
+ _write_project(tmp_path, monkeypatch)
+ app = server.build_app()
+ with TestClient(app) as client:
+ # startup ran init before serving
+ response = client.get("/ping")
+ assert response.status_code == 200
+ assert response.json() == {"status": "healthy"}
+
+ response = client.get("/health")
+ assert response.json() == {"model": "loaded"}
+
+ response = client.post("/generate", json={"prompt": "hi"})
+ assert response.json() == {
+ "echo": {"prompt": "hi"},
+ "model": "loaded",
+ }
+
+ def test_missing_resource_errors(self, tmp_path, monkeypatch):
+ _write_project(tmp_path, monkeypatch)
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "nope")
+ with pytest.raises(RuntimeError, match="nope"):
+ server.build_app()
+
+
+class TestFactoryApi:
+ def test_asgi_factory_served(self, tmp_path, monkeypatch):
+ (tmp_path / "main_factory.py").write_text(
+ textwrap.dedent(
+ """
+ from runpod import App
+
+ app = App("api-test")
+
+ @app.api(name="web", cpu="cpu3c-1-2")
+ def web():
+ from fastapi import FastAPI
+
+ server = FastAPI()
+
+ @server.post("/echo")
+ async def echo(body: dict):
+ return body
+
+ return server
+ """
+ )
+ )
+ manifest = {
+ "version": 1,
+ "app": "api-test",
+ "resources": [
+ {"kind": "api", "name": "web", "module": "main_factory"}
+ ],
+ }
+ (tmp_path / "runpod_manifest.json").write_text(json.dumps(manifest))
+ monkeypatch.setattr(server, "APP_DIR", str(tmp_path))
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "web")
+
+ app = server.build_app()
+ with TestClient(app) as client:
+ assert client.get("/ping").json() == {"status": "healthy"}
+ assert client.post("/echo", json={"a": 1}).json() == {"a": 1}
+
+
+class TestLiveApi:
+ def test_execute_endpoint(self, monkeypatch):
+ monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False)
+ monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False)
+ app = server.build_app()
+ with TestClient(app) as client:
+ assert client.get("/ping").json() == {"status": "healthy"}
+ response = client.post(
+ "/execute",
+ json={
+ "input": {
+ "function_name": "f",
+ "function_code": "def f(a, b):\n return a * b",
+ "args": [6, 7],
+ "kwargs": {},
+ "serialization_format": "json",
+ }
+ },
+ )
+ data = response.json()
+ assert data["success"] is True
+ assert data["json_result"] == 42
+
+
+class TestLiveDispatcher:
+ def test_sync_materializes_routes(self):
+ from fastapi.testclient import TestClient
+
+ from runpod.runtimes.api.server import _build_live_app
+
+ source = (
+ "import runpod\n"
+ "from runpod.apps.markers import get, init, post\n"
+ "app = runpod.App('live')\n"
+ "@app.api(cpu='cpu3c-1-2')\n"
+ "class Counter:\n"
+ " @init\n"
+ " def setup(self):\n"
+ " self.count = 0\n"
+ " @post('/bump')\n"
+ " async def bump(self, body: dict):\n"
+ " self.count += body.get('by', 1)\n"
+ " return {'count': self.count}\n"
+ " @get('/value')\n"
+ " async def value(self):\n"
+ " return {'count': self.count}\n"
+ )
+
+ client = TestClient(_build_live_app())
+ # before sync: core routes only
+ assert client.get("/ping").status_code == 200
+ assert client.post("/bump", json={}).status_code == 404
+
+ r = client.post(
+ "/_runpod/sync", json={"source": source, "resource": "Counter"}
+ )
+ assert r.status_code == 200
+
+ assert client.post("/bump", json={"by": 3}).json() == {"count": 3}
+ assert client.get("/value").json() == {"count": 3}
+ # core routes still work after materialization
+ assert client.get("/ping").status_code == 200
+
+ def test_sync_installs_dependencies_before_materializing(self):
+ from unittest.mock import patch
+
+ from fastapi.testclient import TestClient
+
+ from runpod.runtimes.api.server import _build_live_app
+
+ source = (
+ "import runpod\n"
+ "from runpod.apps.markers import get\n"
+ "app = runpod.App('live-deps')\n"
+ "@app.api(cpu='cpu3c-1-2', dependencies=['somepkg'])\n"
+ "class D:\n"
+ " @get('/ok')\n"
+ " async def ok(self):\n"
+ " return {'ok': True}\n"
+ )
+ client = TestClient(_build_live_app())
+ with (
+ patch(
+ "runpod.runtimes.executor._install", return_value=None
+ ) as install,
+ patch(
+ "runpod.runtimes.executor._install_system",
+ return_value=None,
+ ) as install_system,
+ ):
+ r = client.post(
+ "/_runpod/sync",
+ json={
+ "source": source,
+ "resource": "D",
+ "dependencies": ["somepkg"],
+ "system_dependencies": ["libfoo"],
+ },
+ )
+ assert r.status_code == 200
+ install.assert_called_once_with(["somepkg"], "dependencies")
+ install_system.assert_called_once_with(["libfoo"])
+ assert client.get("/ok").json() == {"ok": True}
+
+ def test_resync_same_source_keeps_state(self):
+ from fastapi.testclient import TestClient
+
+ from runpod.runtimes.api.server import _build_live_app
+
+ source = (
+ "import runpod\n"
+ "from runpod.apps.markers import post\n"
+ "app = runpod.App('live2')\n"
+ "@app.api(cpu='cpu3c-1-2')\n"
+ "class S:\n"
+ " @post('/add')\n"
+ " async def add(self, body: dict):\n"
+ " self.total = getattr(self, 'total', 0) + body['x']\n"
+ " return {'total': self.total}\n"
+ )
+ client = TestClient(_build_live_app())
+ client.post("/_runpod/sync", json={"source": source, "resource": "S"})
+ assert client.post("/add", json={"x": 2}).json() == {"total": 2}
+ # identical source: no rebuild, state survives
+ client.post("/_runpod/sync", json={"source": source, "resource": "S"})
+ assert client.post("/add", json={"x": 3}).json() == {"total": 5}
diff --git a/tests/test_apps/test_app.py b/tests/test_apps/test_app.py
new file mode 100644
index 00000000..803b3073
--- /dev/null
+++ b/tests/test_apps/test_app.py
@@ -0,0 +1,324 @@
+"""tests for the App registry and decorators."""
+
+import pytest
+
+import runpod
+from runpod.apps import App, ApiHandle, FunctionHandle, ResourceKind
+from runpod.apps.app import _REGISTRY, _clear_registry
+from runpod.apps.errors import InvalidResourceError
+from runpod.apps.markers import get, init, post
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+def test_app_registers_itself():
+ app = App("my-app")
+ assert app in _REGISTRY
+ assert runpod.apps.get_registered_apps() == [app]
+
+
+def test_app_requires_name():
+ with pytest.raises(InvalidResourceError):
+ App("")
+
+
+def test_queue_decorator_returns_handle():
+ app = App("a")
+
+ @app.queue(name="q", gpu=runpod.GpuType.NVIDIA_L4, workers=3)
+ async def q(x: int):
+ return x
+
+ assert isinstance(q, FunctionHandle)
+ assert q.spec.kind is ResourceKind.QUEUE
+ assert q.spec.name == "q"
+ assert q.spec.gpu == ["NVIDIA L4"]
+ assert q.spec.workers == (0, 3)
+ assert app.resources["q"] is q
+
+
+def test_queue_name_defaults_to_function_name():
+ app = App("a")
+
+ @app.queue()
+ def my_fn():
+ return 1
+
+ assert my_fn.spec.name == "my_fn"
+
+
+def test_task_decorator():
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu5c-2-4")
+ def t():
+ return "ok"
+
+ assert t.spec.kind is ResourceKind.TASK
+ assert t.spec.cpu == ["cpu5c-2-4"]
+
+
+def test_gpu_cpu_mutually_exclusive():
+ app = App("a")
+ with pytest.raises(InvalidResourceError):
+
+ @app.queue(name="bad", gpu=runpod.GpuType.NVIDIA_L4, cpu="cpu5c-2-4")
+ def bad():
+ pass
+
+
+def test_duplicate_resource_name_rejected():
+ app = App("a")
+
+ @app.queue(name="dupe")
+ def one():
+ pass
+
+ with pytest.raises(InvalidResourceError):
+
+ @app.queue(name="dupe")
+ def two():
+ pass
+
+
+def test_workers_int_shorthand():
+ app = App("a")
+
+ @app.queue(name="q", workers=5)
+ def q():
+ pass
+
+ assert q.spec.workers == (0, 5)
+
+
+def test_workers_tuple():
+ app = App("a")
+
+ @app.queue(name="q", workers=(1, 4))
+ def q():
+ pass
+
+ assert q.spec.workers == (1, 4)
+
+
+def test_workers_invalid():
+ app = App("a")
+ with pytest.raises(InvalidResourceError):
+
+ @app.queue(name="q", workers=(3, 1))
+ def q():
+ pass
+
+
+def test_api_class_collects_routes():
+ app = App("a")
+
+ @app.api(name="api", cpu="cpu5c-2-4")
+ class Inference:
+ @init
+ def setup(self):
+ self.model = object()
+
+ @get("/health")
+ def health(self):
+ return {"ok": True}
+
+ @post("/generate")
+ async def generate(self, body: dict):
+ return body
+
+ assert isinstance(Inference, ApiHandle)
+ routes = {(r.method, r.path) for r in Inference.spec.routes}
+ assert routes == {("GET", "/health"), ("POST", "/generate")}
+ assert Inference._init_name == "setup"
+
+
+def test_api_class_without_routes_rejected():
+ app = App("a")
+ with pytest.raises(InvalidResourceError):
+
+ @app.api(name="api")
+ class Empty:
+ def nothing(self):
+ pass
+
+
+def test_api_duplicate_route_rejected():
+ app = App("a")
+ with pytest.raises(InvalidResourceError):
+
+ @app.api(name="api")
+ class Dupe:
+ @post("/x")
+ def one(self):
+ pass
+
+ @post("/x")
+ def two(self):
+ pass
+
+
+def test_api_asgi_factory():
+ app = App("a")
+
+ @app.api(name="web", cpu="cpu5c-2-4")
+ def web():
+ return object()
+
+ assert isinstance(web, ApiHandle)
+ assert web.spec.asgi_factory is not None
+
+
+def test_api_reserved_path_rejected():
+ with pytest.raises(ValueError):
+ post("/execute")
+
+
+def test_schedule_below_app_decorator():
+ app = App("a")
+
+ @app.task(name="cron")
+ @runpod.schedule(cron="0 * * * *")
+ async def cron():
+ pass
+
+ assert cron.spec.schedule == "0 * * * *"
+
+
+def test_schedule_above_app_decorator():
+ app = App("a")
+
+ @runpod.schedule(cron="*/5 * * * *")
+ @app.task(name="cron")
+ async def cron():
+ pass
+
+ assert cron.spec.schedule == "*/5 * * * *"
+
+
+def test_handle_direct_call_raises():
+ app = App("a")
+
+ @app.queue(name="q")
+ def q():
+ pass
+
+ with pytest.raises(TypeError, match=r"\.remote\("):
+ q()
+
+
+def test_handle_local_runs_body():
+ app = App("a")
+
+ @app.queue(name="q")
+ def q(x):
+ return x * 2
+
+ assert q.local(21) == 42
+
+
+async def test_handle_local_async_follows_signature():
+ app = App("a")
+
+ @app.queue(name="q")
+ async def q(x):
+ return x + 1
+
+ assert await q.local(1) == 2
+
+
+def test_manifest_serialization():
+ app = App("a")
+ volume = "my-volume"
+
+ @app.queue(
+ name="q",
+ gpu=[runpod.GpuGroup.ADA_24],
+ workers=(1, 3),
+ dependencies=["torch"],
+ volume=volume,
+ env={"KEY": "val"},
+ )
+ def q():
+ pass
+
+ manifest = q.spec.to_manifest()
+ assert manifest == {
+ "kind": "queue",
+ "name": "q",
+ "gpuCount": 1,
+ "workersMin": 1,
+ "workersMax": 3,
+ "idleTimeout": 60,
+ "gpus": ["ADA_24"],
+ "dependencies": ["torch"],
+ "networkVolume": "my-volume",
+ "env": {"KEY": "val"},
+ }
+
+
+class TestGpuStringResolution:
+ def test_pool_id_passthrough(self):
+ from runpod.apps.spec import normalize_gpu
+
+ assert normalize_gpu("ADA_24") == ["ADA_24"]
+ assert normalize_gpu("ada_24") == ["ADA_24"]
+
+ def test_device_name_passthrough(self):
+ from runpod.apps.spec import normalize_gpu
+
+ assert normalize_gpu("NVIDIA B200") == ["NVIDIA B200"]
+
+ def test_enum_style_name(self):
+ from runpod.apps.spec import normalize_gpu
+
+ assert normalize_gpu("NVIDIA_B200") == ["NVIDIA B200"]
+
+ def test_shorthand_fragments(self):
+ from runpod.apps.spec import normalize_gpu
+
+ assert normalize_gpu("B200") == ["NVIDIA B200"]
+ assert normalize_gpu("4090") == ["NVIDIA GeForce RTX 4090"]
+ assert normalize_gpu("5090") == ["NVIDIA GeForce RTX 5090"]
+
+ def test_fragment_matches_all_variants(self):
+ from runpod.apps.spec import normalize_gpu
+
+ matches = normalize_gpu("A100")
+ assert set(matches) == {
+ "NVIDIA A100 80GB PCIe",
+ "NVIDIA A100-SXM4-80GB",
+ }
+
+ def test_any_sentinel(self):
+ from runpod.apps.spec import normalize_gpu
+
+ assert normalize_gpu("any") == ["any"]
+
+ def test_unknown_raises_at_decoration(self):
+ import pytest
+
+ from runpod.apps.errors import InvalidResourceError
+ from runpod.apps.spec import normalize_gpu
+
+ with pytest.raises(InvalidResourceError, match="unknown gpu 'B300'"):
+ normalize_gpu("B300")
+
+ def test_gpu_ids_maps_devices_to_pools(self):
+ from runpod.apps.gpu import gpu_ids_value
+
+ # endpoints select by pool; device names map back
+ assert gpu_ids_value(["NVIDIA B200"]) == "BLACKWELL_180"
+ assert gpu_ids_value(["NVIDIA GeForce RTX 4090"]) == "ADA_24"
+ # pool ids pass through untouched
+ assert gpu_ids_value(["ADA_24"]) == "ADA_24"
+ # duplicates collapse (two devices in the same pool)
+ assert (
+ gpu_ids_value(["NVIDIA A100 80GB PCIe", "NVIDIA A100-SXM4-80GB"])
+ == "AMPERE_80"
+ )
diff --git a/tests/test_apps/test_auth.py b/tests/test_apps/test_auth.py
new file mode 100644
index 00000000..335bfc2f
--- /dev/null
+++ b/tests/test_apps/test_auth.py
@@ -0,0 +1,60 @@
+"""browser login flow."""
+
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from runpod.apps.auth import LoginError, auth_url, browser_login
+
+
+def _api(statuses):
+ api = AsyncMock()
+ api.create_auth_request.return_value = {
+ "id": "req-1",
+ "status": "PENDING",
+ "expiresAt": None,
+ }
+ api.get_auth_request_status.side_effect = statuses
+ return api
+
+
+class TestBrowserLogin:
+ def test_returns_key_on_approval(self, monkeypatch):
+ monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0)
+ api = _api(
+ [
+ {"status": "PENDING"},
+ {"status": "APPROVED", "apiKey": "rpa_new"},
+ ]
+ )
+ urls = []
+ key = asyncio.run(
+ browser_login(api=api, on_url=urls.append)
+ )
+ assert key == "rpa_new"
+ assert urls == [auth_url("req-1")]
+
+ def test_denied_raises(self, monkeypatch):
+ monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0)
+ api = _api([{"status": "DENIED"}])
+ with pytest.raises(LoginError, match="denied"):
+ asyncio.run(browser_login(api=api))
+
+ def test_timeout_raises(self, monkeypatch):
+ monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0)
+ api = _api([{"status": "PENDING"}] * 50)
+ with pytest.raises(LoginError, match="timed out"):
+ asyncio.run(browser_login(api=api, timeout_seconds=0))
+
+ def test_missing_request_id_raises(self):
+ api = AsyncMock()
+ api.create_auth_request.return_value = {}
+ with pytest.raises(LoginError, match="initialize"):
+ asyncio.run(browser_login(api=api))
+
+ def test_consumed_without_key_raises(self, monkeypatch):
+ monkeypatch.setattr("runpod.apps.auth.POLL_INTERVAL_SECONDS", 0)
+ api = _api([{"status": "CONSUMED"}])
+ with pytest.raises(LoginError, match="already used"):
+ asyncio.run(browser_login(api=api))
diff --git a/tests/test_apps/test_bootstrap.py b/tests/test_apps/test_bootstrap.py
new file mode 100644
index 00000000..5eadedc0
--- /dev/null
+++ b/tests/test_apps/test_bootstrap.py
@@ -0,0 +1,460 @@
+"""tests for the queue bootstrap and custom-image support."""
+
+import base64
+import json
+import textwrap
+
+import pytest
+
+import runpod
+from runpod.apps import App
+from runpod.apps.app import _clear_registry
+from runpod.apps.dev import _endpoint_input
+from runpod.runtimes import bootstrap
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+class TestLocatePhase:
+ def test_missing_artifact_fails_with_phase(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(tmp_path / "nope.tar.gz"))
+ monkeypatch.setattr(bootstrap, "ARTIFACT_WAIT_SECONDS", 0)
+ monkeypatch.setattr(bootstrap, "APP_DIR", str(tmp_path / "app"))
+ with pytest.raises(bootstrap.PhaseError) as exc_info:
+ bootstrap._locate()
+ assert exc_info.value.phase == "locate"
+
+ def test_extracts_artifact(self, tmp_path, monkeypatch):
+ import tarfile
+
+ src = tmp_path / "src"
+ src.mkdir()
+ (src / "main.py").write_text("x = 1")
+ artifact = tmp_path / "artifact.tar.gz"
+ with tarfile.open(artifact, "w:gz") as tar:
+ tar.add(src / "main.py", arcname="main.py")
+
+ app_dir = tmp_path / "app"
+ monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(artifact))
+ monkeypatch.setattr(bootstrap, "APP_DIR", str(app_dir))
+ assert bootstrap._locate() == str(app_dir)
+ assert (app_dir / "main.py").read_text() == "x = 1"
+
+ def test_unpack_happens_once(self, tmp_path, monkeypatch):
+ import tarfile
+
+ src = tmp_path / "src"
+ src.mkdir()
+ (src / "main.py").write_text("x = 1")
+ artifact = tmp_path / "artifact.tar.gz"
+ with tarfile.open(artifact, "w:gz") as tar:
+ tar.add(src / "main.py", arcname="main.py")
+
+ app_dir = tmp_path / "app"
+ monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(artifact))
+ monkeypatch.setattr(bootstrap, "APP_DIR", str(app_dir))
+ bootstrap._locate()
+ # second boot on a warm container: marker short-circuits
+ artifact.unlink()
+ assert bootstrap._locate() == str(app_dir)
+
+ def test_prebuilt_dir_skips_unpack(self, tmp_path, monkeypatch):
+ prebuilt = tmp_path / "prebuilt"
+ prebuilt.mkdir()
+ monkeypatch.setattr(bootstrap, "PREBUILT_APP_DIR", str(prebuilt))
+ # no artifact anywhere; the host-provided tree wins outright
+ monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(tmp_path / "no.tar.gz"))
+ assert bootstrap._locate() == str(prebuilt)
+
+ def test_rejects_traversal(self, tmp_path, monkeypatch):
+ import io
+ import tarfile
+
+ artifact = tmp_path / "evil.tar.gz"
+ with tarfile.open(artifact, "w:gz") as tar:
+ data = b"evil"
+ info = tarfile.TarInfo(name="../../etc/evil")
+ info.size = len(data)
+ tar.addfile(info, io.BytesIO(data))
+
+ monkeypatch.setattr(bootstrap, "ARTIFACT_PATH", str(artifact))
+ monkeypatch.setattr(bootstrap, "APP_DIR", str(tmp_path / "app"))
+ with pytest.raises(bootstrap.PhaseError) as exc_info:
+ bootstrap._locate()
+ assert exc_info.value.phase == "locate"
+
+
+class TestAttachPhase:
+ def test_env_dir_leads_path_order(self, tmp_path):
+ (tmp_path / "env").mkdir()
+ paths = bootstrap._attach(str(tmp_path))
+ assert paths == [str(tmp_path / "env"), str(tmp_path)]
+
+ def test_no_env_dir_falls_back_to_source_only(self, tmp_path):
+ paths = bootstrap._attach(str(tmp_path))
+ assert paths == [str(tmp_path)]
+
+ def test_manifest_read(self, tmp_path):
+ (tmp_path / "runpod_manifest.json").write_text(
+ json.dumps({"resources": [{"name": "q1"}]})
+ )
+ manifest = bootstrap._manifest(str(tmp_path))
+ assert manifest["resources"][0]["name"] == "q1"
+
+ def test_manifest_missing_fails_with_phase(self, tmp_path):
+ with pytest.raises(bootstrap.PhaseError) as exc_info:
+ bootstrap._manifest(str(tmp_path))
+ assert exc_info.value.phase == "attach"
+
+
+class TestSystemPhase:
+ def test_no_system_deps_no_op(self):
+ bootstrap._install_system({"resources": [{"name": "q"}]})
+
+ def test_missing_apt_fails_with_phase(self, monkeypatch):
+ import shutil
+
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "q")
+ monkeypatch.setattr(shutil, "which", lambda _: None)
+ manifest = {
+ "resources": [{"name": "q", "systemDependencies": ["ffmpeg"]}]
+ }
+ with pytest.raises(bootstrap.PhaseError) as exc_info:
+ bootstrap._install_system(manifest)
+ assert exc_info.value.phase == "system"
+ assert "ffmpeg" in str(exc_info.value)
+
+ def test_installs_resource_system_deps(self, monkeypatch):
+ import shutil
+
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "q")
+ monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/apt-get")
+ calls = []
+
+ class R:
+ returncode = 0
+ stderr = ""
+
+ monkeypatch.setattr(
+ bootstrap.subprocess,
+ "run",
+ lambda cmd, **kw: calls.append(cmd) or R(),
+ )
+ manifest = {
+ "resources": [{"name": "q", "systemDependencies": ["ffmpeg", "sox"]}]
+ }
+ bootstrap._install_system(manifest)
+ assert calls[0][:2] == ["apt-get", "update"]
+ assert calls[1][:3] == ["apt-get", "install", "-y"]
+ assert "ffmpeg" in calls[1] and "sox" in calls[1]
+
+ def test_other_resources_deps_ignored(self, monkeypatch):
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "q")
+ manifest = {
+ "resources": [{"name": "other", "systemDependencies": ["ffmpeg"]}]
+ }
+ # q has no system deps; must not attempt anything
+ bootstrap._install_system(manifest)
+
+
+class TestVerifyPhase:
+ def test_no_exclusions_no_op(self):
+ bootstrap._verify_excluded({"resources": []})
+
+ def test_present_exclusions_no_install(self, monkeypatch):
+ installed = []
+ monkeypatch.setattr(
+ bootstrap, "_pip_install", lambda pkgs, phase: installed.extend(pkgs)
+ )
+ # json is definitely importable; stands in for torch-in-image
+ bootstrap._verify_excluded({"excludedPackages": ["json"]})
+ assert installed == []
+
+ def test_missing_exclusions_installed(self, monkeypatch):
+ installed = []
+ monkeypatch.setattr(
+ bootstrap, "_pip_install", lambda pkgs, phase: installed.extend(pkgs)
+ )
+ bootstrap._verify_excluded(
+ {"excludedPackages": ["definitely-not-installed-xyz"]}
+ )
+ assert installed == ["definitely-not-installed-xyz"]
+
+
+class TestCustomImagePayloads:
+ def test_queue_custom_image_gets_bootstrap(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", image="pytorch/pytorch:latest")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ template = payload["template"]
+ assert template["imageName"] == "pytorch/pytorch:latest"
+ assert "bootstrap.py" in template["dockerArgs"]
+ env = {e["key"]: e["value"] for e in template["env"]}
+ decoded = base64.b64decode(env["RUNPOD_BOOTSTRAP_B64"]).decode()
+ assert "def _locate" in decoded
+
+ def test_queue_default_image_no_bootstrap(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ template = payload["template"]
+ from runpod.apps.images import local_python_version
+
+ assert template["imageName"].startswith(
+ f"runpod/queue:py{local_python_version()}-"
+ )
+ assert template["dockerArgs"] == ""
+ env = {e["key"]: e["value"] for e in template["env"]}
+ assert "RUNPOD_BOOTSTRAP_B64" not in env
+
+ def test_api_custom_image_supported(self):
+ app = App("a")
+
+ @app.api(name="api", cpu="cpu3c-1-2", image="my/server:1")
+ class Api:
+ @runpod.post("/x")
+ def x(self, body: dict):
+ return body
+
+ payload = _endpoint_input(app, Api.spec)
+ assert payload["template"]["imageName"] == "my/server:1"
+
+ def test_image_in_manifest(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", image="custom:1")
+ def q():
+ pass
+
+ assert q.spec.to_manifest()["imageName"] == "custom:1"
+
+
+class TestBootstrapIsStdlibOnly:
+ def test_no_third_party_imports(self):
+ import ast
+ from pathlib import Path
+
+ source = (
+ Path(bootstrap.__file__).read_text()
+ )
+ tree = ast.parse(source)
+ # module-level imports only: function-local imports (the runpod
+ # availability probe in _ensure_runtime) run after installation
+ imports = set()
+ for node in tree.body:
+ if isinstance(node, ast.Import):
+ imports.update(a.name.split(".")[0] for a in node.names)
+ elif isinstance(node, ast.ImportFrom) and node.level == 0:
+ imports.add(node.module.split(".")[0])
+ stdlib = {
+ "json", "os", "subprocess", "sys", "tarfile", "time",
+ "urllib", "io", "importlib", "http",
+ }
+ assert imports <= stdlib, f"non-stdlib imports: {imports - stdlib}"
+
+
+class TestErrorSurface:
+ def test_error_payload_shape(self):
+ error = bootstrap.PhaseError("locate", "artifact missing")
+ payload = bootstrap._error_payload(error)
+ assert payload["error_type"] == "BootstrapError"
+ assert "locate" in payload["error_message"]
+ assert "artifact missing" in payload["error_message"]
+
+ def test_queue_error_loop_exits_without_webhooks(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_WEBHOOK_GET_JOB", raising=False)
+ monkeypatch.delenv("RUNPOD_WEBHOOK_POST_OUTPUT", raising=False)
+ with pytest.raises(SystemExit):
+ bootstrap._queue_error_loop(bootstrap.PhaseError("locate", "x"))
+
+ def test_queue_error_loop_reports_jobs(self, monkeypatch):
+ import contextlib
+ import io
+
+ monkeypatch.setenv("RUNPOD_WEBHOOK_GET_JOB", "http://jobs/get")
+ monkeypatch.setenv("RUNPOD_WEBHOOK_POST_OUTPUT", "http://jobs/out/$ID")
+ monkeypatch.setenv("RUNPOD_AI_API_KEY", "k")
+
+ posted = []
+
+ @contextlib.contextmanager
+ def _get_response():
+ response = io.BytesIO(json.dumps({"id": "job1"}).encode())
+ response.status = 200
+ yield response
+
+ def fake_urlopen(req, timeout=None):
+ url = req.full_url
+ if url.endswith("/get"):
+ return _get_response()
+ posted.append((url, req.data))
+ # first report done: break out of the infinite loop
+ raise KeyboardInterrupt
+
+ monkeypatch.setattr(
+ bootstrap.urllib.request, "urlopen", fake_urlopen
+ )
+ error = bootstrap.PhaseError("attach", "manifest gone")
+ with pytest.raises(KeyboardInterrupt):
+ bootstrap._queue_error_loop(error)
+
+ assert posted
+ url, body = posted[0]
+ assert url.endswith("/out/job1")
+ assert b"BootstrapError" in body
+
+ def test_report_error_routes_by_kind(self, monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ bootstrap, "_api_error_server", lambda e: calls.append("api")
+ )
+ monkeypatch.setattr(
+ bootstrap, "_queue_error_loop", lambda e: calls.append("queue")
+ )
+ error = bootstrap.PhaseError("locate", "x")
+
+ monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "api")
+ bootstrap._report_error(error)
+ monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "queue")
+ bootstrap._report_error(error)
+ assert calls == ["api", "queue"]
+
+
+class TestLiveRuntimeInstall:
+ def test_importable_runtime_no_op(self, monkeypatch):
+ monkeypatch.setattr(
+ bootstrap, "_worker_importable", lambda paths: (True, "")
+ )
+ installs = []
+ monkeypatch.setattr(
+ bootstrap, "_pip_install", lambda pkgs, phase: installs.append(pkgs)
+ )
+ bootstrap._ensure_runtime_installed()
+ assert installs == []
+
+ def test_installs_package_spec(self, monkeypatch):
+ probes = iter([(False, "no module"), (True, "")])
+ monkeypatch.setattr(
+ bootstrap, "_worker_importable", lambda paths: next(probes)
+ )
+ installs = []
+ monkeypatch.setattr(
+ bootstrap, "_pip_install", lambda pkgs, phase: installs.append(pkgs)
+ )
+ monkeypatch.setenv("RUNPOD_PACKAGE_SPEC", "runpod==1.99.0")
+ bootstrap._ensure_runtime_installed()
+ assert installs == [["runpod==1.99.0", "cloudpickle"]]
+
+ def test_api_kind_adds_uvicorn(self, monkeypatch):
+ probes = iter([(False, "no module"), (True, "")])
+ monkeypatch.setattr(
+ bootstrap, "_worker_importable", lambda paths: next(probes)
+ )
+ installs = []
+ monkeypatch.setattr(
+ bootstrap, "_pip_install", lambda pkgs, phase: installs.append(pkgs)
+ )
+ monkeypatch.delenv("RUNPOD_PACKAGE_SPEC", raising=False)
+ monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "api")
+ bootstrap._ensure_runtime_installed()
+ assert installs == [["runpod", "cloudpickle", "uvicorn>=0.30"]]
+
+ def test_install_failure_raises_phase(self, monkeypatch):
+ monkeypatch.setattr(
+ bootstrap, "_worker_importable", lambda paths: (False, "still broken")
+ )
+ monkeypatch.setattr(
+ bootstrap, "_pip_install", lambda pkgs, phase: None
+ )
+ with pytest.raises(bootstrap.PhaseError) as exc_info:
+ bootstrap._ensure_runtime_installed()
+ assert exc_info.value.phase == "runtime"
+
+
+class TestServe:
+ def test_serve_execs_worker_module(self, monkeypatch):
+ execs = []
+ monkeypatch.setattr(
+ bootstrap.os,
+ "execve",
+ lambda exe, argv, env: execs.append((exe, argv, env)),
+ )
+ monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "queue")
+ monkeypatch.setenv("PYTHONPATH", "/existing")
+ bootstrap._serve(["/env", "/app"])
+ exe, argv, env = execs[0]
+ assert argv[-1] == bootstrap.WORKER_MODULES["queue"]
+ assert env["PYTHONPATH"].split(":")[:2] == ["/env", "/app"]
+ assert "/existing" in env["PYTHONPATH"]
+ assert env["RUNPOD_APP_DIR"] == "/app"
+
+ def test_serve_live_mode_no_paths(self, monkeypatch):
+ execs = []
+ monkeypatch.setattr(
+ bootstrap.os,
+ "execve",
+ lambda exe, argv, env: execs.append((exe, argv, env)),
+ )
+ monkeypatch.delenv("PYTHONPATH", raising=False)
+ monkeypatch.setenv("RUNPOD_RUNTIME_KIND", "api")
+ bootstrap._serve([])
+ _, argv, env = execs[0]
+ assert argv[-1] == bootstrap.WORKER_MODULES["api"]
+ assert "RUNPOD_APP_DIR" not in env
+
+
+class TestMain:
+ def test_deployed_path_serves(self, tmp_path, monkeypatch):
+ app_dir = str(tmp_path)
+ (tmp_path / bootstrap.MANIFEST_NAME).write_text(
+ json.dumps({"resources": [{"name": "chat"}]})
+ )
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+ monkeypatch.setattr(bootstrap, "_locate", lambda: app_dir)
+ monkeypatch.setattr(
+ bootstrap, "_worker_importable", lambda paths: (True, "")
+ )
+ served = []
+ monkeypatch.setattr(bootstrap, "_serve", lambda paths: served.append(paths))
+ bootstrap.main()
+ assert served and served[0][-1] == app_dir
+
+ def test_phase_error_reports(self, monkeypatch):
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+ monkeypatch.setattr(
+ bootstrap,
+ "_locate",
+ lambda: (_ for _ in ()).throw(
+ bootstrap.PhaseError("locate", "gone")
+ ),
+ )
+ reported = []
+ monkeypatch.setattr(
+ bootstrap, "_report_error", lambda e: reported.append(e)
+ )
+ bootstrap.main()
+ assert reported and reported[0].phase == "locate"
+
+ def test_live_path(self, monkeypatch):
+ monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False)
+ monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False)
+ monkeypatch.setattr(
+ bootstrap, "_ensure_runtime_installed", lambda: None
+ )
+ served = []
+ monkeypatch.setattr(bootstrap, "_serve", lambda paths: served.append(paths))
+ bootstrap.main()
+ assert served == [[]]
diff --git a/tests/test_apps/test_build.py b/tests/test_apps/test_build.py
new file mode 100644
index 00000000..30af8ece
--- /dev/null
+++ b/tests/test_apps/test_build.py
@@ -0,0 +1,224 @@
+"""tests for deploy-time environment vendoring."""
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from runpod.apps import App
+from runpod.apps.app import _clear_registry
+from runpod.apps.build import (
+ SIZE_PROHIBITIVE_PACKAGES,
+ BuildError,
+ collect_requirements,
+ requirement_name,
+ runtime_requirement,
+ split_exclusions,
+ vendor,
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+class TestRequirementName:
+ @pytest.mark.parametrize(
+ "spec,name",
+ [
+ ("numpy", "numpy"),
+ ("numpy==1.26.0", "numpy"),
+ ("Torch>=2.0", "torch"),
+ ("scikit_learn~=1.4", "scikit-learn"),
+ ("pillow[extras]==10", "pillow"),
+ ],
+ )
+ def test_extracts_distribution_name(self, spec, name):
+ assert requirement_name(spec) == name
+
+
+class TestCollectRequirements:
+ def test_merges_requirements_txt_and_decorator_deps(self, tmp_path):
+ (tmp_path / "requirements.txt").write_text(
+ "numpy==1.26\n# comment\n\npandas\n"
+ )
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", dependencies=["requests", "numpy"])
+ def q():
+ pass
+
+ reqs = collect_requirements(tmp_path, app)
+ # numpy deduped by name, requirements.txt wins on order
+ assert reqs == ["numpy==1.26", "pandas", "requests"]
+
+ def test_no_requirements_file(self, tmp_path):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ assert collect_requirements(tmp_path, app) == []
+
+
+class TestSplitExclusions:
+ def test_torch_family_auto_excluded(self):
+ kept, excluded = split_exclusions(
+ ["numpy", "torch==2.4", "torchvision", "requests"]
+ )
+ assert kept == ["numpy", "requests"]
+ assert excluded == ["torch", "torchvision"]
+
+ def test_user_excludes_added(self):
+ kept, excluded = split_exclusions(
+ ["numpy", "transformers"], extra_excludes=["transformers"]
+ )
+ assert kept == ["numpy"]
+ assert excluded == ["transformers"]
+
+ def test_auto_exclude_off_vendors_everything(self):
+ # custom images carry no torch guarantee: nothing auto-excluded
+ kept, excluded = split_exclusions(
+ ["numpy", "torch==2.4", "triton"], auto_exclude=False
+ )
+ assert kept == ["numpy", "torch==2.4", "triton"]
+ assert excluded == []
+
+ def test_auto_exclude_off_user_excludes_still_apply(self):
+ kept, excluded = split_exclusions(
+ ["numpy", "torch"], extra_excludes=["torch"], auto_exclude=False
+ )
+ assert kept == ["numpy"]
+ assert excluded == ["torch"]
+
+ def test_exclusion_set_is_size_based(self):
+ # sanity-pin the auto set; additions must be size-justified
+ assert SIZE_PROHIBITIVE_PACKAGES == {
+ "torch",
+ "torchvision",
+ "torchaudio",
+ "triton",
+ }
+
+
+class TestRuntimeRequirement:
+ def test_default_is_published_package(self, tmp_path, monkeypatch):
+ monkeypatch.delenv("RUNPOD_PACKAGE_SPEC", raising=False)
+ assert runtime_requirement(tmp_path) == "runpod"
+
+ def test_running_package_overlays_env(self, tmp_path):
+ from runpod.apps.build import sync_running_package
+
+ env_dir = tmp_path / "env"
+ # stale copy from the pypi install: must be overwritten
+ (env_dir / "runpod").mkdir(parents=True)
+ (env_dir / "runpod" / "stale.py").write_text("old = True")
+
+ sync_running_package(env_dir)
+
+ # the client's own tree (which has the runtimes) wins
+ assert (env_dir / "runpod" / "runtimes" / "bootstrap.py").is_file()
+ assert (env_dir / "runpod" / "apps" / "build.py").is_file()
+ assert not any(
+ p.name == "__pycache__"
+ for p in (env_dir / "runpod").rglob("*")
+ if p.is_dir()
+ )
+
+ def test_version_pin_passes_through(self, tmp_path, monkeypatch):
+ monkeypatch.setenv("RUNPOD_PACKAGE_SPEC", "runpod==1.8.0")
+ assert runtime_requirement(tmp_path) == "runpod==1.8.0"
+
+ def test_url_spec_builds_wheel(self, tmp_path, monkeypatch):
+ monkeypatch.setenv(
+ "RUNPOD_PACKAGE_SPEC", "https://example.com/runpod.tar.gz"
+ )
+ wheel = tmp_path / "wheels" / "runpod-0.0.0.dev0-py3-none-any.whl"
+
+ def fake_run(cmd, **kwargs):
+ wheel.parent.mkdir(parents=True, exist_ok=True)
+ wheel.write_bytes(b"")
+
+ class R:
+ returncode = 0
+ stderr = ""
+
+ return R()
+
+ with patch("runpod.apps.build.subprocess.run", side_effect=fake_run):
+ assert runtime_requirement(tmp_path) == str(wheel)
+
+
+def _fake_popen(captured, *, returncode=0, stdout="", stderr=""):
+ import io
+
+ def fake(cmd, **kwargs):
+ captured["cmd"] = cmd
+
+ class P:
+ def __init__(self):
+ self.stdout = io.StringIO(stdout)
+ self.stderr = io.StringIO(stderr)
+ self.returncode = returncode
+
+ def wait(self):
+ return returncode
+
+ return P()
+
+ return fake
+
+
+class TestVendor:
+ def test_targets_worker_platform(self, tmp_path):
+ captured = {}
+ with patch(
+ "runpod.apps.build.subprocess.Popen",
+ side_effect=_fake_popen(captured),
+ ):
+ vendor(tmp_path, ["numpy"], "3.12")
+
+ cmd = captured["cmd"]
+ assert "--target" in cmd
+ assert "--python-version" in cmd and "3.12" in cmd
+ assert "--only-binary" in cmd
+ assert "manylinux_2_28_x86_64" in cmd
+
+ def test_empty_requirements_no_op(self, tmp_path):
+ with patch("runpod.apps.build.subprocess.Popen") as popen:
+ vendor(tmp_path, [], "3.12")
+ popen.assert_not_called()
+
+ def test_failure_raises_build_error(self, tmp_path):
+ with patch(
+ "runpod.apps.build.subprocess.Popen",
+ side_effect=_fake_popen(
+ {}, returncode=1, stderr="no matching distribution"
+ ),
+ ):
+ with pytest.raises(BuildError, match="no matching distribution"):
+ vendor(tmp_path, ["nonexistent-xyz"], "3.12")
+
+ def test_progress_reports_collected_packages(self, tmp_path):
+ seen = []
+ stdout = (
+ "Collecting numpy>=1.0\n"
+ " Downloading numpy-2.0-cp312.whl\n"
+ "Collecting requests\n"
+ "Installing collected packages\n"
+ )
+ with patch(
+ "runpod.apps.build.subprocess.Popen",
+ side_effect=_fake_popen({}, stdout=stdout),
+ ):
+ vendor(
+ tmp_path,
+ ["numpy"],
+ "3.12",
+ progress=lambda c, n: seen.append((c, n)),
+ )
+ assert seen == [(1, "numpy"), (2, "requests")]
diff --git a/tests/test_apps/test_compat_lock.py b/tests/test_apps/test_compat_lock.py
new file mode 100644
index 00000000..d810aa5c
--- /dev/null
+++ b/tests/test_apps/test_compat_lock.py
@@ -0,0 +1,105 @@
+"""compatibility locks: the legacy surface must not change.
+
+these tests intentionally assert exact signatures and behaviors of the
+pre-apps sdk. if one fails, the apps work has leaked into the legacy
+surface, which is a release blocker.
+"""
+
+import inspect
+import unittest
+from unittest.mock import patch
+
+import runpod
+from runpod.endpoint.runner import Endpoint, Job, RunPodClient
+
+
+class TestEndpointSignatureLock(unittest.TestCase):
+ """runpod.Endpoint(id).run_sync() is byte-for-byte the legacy client."""
+
+ def test_endpoint_is_runner_endpoint(self):
+ self.assertIs(runpod.Endpoint, Endpoint)
+
+ def test_constructor_signature(self):
+ params = list(inspect.signature(Endpoint.__init__).parameters)
+ self.assertEqual(params, ["self", "endpoint_id", "api_key"])
+
+ def test_run_sync_signature(self):
+ params = list(inspect.signature(Endpoint.run_sync).parameters)
+ self.assertEqual(params, ["self", "request_input", "timeout"])
+ default = inspect.signature(Endpoint.run_sync).parameters["timeout"].default
+ self.assertEqual(default, 86400)
+
+ def test_run_signature(self):
+ params = list(inspect.signature(Endpoint.run).parameters)
+ self.assertEqual(params, ["self", "request_input"])
+
+ def test_endpoint_methods_present(self):
+ for method in ("run", "run_sync", "health", "purge_queue"):
+ self.assertTrue(callable(getattr(Endpoint, method)))
+
+ def test_job_methods_present(self):
+ for method in ("status", "output", "stream", "cancel"):
+ self.assertTrue(callable(getattr(Job, method)))
+
+ @patch.object(RunPodClient, "post")
+ @patch("runpod.api_key", "test-key")
+ def test_run_sync_behavior(self, mock_post):
+ mock_post.return_value = {
+ "id": "job-1",
+ "status": "COMPLETED",
+ "output": {"result": 42},
+ }
+ endpoint = Endpoint("ep-abc", api_key="test-key")
+ result = endpoint.run_sync({"prompt": "hi"})
+ self.assertEqual(result, {"result": 42})
+ mock_post.assert_called_once_with(
+ "ep-abc/runsync", {"input": {"prompt": "hi"}}, timeout=86400
+ )
+
+ @patch.object(RunPodClient, "post")
+ @patch("runpod.api_key", "test-key")
+ def test_run_returns_job(self, mock_post):
+ mock_post.return_value = {"id": "job-2"}
+ endpoint = Endpoint("ep-abc", api_key="test-key")
+ job = endpoint.run({"prompt": "hi"})
+ self.assertIsInstance(job, Job)
+ self.assertEqual(job.job_id, "job-2")
+
+
+class TestLegacyFlatVerbsPresent(unittest.TestCase):
+ """the flat api verbs stay importable from the package root."""
+
+ def test_flat_verbs(self):
+ for verb in (
+ "create_pod",
+ "get_pods",
+ "get_pod",
+ "stop_pod",
+ "resume_pod",
+ "terminate_pod",
+ "create_endpoint",
+ "get_endpoints",
+ "create_template",
+ "get_gpus",
+ "get_gpu",
+ "get_user",
+ "update_user_settings",
+ "update_endpoint_template",
+ "create_container_registry_auth",
+ "update_container_registry_auth",
+ "delete_container_registry_auth",
+ ):
+ self.assertTrue(
+ callable(getattr(runpod, verb)), f"runpod.{verb} missing"
+ )
+
+ def test_asyncio_endpoint_present(self):
+ self.assertTrue(hasattr(runpod, "AsyncioEndpoint"))
+ self.assertTrue(hasattr(runpod, "AsyncioJob"))
+
+ def test_serverless_start_present(self):
+ self.assertTrue(callable(runpod.serverless.start))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_apps/test_config_params.py b/tests/test_apps/test_config_params.py
new file mode 100644
index 00000000..214a40c6
--- /dev/null
+++ b/tests/test_apps/test_config_params.py
@@ -0,0 +1,364 @@
+"""endpoint configuration parameters across spec, dev, and deploy payloads."""
+
+import pytest
+
+import runpod
+from runpod.apps import App
+from runpod.apps.app import _clear_registry
+from runpod.apps.dev import _endpoint_input
+from runpod.apps.errors import InvalidResourceError
+from runpod.apps.spec import (
+ ResourceKind,
+ ResourceSpec,
+ normalize_cuda_version,
+ normalize_scaler_type,
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+class TestNormalizers:
+ def test_scaler_type_uppercases(self):
+ assert normalize_scaler_type("queue_delay") == "QUEUE_DELAY"
+ assert normalize_scaler_type("REQUEST_COUNT") == "REQUEST_COUNT"
+
+ def test_scaler_type_none_passes(self):
+ assert normalize_scaler_type(None) is None
+
+ def test_scaler_type_rejects_unknown(self):
+ with pytest.raises(InvalidResourceError):
+ normalize_scaler_type("SOMETHING")
+
+ def test_cuda_version_valid(self):
+ assert normalize_cuda_version("12.8") == "12.8"
+
+ def test_cuda_version_none_passes(self):
+ assert normalize_cuda_version(None) is None
+
+ def test_cuda_version_rejects_unknown(self):
+ with pytest.raises(InvalidResourceError):
+ normalize_cuda_version("10.0")
+
+
+class TestSpecValidation:
+ def test_max_concurrency_floor(self):
+ with pytest.raises(InvalidResourceError):
+ ResourceSpec(
+ kind=ResourceKind.QUEUE, name="q", max_concurrency=0
+ )
+
+ def test_execution_timeout_floor(self):
+ with pytest.raises(InvalidResourceError):
+ ResourceSpec(
+ kind=ResourceKind.QUEUE, name="q", execution_timeout_ms=-1
+ )
+
+ def test_scaler_value_floor(self):
+ with pytest.raises(InvalidResourceError):
+ ResourceSpec(kind=ResourceKind.QUEUE, name="q", scaler_value=0)
+
+ def test_container_disk_floor(self):
+ with pytest.raises(InvalidResourceError):
+ ResourceSpec(
+ kind=ResourceKind.QUEUE, name="q", container_disk_gb=0
+ )
+
+ def test_min_cuda_rejected_on_cpu(self):
+ with pytest.raises(InvalidResourceError):
+ ResourceSpec(
+ kind=ResourceKind.QUEUE,
+ name="q",
+ cpu=["cpu3c-1-2"],
+ min_cuda_version="12.8",
+ )
+
+ def test_effective_scaler_type_defaults_by_kind(self):
+ queue = ResourceSpec(kind=ResourceKind.QUEUE, name="q")
+ api = ResourceSpec(kind=ResourceKind.API, name="api")
+ assert queue.effective_scaler_type == "QUEUE_DELAY"
+ assert api.effective_scaler_type == "REQUEST_COUNT"
+
+ def test_effective_scaler_type_explicit_wins(self):
+ spec = ResourceSpec(
+ kind=ResourceKind.QUEUE, name="q", scaler_type="REQUEST_COUNT"
+ )
+ assert spec.effective_scaler_type == "REQUEST_COUNT"
+
+
+class TestDecoratorPlumbing:
+ def test_queue_accepts_all_params(self):
+ app = App("a")
+
+ @app.queue(
+ name="q",
+ gpu="4090",
+ max_concurrency=4,
+ execution_timeout_ms=60000,
+ flashboot=False,
+ scaler_type="request_count",
+ scaler_value=8,
+ min_cuda_version="12.4",
+ accelerate_downloads=False,
+ container_disk_gb=50,
+ )
+ def q():
+ pass
+
+ spec = q.spec
+ assert spec.max_concurrency == 4
+ assert spec.execution_timeout_ms == 60000
+ assert spec.flashboot is False
+ assert spec.scaler_type == "REQUEST_COUNT"
+ assert spec.scaler_value == 8
+ assert spec.min_cuda_version == "12.4"
+ assert spec.accelerate_downloads is False
+ assert spec.container_disk_gb == 50
+
+ def test_api_accepts_params(self):
+ app = App("a")
+
+ @app.api(
+ name="api",
+ cpu="cpu3c-1-2",
+ execution_timeout_ms=30000,
+ scaler_value=2,
+ container_disk_gb=20,
+ )
+ class Api:
+ @runpod.post("/x")
+ def x(self, body: dict):
+ return body
+
+ assert Api.spec.execution_timeout_ms == 30000
+ assert Api.spec.scaler_value == 2
+ assert Api.spec.container_disk_gb == 20
+
+ def test_task_accepts_params(self):
+ app = App("a")
+
+ @app.task(
+ name="t",
+ gpu="4090",
+ min_cuda_version="12.8",
+ accelerate_downloads=False,
+ container_disk_gb=100,
+ )
+ def t():
+ pass
+
+ assert t.spec.min_cuda_version == "12.8"
+ assert t.spec.accelerate_downloads is False
+ assert t.spec.container_disk_gb == 100
+
+ def test_queue_invalid_scaler_fails_at_decoration(self):
+ app = App("a")
+
+ with pytest.raises(InvalidResourceError):
+
+ @app.queue(name="q", scaler_type="nope")
+ def q():
+ pass
+
+
+class TestManifestSerialization:
+ def test_non_defaults_serialized(self):
+ spec = ResourceSpec(
+ kind=ResourceKind.QUEUE,
+ name="q",
+ max_concurrency=4,
+ execution_timeout_ms=1000,
+ flashboot=False,
+ scaler_type="REQUEST_COUNT",
+ scaler_value=8,
+ min_cuda_version="12.8",
+ accelerate_downloads=False,
+ container_disk_gb=42,
+ )
+ data = spec.to_manifest()
+ assert data["maxConcurrency"] == 4
+ assert data["executionTimeoutMs"] == 1000
+ assert data["flashboot"] is False
+ assert data["scalerType"] == "REQUEST_COUNT"
+ assert data["scalerValue"] == 8
+ assert data["minCudaVersion"] == "12.8"
+ assert data["accelerateDownloads"] is False
+ assert data["containerDiskGb"] == 42
+
+ def test_defaults_omitted(self):
+ spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q")
+ data = spec.to_manifest()
+ for key in (
+ "maxConcurrency",
+ "executionTimeoutMs",
+ "flashboot",
+ "scalerType",
+ "scalerValue",
+ "minCudaVersion",
+ "accelerateDownloads",
+ "containerDiskGb",
+ ):
+ assert key not in data
+
+
+class TestDevPayload:
+ def test_config_params_reach_payload(self):
+ app = App("a")
+
+ @app.queue(
+ name="q",
+ gpu="4090",
+ max_concurrency=3,
+ execution_timeout_ms=90000,
+ scaler_type="request_count",
+ scaler_value=7,
+ min_cuda_version="12.6",
+ container_disk_gb=40,
+ )
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["scalerType"] == "REQUEST_COUNT"
+ assert payload["scalerValue"] == 7
+ assert payload["executionTimeoutMs"] == 90000
+ assert payload["minCudaVersion"] == "12.6"
+ assert payload["template"]["containerDiskInGb"] == 40
+ env = {e["key"]: e["value"] for e in payload["template"]["env"]}
+ assert env["RUNPOD_MAX_CONCURRENCY"] == "3"
+
+ def test_flashboot_disabled_omits_flag(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", flashboot=False)
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert "flashBootType" not in payload
+
+ def test_defaults_unchanged(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["scalerType"] == "QUEUE_DELAY"
+ assert payload["scalerValue"] == 4
+ assert payload["executionTimeoutMs"] == 0
+ assert payload["flashBootType"] == "FLASHBOOT"
+ assert payload["template"]["containerDiskInGb"] == 10
+ env = {e["key"]: e["value"] for e in payload["template"]["env"]}
+ assert "RUNPOD_MAX_CONCURRENCY" not in env
+
+
+class TestDeployPayload:
+ def test_config_params_reach_payload(self):
+ from runpod.apps.deploy import _deployed_endpoint_input
+
+ app = App("dep")
+
+ @app.queue(
+ name="que",
+ gpu="4090",
+ max_concurrency=2,
+ execution_timeout_ms=5000,
+ scaler_value=9,
+ min_cuda_version="12.8",
+ container_disk_gb=64,
+ )
+ def que():
+ pass
+
+ payload = _deployed_endpoint_input(app, que.spec, "env-1", "b-1", "3.12")
+ assert payload["scalerValue"] == 9
+ assert payload["executionTimeoutMs"] == 5000
+ assert payload["minCudaVersion"] == "12.8"
+ assert payload["template"]["containerDiskInGb"] == 64
+ env = {e["key"]: e["value"] for e in payload["template"]["env"]}
+ assert env["RUNPOD_MAX_CONCURRENCY"] == "2"
+
+ def test_flashboot_default_present(self):
+ from runpod.apps.deploy import _deployed_endpoint_input
+
+ app = App("dep")
+
+ @app.queue(name="que", cpu="cpu3c-1-2")
+ def que():
+ pass
+
+ payload = _deployed_endpoint_input(app, que.spec, "env-1", "b-1", "3.12")
+ assert payload["flashBootType"] == "FLASHBOOT"
+
+ def test_flashboot_disabled_omitted(self):
+ from runpod.apps.deploy import _deployed_endpoint_input
+
+ app = App("dep")
+
+ @app.queue(name="que", cpu="cpu3c-1-2", flashboot=False)
+ def que():
+ pass
+
+ payload = _deployed_endpoint_input(app, que.spec, "env-1", "b-1", "3.12")
+ assert "flashBootType" not in payload
+
+
+class TestTaskPayload:
+ def test_min_cuda_expands_to_allowed_versions(self):
+ from runpod.apps.tasks import _pod_input, cuda_versions_at_least
+
+ app = App("a")
+
+ @app.task(name="t", gpu="4090", min_cuda_version="12.8")
+ def t():
+ pass
+
+ pod = _pod_input(t.spec, "tok", "t")
+ assert pod["allowedCudaVersions"] == ["12.8", "12.9", "13.0"]
+ assert cuda_versions_at_least("12.9") == ["12.9", "13.0"]
+
+ def test_container_disk_override(self):
+ from runpod.apps.tasks import _pod_input
+
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu3c-1-2", container_disk_gb=25)
+ def t():
+ pass
+
+ pod = _pod_input(t.spec, "tok", "t")
+ assert pod["containerDiskInGb"] == 25
+
+
+class TestAccelerateDownloads:
+ def test_flag_travels_in_function_request(self):
+ from runpod.apps.targets import LiveTarget
+
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", accelerate_downloads=False)
+ def q(x: int):
+ return x
+
+ target = LiveTarget("ep-1", "q")
+ payload = target.build_payload(q._fn, q.spec, (1,), {})
+ assert payload["input"]["accelerate_downloads"] is False
+
+ def test_default_true(self):
+ from runpod.apps.targets import LiveTarget
+
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q(x: int):
+ return x
+
+ target = LiveTarget("ep-1", "q")
+ payload = target.build_payload(q._fn, q.spec, (1,), {})
+ assert payload["input"]["accelerate_downloads"] is True
diff --git a/tests/test_apps/test_deploy.py b/tests/test_apps/test_deploy.py
new file mode 100644
index 00000000..f8223061
--- /dev/null
+++ b/tests/test_apps/test_deploy.py
@@ -0,0 +1,257 @@
+"""tests for discovery, manifest building, and packaging."""
+
+import io
+import json
+import tarfile
+import textwrap
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+import runpod
+from runpod.apps import App
+from runpod.apps.app import _clear_registry
+from runpod.apps.deploy import (
+ DeployResult,
+ build_manifest,
+ deploy_app,
+ package_project,
+)
+from runpod.apps.discovery import DiscoveryError, discover_apps
+from runpod.apps.errors import ScheduleNotSupported
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+def _write_project(tmp_path: Path) -> Path:
+ (tmp_path / "main.py").write_text(
+ textwrap.dedent(
+ """
+ import runpod
+ from runpod import App
+
+ app = App("demo-app")
+
+ @app.queue(name="que1", cpu="cpu5c-2-4")
+ def que1(x: int):
+ return x * 2
+
+ if __name__ == "__main__":
+ raise SystemExit("main guard must not run during discovery")
+ """
+ )
+ )
+ return tmp_path
+
+
+class TestDiscovery:
+ def test_discovers_app(self, tmp_path):
+ _write_project(tmp_path)
+ apps = discover_apps(tmp_path)
+ assert len(apps) == 1
+ assert apps[0].name == "demo-app"
+ assert "que1" in apps[0].resources
+
+ def test_single_file_target(self, tmp_path):
+ _write_project(tmp_path)
+ apps = discover_apps(tmp_path / "main.py")
+ assert len(apps) == 1
+
+ def test_main_guard_not_executed(self, tmp_path):
+ _write_project(tmp_path)
+ # would raise SystemExit if __main__ ran
+ discover_apps(tmp_path)
+
+ def test_import_error_reported(self, tmp_path):
+ (tmp_path / "broken.py").write_text("import nonexistent_module_xyz")
+ with pytest.raises(DiscoveryError, match="broken.py"):
+ discover_apps(tmp_path)
+
+ def test_skips_venv_dirs(self, tmp_path):
+ _write_project(tmp_path)
+ venv = tmp_path / ".venv" / "lib"
+ venv.mkdir(parents=True)
+ (venv / "bad.py").write_text("import nonexistent_module_xyz")
+ apps = discover_apps(tmp_path)
+ assert len(apps) == 1
+
+ def test_non_python_file_rejected(self, tmp_path):
+ f = tmp_path / "notes.txt"
+ f.write_text("hi")
+ with pytest.raises(DiscoveryError):
+ discover_apps(f)
+
+
+class TestManifest:
+ def test_manifest_shape(self, tmp_path):
+ app = App("m-app")
+
+ @app.queue(name="qee", cpu="cpu5c-2-4", dependencies=["numpy"])
+ def qee(x):
+ return x
+
+ manifest = build_manifest(app, tmp_path)
+ assert manifest["app"] == "m-app"
+ assert manifest["version"] == 1
+ (resource,) = manifest["resources"]
+ assert resource["kind"] == "queue"
+ assert resource["name"] == "qee"
+ assert resource["dependencies"] == ["numpy"]
+ assert resource["qualname"]
+
+ def test_schedule_blocked_until_backend_support(self, tmp_path):
+ app = App("s-app")
+
+ @app.task(name="t")
+ @runpod.schedule(cron="0 * * * *")
+ def t():
+ pass
+
+ with pytest.raises(ScheduleNotSupported):
+ build_manifest(app, tmp_path)
+
+
+class TestPackaging:
+ def test_tarball_contains_source_and_manifest(self, tmp_path):
+ _write_project(tmp_path)
+ manifest = {"version": 1, "app": "demo-app", "resources": []}
+ tar_path = package_project(tmp_path, manifest)
+
+ with tarfile.open(tar_path) as tar:
+ names = tar.getnames()
+ assert "main.py" in names
+ assert "runpod_manifest.json" in names
+ extracted = json.load(tar.extractfile("runpod_manifest.json"))
+ assert extracted["app"] == "demo-app"
+
+ def test_ignores_applied(self, tmp_path):
+ _write_project(tmp_path)
+ (tmp_path / "secret.env").write_text("KEY=1")
+ (tmp_path / ".runpodignore").write_text("secret.env\n")
+ pycache = tmp_path / "__pycache__"
+ pycache.mkdir()
+ (pycache / "x.pyc").write_text("junk")
+
+ tar_path = package_project(tmp_path, {"version": 1, "resources": []})
+ with tarfile.open(tar_path) as tar:
+ names = tar.getnames()
+ assert "secret.env" not in names
+ assert not any("__pycache__" in n for n in names)
+
+ def test_vendored_env_included_under_env(self, tmp_path):
+ _write_project(tmp_path)
+ env_dir = tmp_path / "built-env"
+ (env_dir / "numpy").mkdir(parents=True)
+ (env_dir / "numpy" / "__init__.py").write_text("")
+
+ tar_path = package_project(
+ tmp_path, {"version": 1, "resources": []}, env_dir=env_dir
+ )
+ with tarfile.open(tar_path) as tar:
+ names = tar.getnames()
+ assert "env/numpy/__init__.py" in names
+ # env dir under project root must not be double-added as source
+ assert "built-env/numpy/__init__.py" not in names
+ assert "main.py" in names
+
+
+def _stub_build(tmp_path):
+ """patch environment vendoring with a tiny fake env tree."""
+ from runpod.apps.build import BuildResult
+
+ env_dir = tmp_path / "fake-env"
+ env_dir.mkdir(exist_ok=True)
+ (env_dir / "vendored_pkg.py").write_text("x = 1")
+ return patch(
+ "runpod.apps.deploy.build_environment",
+ return_value=BuildResult(env_dir=env_dir, requirements=["runpod"]),
+ )
+
+
+class TestDeployPipeline:
+ async def test_deploy_app_full_flow(self, tmp_path):
+ _write_project(tmp_path)
+ (app,) = discover_apps(tmp_path)
+
+ api = AsyncMock()
+ api.get_app_by_name.return_value = None
+ api.create_app.return_value = {"id": "app-1", "flashEnvironments": []}
+ api.create_environment.return_value = {"id": "env-1", "name": "default"}
+ api.prepare_artifact_upload.return_value = {
+ "uploadUrl": "https://upload",
+ "objectKey": "key-1",
+ }
+ api.finalize_artifact_upload.return_value = {"id": "build-1"}
+ api.deploy_build.return_value = {"id": "env-1"}
+
+ with _stub_build(tmp_path):
+ result = await deploy_app(app, tmp_path, api=api)
+
+ assert isinstance(result, DeployResult)
+ assert result.build_id == "build-1"
+ assert result.resources == ["que1"]
+ api.upload_tarball.assert_awaited_once()
+ api.deploy_build.assert_awaited_once_with("env-1", "build-1")
+
+ async def test_deploy_reuses_existing_app_and_env(self, tmp_path):
+ _write_project(tmp_path)
+ (app,) = discover_apps(tmp_path)
+
+ api = AsyncMock()
+ api.get_app_by_name.return_value = {
+ "id": "app-1",
+ "flashEnvironments": [{"id": "env-1", "name": "default"}],
+ }
+ api.prepare_artifact_upload.return_value = {
+ "uploadUrl": "https://upload",
+ "objectKey": "key-1",
+ }
+ api.finalize_artifact_upload.return_value = {"id": "build-2"}
+
+ with _stub_build(tmp_path):
+ await deploy_app(app, tmp_path, api=api)
+
+ api.create_app.assert_not_awaited()
+ api.create_environment.assert_not_awaited()
+
+
+class TestTolerantDiscovery:
+ def test_broken_bystander_warns_but_discovers(self, tmp_path):
+ _write_project(tmp_path)
+ (tmp_path / "scratch.py").write_text("this is not python !!!")
+ apps = discover_apps(tmp_path)
+ assert len(apps) == 1
+
+ def test_single_file_target_stays_strict(self, tmp_path):
+ broken = tmp_path / "broken.py"
+ broken.write_text("import nonexistent_module_xyz")
+ with pytest.raises(DiscoveryError, match="broken.py"):
+ discover_apps(broken)
+
+ def test_no_apps_and_failures_raises_with_causes(self, tmp_path):
+ (tmp_path / "broken.py").write_text("import nonexistent_module_xyz")
+ with pytest.raises(DiscoveryError, match="broken.py"):
+ discover_apps(tmp_path)
+
+ def test_import_time_invocation_diagnosed(self, tmp_path):
+ _write_project(tmp_path)
+ (tmp_path / "client.py").write_text(
+ "from main import que1\nque1.remote(1)\n"
+ )
+ # directory walk: the client file fails with the precise
+ # diagnosis but the app still discovers
+ apps = discover_apps(tmp_path)
+ assert len(apps) == 1
+
+ def test_import_time_invocation_message(self, tmp_path):
+ _write_project(tmp_path)
+ client = tmp_path / "client.py"
+ client.write_text("from main import que1\nque1.remote(1)\n")
+ with pytest.raises(DiscoveryError, match="invoked at import time"):
+ discover_apps(client)
diff --git a/tests/test_apps/test_dev.py b/tests/test_apps/test_dev.py
new file mode 100644
index 00000000..4ad35661
--- /dev/null
+++ b/tests/test_apps/test_dev.py
@@ -0,0 +1,470 @@
+"""tests for dev session provisioning."""
+
+from unittest.mock import AsyncMock
+
+import pytest
+
+import runpod
+from runpod.apps import App
+from runpod.apps.app import _clear_registry
+from runpod.apps.dev import DevSession, _endpoint_input, dev_endpoint_name
+from runpod.apps.targets import LiveTarget
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+def _mock_api():
+ api = AsyncMock()
+ api.list_my_endpoints.return_value = []
+ api.save_endpoint.return_value = {"id": "ep-new"}
+ api.delete_endpoint.return_value = True
+ return api
+
+
+class TestEndpointInput:
+ def test_cpu_queue_payload(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", workers=(0, 1))
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["name"].startswith("dev-a-q-")
+ assert payload["instanceIds"] == ["cpu3c-1-2"]
+ assert "EU-RO-1" in payload["locations"]
+ assert "US-KS-2" in payload["locations"]
+ assert payload["scalerType"] == "QUEUE_DELAY"
+ assert payload["flashBootType"] == "FLASHBOOT"
+
+ def test_cpu5_pins_to_stocked_datacenters(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu5c-2-4")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["locations"] == "EU-RO-1"
+
+ def test_cpu_locations_are_storage_supported(self):
+ from runpod.apps.datacenter import DataCenter
+
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ valid = {dc.value for dc in DataCenter}
+ for loc in payload["locations"].split(","):
+ assert loc in valid
+ template = payload["template"]
+ assert template["dockerArgs"] == ""
+ env = {e["key"]: e["value"] for e in template["env"]}
+ assert env["RUNPOD_DEV_GENERATION"] == "1"
+ # nested .remote() support: dev-session marker always present
+ assert env["RUNPOD_DEV_APP"] == app.name
+
+ def test_api_key_forwarded_when_configured(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "rpa_test123")
+ app = App("keyed")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ env = {e["key"]: e["value"] for e in payload["template"]["env"]}
+ assert env["RUNPOD_API_KEY"] == "rpa_test123"
+ assert "gpuIds" not in payload
+
+ def test_gpu_queue_payload(self):
+ app = App("a")
+
+ @app.queue(name="q", gpu=runpod.GpuGroup.ADA_24)
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["gpuIds"] == "ADA_24"
+ assert payload["gpuCount"] == 1
+ assert "instanceIds" not in payload
+ assert "locations" not in payload
+
+ def test_api_payload_is_lb(self):
+ app = App("a")
+
+ @app.api(name="api", cpu="cpu3c-1-2")
+ class Api:
+ @runpod.post("/x")
+ def x(self, body: dict):
+ return body
+
+ payload = _endpoint_input(app, Api.spec)
+ assert payload["type"] == "LB"
+ assert payload["scalerType"] == "REQUEST_COUNT"
+
+ def test_datacenter_pins_locations(self):
+ app = App("a")
+
+ @app.queue(name="q", gpu=runpod.GpuGroup.ADA_24, datacenter="US-KS-2")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["locations"] == "US-KS-2"
+
+ def test_env_forwarded_to_template(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2", env={"K": "v"})
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert {"key": "K", "value": "v"} in payload["template"]["env"]
+
+ def test_generation_stamped_in_template_env(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec, generation=7)
+ assert {"key": "RUNPOD_DEV_GENERATION", "value": "7"} in payload[
+ "template"
+ ]["env"]
+
+
+class TestDevSession:
+ async def test_start_provisions_and_registers_targets(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = _mock_api()
+ session = DevSession([app], api=api)
+ await session.start()
+
+ api.save_endpoint.assert_awaited_once()
+ target = app._dev_targets["q"]
+ assert isinstance(target, LiveTarget)
+ assert target.endpoint_id == "ep-new"
+
+ async def test_start_adopts_and_reconciles_existing_endpoint(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = _mock_api()
+ api.list_my_endpoints.return_value = [
+ {"id": "ep-old", "name": dev_endpoint_name("a", "q")}
+ ]
+ api.save_endpoint.return_value = {"id": "ep-old"}
+ session = DevSession([app], api=api)
+ await session.start()
+
+ # adopted endpoints are reconciled via saveEndpoint with the id set
+ payload = api.save_endpoint.await_args.args[0]
+ assert payload["id"] == "ep-old"
+ assert app._dev_targets["q"].endpoint_id == "ep-old"
+
+ async def test_stop_deletes_all_session_endpoints(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = _mock_api()
+ session = DevSession([app], api=api)
+ await session.start()
+ await session.stop()
+
+ api.delete_endpoint.assert_awaited_once_with("ep-new")
+ assert app._dev_targets == {}
+
+ async def test_tasks_not_provisioned(self):
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu3c-1-2")
+ def t():
+ pass
+
+ api = _mock_api()
+ session = DevSession([app], api=api)
+ await session.start()
+
+ api.save_endpoint.assert_not_awaited()
+
+
+class TestDevRefresh:
+ async def test_refresh_bumps_generation_and_updates(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = _mock_api()
+ api.save_endpoint.return_value = {"id": "ep-1"}
+ session = DevSession([app], api=api)
+ await session.start()
+
+ await session.refresh([app])
+
+ payload = api.save_endpoint.await_args.args[0]
+ assert payload["id"] == "ep-1"
+ assert {"key": "RUNPOD_DEV_GENERATION", "value": "2"} in payload[
+ "template"
+ ]["env"]
+ assert session.generation == 2
+
+ async def test_refresh_provisions_added_resource(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = _mock_api()
+ api.save_endpoint.return_value = {"id": "ep-1"}
+ session = DevSession([app], api=api)
+ await session.start()
+
+ _clear_registry()
+ app2 = App("a")
+
+ @app2.queue(name="q", cpu="cpu3c-1-2")
+ def q2():
+ pass
+
+ @app2.queue(name="extra", cpu="cpu3c-1-2")
+ def extra():
+ pass
+
+ api.save_endpoint.return_value = {"id": "ep-2"}
+ await session.refresh([app2])
+
+ names = {
+ c.args[0]["name"] for c in api.save_endpoint.await_args_list
+ }
+ assert dev_endpoint_name("a", "extra") in names
+ assert "extra" in app2._dev_targets
+
+ async def test_refresh_deletes_removed_resource(self):
+ app = App("a")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ @app.queue(name="gone", cpu="cpu3c-1-2")
+ def gone():
+ pass
+
+ api = _mock_api()
+ api.save_endpoint.side_effect = [{"id": "ep-q"}, {"id": "ep-gone"}]
+ session = DevSession([app], api=api)
+ await session.start()
+
+ _clear_registry()
+ app2 = App("a")
+
+ @app2.queue(name="q", cpu="cpu3c-1-2")
+ def q2():
+ pass
+
+ api.save_endpoint.side_effect = None
+ api.save_endpoint.return_value = {"id": "ep-q"}
+ await session.refresh([app2])
+
+ api.delete_endpoint.assert_awaited_once_with("ep-gone")
+
+
+class TestDevEvents:
+ async def test_lifecycle_events_emitted(self):
+ events = []
+
+ class Sink:
+ def provisioning(self, name, kind, hardware):
+ events.append(("provisioning", name, kind, hardware))
+
+ def adopted(self, name, endpoint_id):
+ events.append(("adopted", name, endpoint_id))
+
+ def ready(self, name, endpoint_id):
+ events.append(("ready", name, endpoint_id))
+
+ def resource_changed(self, name, fields):
+ events.append(("resource_changed", name, fields))
+
+ def resource_added(self, name, kind, hardware):
+ events.append(("resource_added", name, kind, hardware))
+
+ def resource_removed(self, name):
+ events.append(("resource_removed", name))
+
+ def deleted(self, name):
+ events.append(("deleted", name))
+
+ app = App("ev-app")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = AsyncMock()
+ api.list_my_endpoints.return_value = []
+ api.save_endpoint.return_value = {"id": "ep-1"}
+ api.delete_endpoint.return_value = True
+
+ session = DevSession([app], api=api, events=Sink())
+ await session.start()
+ await session.refresh([app])
+ await session.stop()
+
+ kinds = [e[0] for e in events]
+ assert "provisioning" in kinds
+ assert "ready" in kinds
+ assert "deleted" in kinds
+ # unchanged resource refreshes silently: no diff events
+ assert "resource_changed" not in kinds
+ assert "resource_added" not in kinds
+ assert ("provisioning", "q", "queue", "cpu3c-1-2") in events
+ # deleted reports the resource name, not the endpoint name
+ assert ("deleted", "q") in events
+
+ async def test_refresh_diff_events(self):
+ events = []
+
+ class Sink:
+ def resource_added(self, name, kind, hardware):
+ events.append(("added", name, kind, hardware))
+
+ def resource_changed(self, name, fields):
+ events.append(("changed", name, tuple(fields)))
+
+ def resource_removed(self, name):
+ events.append(("removed", name))
+
+ app = App("diff-app")
+
+ @app.queue(name="stays", cpu="cpu3c-1-2")
+ def stays():
+ pass
+
+ @app.queue(name="goes", cpu="cpu3c-1-2")
+ def goes():
+ pass
+
+ api = AsyncMock()
+ api.list_my_endpoints.return_value = []
+ api.save_endpoint.side_effect = lambda p: {"id": f"ep-{p['name']}"}
+ api.delete_endpoint.return_value = True
+
+ session = DevSession([app], api=api, events=Sink())
+ await session.start()
+
+ # rescan: 'goes' removed, 'stays' reconfigured, 'fresh' added
+ app2 = App("diff-app")
+
+ @app2.queue(name="stays", cpu="cpu3c-1-2", workers=(1, 4))
+ def stays2():
+ pass
+
+ @app2.queue(name="fresh", cpu="cpu5c-2-4")
+ def fresh():
+ pass
+
+ await session.refresh([app2])
+
+ assert ("removed", "goes") in events
+ added = [e for e in events if e[0] == "added"]
+ assert ("added", "fresh", "queue", "cpu5c-2-4") in added
+ changed = [e for e in events if e[0] == "changed"]
+ assert len(changed) == 1
+ assert changed[0][1] == "stays"
+ assert "workersMin" in changed[0][2] or "workersMax" in changed[0][2]
+
+ async def test_missing_event_methods_ignored(self):
+ app = App("ev-app2")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = AsyncMock()
+ api.list_my_endpoints.return_value = []
+ api.save_endpoint.return_value = {"id": "ep-1"}
+
+ # events object with no handlers at all must not break anything
+ session = DevSession([app], api=api, events=object())
+ await session.start()
+ await session.stop()
+
+
+class TestGpuIdsPayload:
+ def test_no_gpu_selection_expands_to_all_pools(self):
+ # the api rejects "any"; an unconstrained gpu queue asks for
+ # every pool id instead
+ app = App("gpu-any")
+
+ @app.queue(name="q")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ gpu_ids = payload["gpuIds"].split(",")
+ assert "any" not in gpu_ids
+ assert "ADA_24" in gpu_ids and "AMPERE_80" in gpu_ids
+
+ def test_explicit_pools_pass_through(self):
+ app = App("gpu-explicit")
+
+ @app.queue(name="q", gpu="ADA_24")
+ def q():
+ pass
+
+ payload = _endpoint_input(app, q.spec)
+ assert payload["gpuIds"] == "ADA_24"
+
+
+class TestRefreshKeepsTaskEvents:
+ async def test_dev_events_reattached_after_refresh(self):
+ sink = object()
+ app = App("evt-app")
+
+ @app.queue(name="q", cpu="cpu3c-1-2")
+ def q():
+ pass
+
+ api = AsyncMock()
+ api.list_my_endpoints.return_value = []
+ api.save_endpoint.return_value = {"id": "ep-1"}
+
+ session = DevSession([app], api=api, events=sink)
+ await session.start()
+ assert app._dev_events is sink
+
+ # file change: discovery builds a brand-new app instance
+ app2 = App("evt-app")
+
+ @app2.queue(name="q", cpu="cpu3c-1-2")
+ def q2():
+ pass
+
+ await session.refresh([app2])
+ assert app2._dev_events is sink
diff --git a/tests/test_apps/test_dispatch.py b/tests/test_apps/test_dispatch.py
new file mode 100644
index 00000000..a6dd0388
--- /dev/null
+++ b/tests/test_apps/test_dispatch.py
@@ -0,0 +1,520 @@
+"""tests for context detection and remote dispatch."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+import runpod
+from runpod.apps import App, Context, current_context, is_local
+from runpod.apps.app import _clear_registry
+from runpod.apps.errors import (
+ EndpointNotFound,
+ InvalidResourceError,
+ RemoteExecutionError,
+)
+from runpod.apps.targets import (
+ SentinelTarget,
+ args_to_input,
+ unwrap_job_output,
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+class TestContextDetection:
+ def test_local_by_default(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ assert current_context() is Context.LOCAL
+ assert is_local() is True
+
+ def test_worker_via_endpoint_id(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1")
+ assert current_context() is Context.WORKER
+ assert is_local() is False
+
+ def test_worker_via_pod_id(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_ENDPOINT_ID", raising=False)
+ monkeypatch.setenv("RUNPOD_POD_ID", "pod-1")
+ assert current_context() is Context.WORKER
+
+ def test_dev_session(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID"):
+ monkeypatch.delenv(var, raising=False)
+ monkeypatch.setenv("RUNPOD_DEV_SESSION", "1")
+ assert current_context() is Context.DEV
+ assert is_local() is True
+
+
+class TestArgsToInput:
+ def test_positional_mapped_to_names(self):
+ def fn(prompt, temp):
+ pass
+
+ assert args_to_input(fn, ("hi", 0.5), {}) == {"prompt": "hi", "temp": 0.5}
+
+ def test_kwargs_merged(self):
+ def fn(a, b):
+ pass
+
+ assert args_to_input(fn, (1,), {"b": 2}) == {"a": 1, "b": 2}
+
+ def test_empty_gets_placeholder(self):
+ def fn():
+ pass
+
+ assert args_to_input(fn, (), {}) == {"__empty": True}
+
+ def test_too_many_positional(self):
+ def fn(a):
+ pass
+
+ with pytest.raises(TypeError):
+ args_to_input(fn, (1, 2), {})
+
+
+class TestUnwrapJobOutput:
+ def test_output_extracted(self):
+ assert unwrap_job_output({"status": "COMPLETED", "output": {"x": 1}}) == {
+ "x": 1
+ }
+
+ def test_failed_raises(self):
+ with pytest.raises(RemoteExecutionError):
+ unwrap_job_output({"status": "FAILED", "error": "boom"})
+
+ def test_error_in_output_raises(self):
+ with pytest.raises(RemoteExecutionError):
+ unwrap_job_output({"status": "COMPLETED", "output": {"error": "bad"}})
+
+
+class TestRemoteDispatch:
+ def test_remote_sync_via_sentinel(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = {"doubled": 4}
+ result = q.remote(2)
+
+ assert result == {"doubled": 4}
+ mock_invoke.assert_awaited_once_with({"input": {"x": 2}})
+
+ async def test_remote_aio(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ async def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = {"ok": True}
+ result = await q.remote.aio(1)
+
+ assert result == {"ok": True}
+
+ def test_remote_with_options_merges_payload(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = {"ok": 1}
+ q.with_options(
+ webhook="https://hook", execution_timeout=5000, low_priority=True
+ ).remote(2)
+
+ mock_invoke.assert_awaited_once_with(
+ {
+ "input": {"x": 2},
+ "webhook": "https://hook",
+ "policy": {"executionTimeout": 5000, "lowPriority": True},
+ }
+ )
+
+ def test_spawn_with_options_merges_payload(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "submit", new_callable=AsyncMock
+ ) as mock_submit:
+ mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"}
+ q.with_options(s3_config={"bucketName": "b"}).spawn(1)
+
+ mock_submit.assert_awaited_once_with(
+ {"input": {"x": 1}, "s3Config": {"bucketName": "b"}}
+ )
+
+ def test_with_options_returns_new_handle(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ bound = q.with_options(webhook="https://hook")
+ assert bound is not q
+ assert q._job_options == {}
+ # policy fields deep-merge across chained calls
+ chained = q.with_options(execution_timeout=1).with_options(ttl=2)
+ assert chained._job_options == {"policy": {"executionTimeout": 1, "ttl": 2}}
+
+ def test_with_options_rejects_tasks(self):
+ app = App("my-app")
+
+ @app.task(name="t")
+ def t(x):
+ return x
+
+ with pytest.raises(InvalidResourceError, match="only to @app.queue"):
+ t.with_options(webhook="https://hook")
+
+ def test_worker_executes_own_body(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1")
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "q")
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x * 10
+
+ assert q.remote(4) == 40
+
+ def test_worker_calls_sibling_via_sentinel(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1")
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "other")
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = "remote-result"
+ result = q.remote(1)
+
+ assert result == "remote-result"
+
+ def test_dev_without_provisioned_target_raises(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID"):
+ monkeypatch.delenv(var, raising=False)
+ monkeypatch.setenv("RUNPOD_DEV_SESSION", "1")
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q():
+ pass
+
+ with pytest.raises(EndpointNotFound):
+ q.remote()
+
+ def test_spawn_returns_job(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "submit", new_callable=AsyncMock
+ ) as mock_submit:
+ mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"}
+ job = q.spawn(1)
+
+ assert isinstance(job, runpod.Job)
+ assert job.id == "job-1"
+
+ def test_stream_generator_function(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="gen")
+ def gen(n):
+ yield from range(n)
+
+ async def fake_stream(self, job_id, *, timeout=300.0):
+ for chunk in ("x", "y"):
+ yield chunk
+
+ with (
+ patch.object(
+ SentinelTarget, "submit", new_callable=AsyncMock
+ ) as mock_submit,
+ patch.object(SentinelTarget, "stream_job", fake_stream),
+ ):
+ mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"}
+ chunks = list(gen.stream(2))
+
+ assert chunks == ["x", "y"]
+
+ def test_stream_rejects_non_generator(self, monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="plain")
+ def plain(x):
+ return x
+
+ with pytest.raises(InvalidResourceError, match="not a generator"):
+ list(plain.stream(1))
+
+
+class TestStubs:
+ def test_queue_stub_requires_name_or_id(self):
+ with pytest.raises(ValueError):
+ runpod.Queue(app="a")
+ with pytest.raises(ValueError):
+ runpod.Queue(app="a", name="x", id="y")
+
+ def test_queue_stub_remote(self):
+ stub = runpod.Queue(app="other-app", name="q")
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = {"ok": 1}
+ result = stub.remote(prompt="hi")
+
+ assert result == {"ok": 1}
+ mock_invoke.assert_awaited_once_with({"input": {"prompt": "hi"}})
+
+ def test_queue_stub_with_options(self):
+ stub = runpod.Queue(app="other-app", name="q")
+ bound = stub.with_options(webhook="https://hook", ttl=1000)
+ assert bound is not stub
+ assert stub._job_options == {}
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = {"ok": 1}
+ bound.remote(prompt="hi")
+
+ mock_invoke.assert_awaited_once_with(
+ {
+ "input": {"prompt": "hi"},
+ "webhook": "https://hook",
+ "policy": {"ttl": 1000},
+ }
+ )
+
+ def test_queue_stub_spawn_returns_job(self):
+ stub = runpod.Queue(app="other-app", name="q")
+ with patch.object(
+ SentinelTarget, "submit", new_callable=AsyncMock
+ ) as mock_submit:
+ mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"}
+ job = stub.spawn(prompt="hi")
+
+ assert isinstance(job, runpod.Job)
+ assert job.id == "job-1"
+
+ def test_queue_stub_stream(self):
+ stub = runpod.Queue(app="other-app", name="q")
+
+ async def fake_stream(self, job_id, *, timeout=300.0):
+ for chunk in ("x", "y"):
+ yield chunk
+
+ with (
+ patch.object(
+ SentinelTarget, "submit", new_callable=AsyncMock
+ ) as mock_submit,
+ patch.object(SentinelTarget, "stream_job", fake_stream),
+ ):
+ mock_submit.return_value = {"id": "job-1", "status": "IN_QUEUE"}
+ chunks = list(stub.stream(prompt="hi"))
+
+ assert chunks == ["x", "y"]
+
+ def test_queue_stub_reconnects_to_job(self):
+ stub = runpod.Queue(app="other-app", name="q")
+ job = stub.job("job-1")
+
+ assert isinstance(job, runpod.Job)
+ assert job.id == "job-1"
+
+ def test_api_stub_http(self):
+ stub = runpod.Api(app="other-app", name="api")
+ with patch.object(
+ SentinelTarget, "request", new_callable=AsyncMock
+ ) as mock_request:
+ mock_request.return_value = {"status": "healthy"}
+ result = stub.get("/health")
+
+ assert result == {"status": "healthy"}
+ mock_request.assert_awaited_once_with("GET", "/health", None)
+
+
+class TestSyncBridge:
+ def test_remote_inside_running_loop(self, monkeypatch):
+ """calling sync .remote() from inside an event loop must not raise."""
+ import asyncio
+
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = App("my-app")
+
+ @app.queue(name="q")
+ def q(x):
+ return x
+
+ with patch.object(
+ SentinelTarget, "invoke", new_callable=AsyncMock
+ ) as mock_invoke:
+ mock_invoke.return_value = "bridged"
+
+ async def caller():
+ return q.remote(1)
+
+ result = asyncio.run(caller())
+
+ assert result == "bridged"
+
+
+class TestModuleSourceShipping:
+ def test_whole_module_ships(self, tmp_path):
+ import importlib.util
+ import sys
+
+ mod_file = tmp_path / "shipmod.py"
+ mod_file.write_text(
+ "import asyncio\n"
+ "from pathlib import Path\n"
+ "\n"
+ "GREETING = 'hello'\n"
+ "\n"
+ "async def waiter():\n"
+ " await asyncio.sleep(0)\n"
+ " return f'{GREETING} {Path(\"x\")}'\n"
+ "\n"
+ "if __name__ == '__main__':\n"
+ " raise SystemExit('main guard must not run')\n"
+ )
+ spec = importlib.util.spec_from_file_location("shipmod", mod_file)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ sys.modules["shipmod"] = mod
+ try:
+ from runpod.apps.serialization import get_function_source
+
+ source = get_function_source(mod.waiter)
+ # the entire module ships: imports and globals included
+ assert "import asyncio" in source
+ assert "GREETING = 'hello'" in source
+
+ # executes like deployed-mode module import: main guard inert
+ namespace = {"__name__": "__runpod_live__"}
+ exec(source, namespace)
+ import asyncio as aio
+
+ assert aio.run(namespace["waiter"]()) == "hello x"
+ finally:
+ sys.modules.pop("shipmod", None)
+
+ def test_execute_request_unwraps_handles(self):
+ # a shipped module defines decorated handles; the runner must
+ # call the wrapped function, not the handle
+ from runpod.runtimes.executor import execute_request
+
+ code = (
+ "import runpod\n"
+ "app = runpod.App('t')\n"
+ "@app.queue()\n"
+ "def double(x):\n"
+ " return x * 2\n"
+ )
+ response = execute_request(
+ {
+ "function_name": "double",
+ "function_code": code,
+ "args": [],
+ "kwargs": {"x": "AAA"},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"], response.get("error")
+ assert response["json_result"] == "AAAAAA"
+
+ def test_nested_source_extraction_from_shipped_module(self):
+ # inside a live worker, a function calling sibling.remote()
+ # re-extracts that sibling's source via inspect; the shipped
+ # module must be inspectable after exec
+ from runpod.runtimes.executor import execute_request
+
+ code = (
+ "import runpod\n"
+ "app = runpod.App('t2')\n"
+ "@app.queue()\n"
+ "def sibling(x):\n"
+ " return x\n"
+ "@app.queue()\n"
+ "def caller():\n"
+ " from runpod.apps.serialization import get_function_source\n"
+ " return get_function_source(sibling._fn)\n"
+ )
+ response = execute_request(
+ {
+ "function_name": "caller",
+ "function_code": code,
+ "args": [],
+ "kwargs": {},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"], response.get("error")
+ # the whole module re-ships (decorators intact), matching what
+ # the first hop sent
+ assert "def sibling" in response["json_result"]
+ assert "@app.queue()" in response["json_result"]
+
+ def test_missing_module_on_deserialize_is_actionable(self):
+ import base64
+
+ import cloudpickle
+ import pytest
+
+ from runpod.apps.errors import RemoteExecutionError
+ from runpod.apps.serialization import deserialize_result
+
+ class FakePickle:
+ def __reduce__(self):
+ return (__import__, ("nonexistent_pkg_xyz",))
+
+ payload = base64.b64encode(cloudpickle.dumps(FakePickle())).decode()
+ with pytest.raises(RemoteExecutionError, match="nonexistent_pkg_xyz"):
+ deserialize_result(payload)
diff --git a/tests/test_apps/test_executor.py b/tests/test_apps/test_executor.py
new file mode 100644
index 00000000..01205e70
--- /dev/null
+++ b/tests/test_apps/test_executor.py
@@ -0,0 +1,195 @@
+"""unit tests for the shared FunctionRequest execution engine."""
+
+import pytest
+
+from runpod.runtimes import executor
+
+
+def test_execute_roundtrip_json():
+ response = executor.execute_request(
+ {
+ "function_name": "mul",
+ "function_code": "def mul(a, b):\n return a * b",
+ "args": [6, 7],
+ "kwargs": {},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"] is True
+ assert response["json_result"] == 42
+
+
+def test_json_result_rejects_non_json_return():
+ with pytest.raises(TypeError, match="json-serializable"):
+ executor._serialize_result(object(), "json")
+
+
+def test_serialize_chunk_marks_stream():
+ chunk = executor.serialize_chunk("tok", {"serialization_format": "json"})
+ assert chunk == {"success": True, "__stream__": True, "json_result": "tok"}
+
+
+def test_resolve_request_returns_callable():
+ prepared, error = executor.resolve_request(
+ {
+ "function_name": "double",
+ "function_code": "def double(x):\n return x * 2",
+ "kwargs": {},
+ "args": [],
+ "serialization_format": "json",
+ }
+ )
+ assert error is None
+ fn, args, kwargs = prepared
+ assert fn(4) == 8
+
+
+def test_resolve_request_missing_function():
+ prepared, error = executor.resolve_request(
+ {"function_name": "nope", "function_code": "x = 1"}
+ )
+ assert prepared is None
+ assert error["success"] is False
+ assert "not found" in error["error"]
+
+
+def test_generator_results_aggregate():
+ response = executor.execute_request(
+ {
+ "function_name": "gen",
+ "function_code": "def gen(n):\n yield from range(n)",
+ "args": [],
+ "kwargs": {"n": 3},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"] is True
+ assert response["json_result"] == [0, 1, 2]
+
+
+class TestSystemDependencies:
+ def test_missing_apt_reports_error(self, monkeypatch):
+ import shutil
+
+ monkeypatch.setattr(shutil, "which", lambda _: None)
+ response = executor.execute_request(
+ {
+ "function_name": "f",
+ "function_code": "def f():\n return 1",
+ "system_dependencies": ["ffmpeg"],
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"] is False
+ assert "apt-get is not available" in response["error"]
+
+ def test_system_deps_installed_before_execution(self, monkeypatch):
+ import shutil
+
+ monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/apt-get")
+ monkeypatch.setattr(executor, "_apt_updated", False)
+ calls = []
+
+ class R:
+ returncode = 0
+ stderr = ""
+
+ monkeypatch.setattr(
+ executor.subprocess,
+ "run",
+ lambda cmd, **kw: calls.append(cmd) or R(),
+ )
+ response = executor.execute_request(
+ {
+ "function_name": "f",
+ "function_code": "def f():\n return 40 + 2",
+ "system_dependencies": ["ffmpeg"],
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"] is True
+ assert response["json_result"] == 42
+ assert calls[0][:2] == ["apt-get", "update"]
+ assert "ffmpeg" in calls[1]
+
+ def test_apt_update_runs_once(self, monkeypatch):
+ import shutil
+
+ monkeypatch.setattr(shutil, "which", lambda _: "/usr/bin/apt-get")
+ monkeypatch.setattr(executor, "_apt_updated", False)
+ calls = []
+
+ class R:
+ returncode = 0
+ stderr = ""
+
+ monkeypatch.setattr(
+ executor.subprocess,
+ "run",
+ lambda cmd, **kw: calls.append(cmd) or R(),
+ )
+ executor._install_system(["ffmpeg"])
+ executor._install_system(["sox"])
+ updates = [c for c in calls if c[:2] == ["apt-get", "update"]]
+ assert len(updates) == 1
+
+
+class TestStdoutTee:
+ def test_prints_reach_real_stdout_and_response(self, capfd):
+ response = executor.execute_request(
+ {
+ "function_name": "speak",
+ "function_code": "def speak():\n print('live line')\n return 1\n",
+ "args": [],
+ "kwargs": {},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"]
+ # captured for the job response
+ assert "live line" in response["stdout"]
+ # and written through to the container's stdout for log streams
+ assert "live line" in capfd.readouterr().out
+
+
+class TestCloudpickleLoading:
+ def test_available_returns_module(self):
+ module = executor._load_cloudpickle()
+ assert module is not None
+ assert hasattr(module, "dumps")
+
+ def test_missing_without_install_returns_none(self, monkeypatch):
+ import builtins
+
+ real_import = builtins.__import__
+
+ def no_cloudpickle(name, *args, **kwargs):
+ if name == "cloudpickle":
+ raise ImportError("not installed")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", no_cloudpickle)
+ assert executor._load_cloudpickle(install=False) is None
+
+
+class TestInstallHelpers:
+ def test_install_empty_is_noop(self):
+ assert executor._install([], "nothing") is None
+
+ def test_install_failure_returns_message(self, monkeypatch):
+ from unittest.mock import MagicMock
+
+ result = MagicMock(returncode=1, stderr="resolver exploded")
+ monkeypatch.setattr(
+ executor.subprocess, "run", MagicMock(return_value=result)
+ )
+ message = executor._install(["ghost-package"], "deps")
+ assert "resolver exploded" in message
+
+ def test_install_system_requires_apt(self, monkeypatch):
+ monkeypatch.setattr(executor.shutil, "which", lambda _: None)
+ message = executor._install_system(["ffmpeg"])
+ assert "apt-get" in message
+
+ def test_install_system_empty_is_noop(self):
+ assert executor._install_system([]) is None
diff --git a/tests/test_apps/test_images.py b/tests/test_apps/test_images.py
new file mode 100644
index 00000000..87e7dfc2
--- /dev/null
+++ b/tests/test_apps/test_images.py
@@ -0,0 +1,83 @@
+"""tests for runtime image selection."""
+
+import pytest
+
+from runpod.apps.images import (
+ SUPPORTED_PYTHON_VERSIONS,
+ image_for_spec,
+ local_python_version,
+ runtime_image,
+)
+from runpod.apps.spec import ResourceKind, ResourceSpec
+
+
+class TestRuntimeImage:
+ @pytest.mark.parametrize("kind", list(ResourceKind))
+ @pytest.mark.parametrize("gpu", [False, True])
+ @pytest.mark.parametrize("version", SUPPORTED_PYTHON_VERSIONS)
+ def test_full_matrix_resolves(self, kind, gpu, version):
+ image = runtime_image(kind, gpu=gpu, python_version=version)
+ assert image.startswith("runpod/")
+ assert f"py{version}-" in image
+ assert ("-gpu:" in image) == gpu
+
+ def test_unsupported_python_rejected(self):
+ with pytest.raises(ValueError, match="3.9"):
+ runtime_image(ResourceKind.QUEUE, gpu=False, python_version="3.9")
+
+ def test_tag_channel_from_env(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_RUNTIME_TAG", "dev")
+ image = runtime_image(ResourceKind.TASK, gpu=True, python_version="3.11")
+ assert image == "runpod/task-gpu:py3.11-dev"
+
+ def test_default_channel_is_latest(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_RUNTIME_TAG", raising=False)
+ image = runtime_image(ResourceKind.API, gpu=False, python_version="3.12")
+ assert image == "runpod/api:py3.12-latest"
+
+
+class TestImageForSpec:
+ def test_custom_image_wins(self):
+ spec = ResourceSpec(
+ kind=ResourceKind.QUEUE, name="q", image="my/image:1"
+ )
+ assert image_for_spec(spec) == "my/image:1"
+
+ def test_cpu_spec_gets_cpu_image(self):
+ spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q", cpu=["cpu3c-1-2"])
+ assert image_for_spec(spec, python_version="3.12") == (
+ "runpod/queue:py3.12-latest"
+ )
+
+ def test_gpu_spec_gets_gpu_image(self):
+ spec = ResourceSpec(kind=ResourceKind.QUEUE, name="q", gpu=["ADA_24"])
+ assert image_for_spec(spec, python_version="3.12") == (
+ "runpod/queue-gpu:py3.12-latest"
+ )
+
+
+class TestLocalPythonVersion:
+ def test_returns_supported_version(self):
+ assert local_python_version() in SUPPORTED_PYTHON_VERSIONS
+
+ def test_unsupported_local_python_fails_loudly(self, monkeypatch):
+ # cloudpickle payloads are version-bound; a silent fallback
+ # would break at deserialization time on the worker
+ import runpod.apps.images as images
+
+ class FakeVersion:
+ major, minor = 3, 9
+
+ monkeypatch.setattr(images.sys, "version_info", FakeVersion)
+ with pytest.raises(RuntimeError, match="3.9"):
+ local_python_version()
+
+ def test_supported_range_policy(self):
+ # non-EOL cpython with torch wheels; update at EOL boundaries
+ assert SUPPORTED_PYTHON_VERSIONS == (
+ "3.10",
+ "3.11",
+ "3.12",
+ "3.13",
+ "3.14",
+ )
diff --git a/tests/test_apps/test_job.py b/tests/test_apps/test_job.py
new file mode 100644
index 00000000..cc8a4e24
--- /dev/null
+++ b/tests/test_apps/test_job.py
@@ -0,0 +1,161 @@
+"""tests for the shared Apps SDK queue job."""
+
+from typing import Any, Callable, Dict, Optional
+
+import pytest
+
+import runpod
+from runpod.apps.app import _clear_registry
+from runpod.apps.errors import InvalidResourceError
+from runpod.apps.job import Job
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+class FakeTarget:
+ def __init__(self):
+ self.calls = []
+
+ async def job_status(self, job_id: str) -> Dict[str, Any]:
+ self.calls.append(("status", job_id))
+ return {"id": job_id, "status": "IN_PROGRESS", "workerId": "worker-1"}
+
+ async def wait(
+ self,
+ job_data: Dict[str, Any],
+ *,
+ timeout: float,
+ on_status: Optional[Callable[[Dict[str, Any]], None]] = None,
+ ) -> Any:
+ self.calls.append(("wait", job_data["id"], timeout))
+ final = {
+ "id": job_data["id"],
+ "status": "COMPLETED",
+ "output": {"answer": 42},
+ }
+ if on_status is not None:
+ on_status(final)
+ return final["output"]
+
+ async def cancel_job(self, job_id: str) -> Dict[str, Any]:
+ self.calls.append(("cancel", job_id))
+ return {"id": job_id, "status": "CANCELLED"}
+
+ async def retry_job(self, job_id: str) -> Dict[str, Any]:
+ self.calls.append(("retry", job_id))
+ return {"id": job_id, "status": "IN_QUEUE", "retries": 1}
+
+ async def stream_job(self, job_id: str, *, timeout: float = 300.0):
+ self.calls.append(("stream", job_id))
+ for chunk in ("a", "b", "c"):
+ yield chunk
+
+
+def test_job_status_polls_target():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, target)
+
+ assert job.status() == "IN_PROGRESS"
+ assert target.calls == [("status", "job-1")]
+
+
+def test_job_status_skips_poll_once_terminal():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "COMPLETED"}, target)
+
+ assert job.status() == "COMPLETED"
+ assert target.calls == []
+
+
+def test_job_result_reaches_terminal_state():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, target)
+
+ assert job.result(timeout=12) == {"answer": 42}
+ assert job.done is True
+ assert job.status() == "COMPLETED"
+ # the wait carried timeout through; terminal status needed no extra poll
+ assert target.calls == [("wait", "job-1", 12)]
+
+
+def test_job_output_aliases_result():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, target)
+
+ assert job.output() == {"answer": 42}
+
+
+def test_job_cancel_and_retry_update_state():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_PROGRESS"}, target)
+
+ assert job.cancel() is job
+ assert job.done is True
+ assert job.retry() is job
+ assert job.done is False
+ assert target.calls == [("cancel", "job-1"), ("retry", "job-1")]
+
+
+async def test_job_async_forms():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, target)
+
+ assert await job.status.aio() == "IN_PROGRESS"
+ assert await job.result.aio() == {"answer": 42}
+ assert await job.cancel.aio() is job
+ assert await job.retry.aio() is job
+
+
+def test_job_stream_sync():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, target)
+
+ assert list(job.stream()) == ["a", "b", "c"]
+ assert target.calls == [("stream", "job-1")]
+
+
+async def test_job_stream_async():
+ target = FakeTarget()
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, target)
+
+ chunks = [chunk async for chunk in job.stream.aio()]
+ assert chunks == ["a", "b", "c"]
+
+
+def test_job_repr():
+ job = Job({"id": "job-1", "status": "IN_QUEUE"}, FakeTarget())
+ assert repr(job) == "Job(id='job-1', status='IN_QUEUE')"
+
+
+def test_decorated_queue_reconnects_to_job(monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = runpod.App("demo")
+
+ @app.queue(name="generate")
+ def generate(prompt: str):
+ return prompt
+
+ job = generate.job("job-1")
+
+ assert isinstance(job, runpod.Job)
+ assert job.id == "job-1"
+ assert job.done is False
+
+
+def test_task_job_cannot_reconnect(monkeypatch):
+ for var in ("RUNPOD_ENDPOINT_ID", "RUNPOD_POD_ID", "RUNPOD_DEV_SESSION"):
+ monkeypatch.delenv(var, raising=False)
+ app = runpod.App("demo")
+
+ @app.task(name="train")
+ def train():
+ return None
+
+ with pytest.raises(InvalidResourceError, match="cannot be reconnected"):
+ train.job("job-1")
diff --git a/tests/test_apps/test_logs.py b/tests/test_apps/test_logs.py
new file mode 100644
index 00000000..f32b661b
--- /dev/null
+++ b/tests/test_apps/test_logs.py
@@ -0,0 +1,140 @@
+"""unit tests for pod log access."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from runpod.apps import logs as pod_logs_mod
+from runpod.apps.logs import pod_logs, stream_pod_logs, tail_summary
+
+
+class TestTailSummary:
+ def test_empty(self):
+ assert tail_summary({}) == "(no logs available)"
+ assert tail_summary({"container": [], "system": []}) == (
+ "(no logs available)"
+ )
+
+ def test_container_only(self):
+ out = tail_summary({"container": ["a", "b"], "system": []})
+ assert "--- container (last 2) ---" in out
+ assert "a" in out and "b" in out
+ assert "system" not in out
+
+ def test_tail_limit(self):
+ entries = [f"line{i}" for i in range(50)]
+ out = tail_summary({"container": entries}, lines=3)
+ assert "line49" in out
+ assert "line46" not in out
+ assert "(last 3)" in out
+
+ def test_system_before_container(self):
+ out = tail_summary({"container": ["c"], "system": ["s"]})
+ assert out.index("system") < out.index("container")
+
+
+def _mock_session(response):
+ """a ClientSession whose get() context manager yields `response`."""
+ get_cm = MagicMock()
+ get_cm.__aenter__ = AsyncMock(return_value=response)
+ get_cm.__aexit__ = AsyncMock(return_value=False)
+ session = MagicMock()
+ session.get = MagicMock(return_value=get_cm)
+ session_cm = MagicMock()
+ session_cm.__aenter__ = AsyncMock(return_value=session)
+ session_cm.__aexit__ = AsyncMock(return_value=False)
+ return session_cm, session
+
+
+class TestPodLogs:
+ async def test_snapshot_normalizes_nulls(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "test-key")
+ response = MagicMock()
+ response.raise_for_status = MagicMock()
+ response.json = AsyncMock(
+ return_value={"container": ["hello"], "system": None}
+ )
+ session_cm, session = _mock_session(response)
+
+ with patch("aiohttp.ClientSession", return_value=session_cm):
+ result = await pod_logs("pod123")
+
+ assert result == {"container": ["hello"], "system": []}
+ url = session.get.call_args[0][0]
+ assert "pod123" in url
+ headers = session.get.call_args[1]["headers"]
+ assert headers["Authorization"] == "Bearer test-key"
+
+ async def test_log_type_param(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "test-key")
+ response = MagicMock()
+ response.raise_for_status = MagicMock()
+ response.json = AsyncMock(return_value={})
+ session_cm, session = _mock_session(response)
+
+ with patch("aiohttp.ClientSession", return_value=session_cm):
+ await pod_logs("pod123", log_type="system")
+
+ assert session.get.call_args[1]["params"] == {"type": "system"}
+
+
+class _FakeContent:
+ def __init__(self, lines):
+ self._lines = list(lines)
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ if not self._lines:
+ raise StopAsyncIteration
+ return self._lines.pop(0)
+
+
+class TestStreamPodLogs:
+ async def test_parses_sse_frames(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "test-key")
+ frames = [
+ b": heartbeat\n",
+ b"data: " + json.dumps(
+ {"source": "container", "line": "hi", "ts": "t1"}
+ ).encode() + b"\n",
+ b"garbage\n",
+ b"data: not-json\n",
+ b"data: " + json.dumps(
+ {"source": "system", "line": "boot", "ts": "t2"}
+ ).encode() + b"\n",
+ ]
+ response = MagicMock()
+ response.raise_for_status = MagicMock()
+ response.content = _FakeContent(frames)
+ session_cm, session = _mock_session(response)
+
+ with patch("aiohttp.ClientSession", return_value=session_cm):
+ events = [e async for e in stream_pod_logs("pod123")]
+
+ assert events == [
+ {"source": "container", "line": "hi", "ts": "t1"},
+ {"source": "system", "line": "boot", "ts": "t2"},
+ ]
+ params = session.get.call_args[1]["params"]
+ assert params["stream"] == "true"
+ assert params["tail"] == "100"
+
+ async def test_since_param(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "test-key")
+ response = MagicMock()
+ response.raise_for_status = MagicMock()
+ response.content = _FakeContent([])
+ session_cm, session = _mock_session(response)
+
+ with patch("aiohttp.ClientSession", return_value=session_cm):
+ async for _ in stream_pod_logs(
+ "pod123", since="2026-01-01T00:00:00Z", tail=5
+ ):
+ pass
+
+ params = session.get.call_args[1]["params"]
+ assert params["since"] == "2026-01-01T00:00:00Z"
+ assert params["tail"] == "5"
diff --git a/tests/test_apps/test_manage.py b/tests/test_apps/test_manage.py
new file mode 100644
index 00000000..637505a5
--- /dev/null
+++ b/tests/test_apps/test_manage.py
@@ -0,0 +1,150 @@
+"""app/environment lifecycle: list, inspect, undeploy, delete."""
+
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from runpod.apps.manage import (
+ AppNotFound,
+ EnvironmentNotFound,
+ delete_app,
+ get_app,
+ get_environment,
+ list_apps,
+ undeploy_environment,
+)
+
+
+def _api(**overrides):
+ api = AsyncMock()
+ api.list_apps.return_value = []
+ api.get_app_by_name.return_value = None
+ api.get_environment_by_name.return_value = None
+ api.delete_endpoint.return_value = True
+ api.delete_environment.return_value = True
+ api.delete_app.return_value = True
+ for key, value in overrides.items():
+ getattr(api, key).return_value = value
+ return api
+
+
+class TestQueries:
+ def test_list_sorted_by_name(self):
+ api = _api(
+ list_apps=[{"name": "zeta"}, {"name": "alpha"}]
+ )
+ apps = asyncio.run(list_apps(api=api))
+ assert [a["name"] for a in apps] == ["alpha", "zeta"]
+
+ def test_get_app_not_found(self):
+ with pytest.raises(AppNotFound, match="rp deploy"):
+ asyncio.run(get_app("ghost", api=_api()))
+
+ def test_get_environment_not_found(self):
+ with pytest.raises(EnvironmentNotFound, match="ghost"):
+ asyncio.run(get_environment("app", "ghost", api=_api()))
+
+
+class TestUndeployEnvironment:
+ def _env(self, n_endpoints=2):
+ return {
+ "id": "env-1",
+ "name": "default",
+ "endpoints": [
+ {"id": f"ep-{i}", "name": f"svc-{i}"}
+ for i in range(n_endpoints)
+ ],
+ }
+
+ def test_deletes_endpoints_then_environment(self):
+ api = _api(get_environment_by_name=self._env())
+ result = asyncio.run(
+ undeploy_environment("app", "default", api=api)
+ )
+ assert result.endpoints_deleted == 2
+ assert result.environment_deleted
+ assert not result.failures
+ api.delete_environment.assert_awaited_once_with("env-1")
+
+ def test_keep_env_flag(self):
+ api = _api(get_environment_by_name=self._env())
+ result = asyncio.run(
+ undeploy_environment("app", "default", api=api, delete_env=False)
+ )
+ assert result.endpoints_deleted == 2
+ assert not result.environment_deleted
+ api.delete_environment.assert_not_awaited()
+
+ def test_endpoint_failure_keeps_environment(self):
+ api = _api(get_environment_by_name=self._env(1))
+ api.delete_endpoint.side_effect = RuntimeError("in use")
+ result = asyncio.run(
+ undeploy_environment("app", "default", api=api)
+ )
+ assert result.endpoints_deleted == 0
+ assert not result.environment_deleted
+ assert result.failures
+ api.delete_environment.assert_not_awaited()
+
+ def test_events_emitted(self):
+ events = []
+
+ class Sink:
+ def cleanup_started(self, total):
+ events.append(("started", total))
+
+ def deleting(self, name):
+ events.append(("deleting", name))
+
+ def deleted(self, name):
+ events.append(("deleted", name))
+
+ api = _api(get_environment_by_name=self._env(1))
+ asyncio.run(
+ undeploy_environment("app", "default", api=api, events=Sink())
+ )
+ assert events == [
+ ("started", 1),
+ ("deleting", "svc-0"),
+ ("deleted", "svc-0"),
+ ]
+
+
+class TestDeleteApp:
+ def test_undeploys_all_envs_then_app(self):
+ app_data = {
+ "id": "app-1",
+ "name": "myapp",
+ "flashEnvironments": [
+ {"id": "env-1", "name": "default"},
+ {"id": "env-2", "name": "staging"},
+ ],
+ }
+ api = _api(get_app_by_name=app_data)
+ api.get_environment_by_name.side_effect = [
+ {"id": "env-1", "name": "default", "endpoints": [{"id": "e1", "name": "a"}]},
+ {"id": "env-2", "name": "staging", "endpoints": []},
+ ]
+ result = asyncio.run(delete_app("myapp", api=api))
+ assert result.endpoints_deleted == 1
+ assert result.app_deleted
+ api.delete_app.assert_awaited_once_with("app-1")
+
+ def test_failure_keeps_app(self):
+ app_data = {
+ "id": "app-1",
+ "name": "myapp",
+ "flashEnvironments": [{"id": "env-1", "name": "default"}],
+ }
+ api = _api(get_app_by_name=app_data)
+ api.get_environment_by_name.return_value = {
+ "id": "env-1",
+ "name": "default",
+ "endpoints": [{"id": "e1", "name": "a"}],
+ }
+ api.delete_endpoint.side_effect = RuntimeError("nope")
+ result = asyncio.run(delete_app("myapp", api=api))
+ assert not result.app_deleted
+ assert result.failures
+ api.delete_app.assert_not_awaited()
diff --git a/tests/test_apps/test_model.py b/tests/test_apps/test_model.py
new file mode 100644
index 00000000..2aaaaf23
--- /dev/null
+++ b/tests/test_apps/test_model.py
@@ -0,0 +1,91 @@
+"""model references: parsing, paths, payload plumbing."""
+
+from pathlib import Path
+
+import pytest
+
+from runpod.apps.model import Model, ModelError, model_reference
+
+
+class TestModelRef:
+ def test_repo_parse(self):
+ model = Model("meta-llama/Llama-3.1-8B-Instruct")
+ assert model.owner == "meta-llama"
+ assert model.name == "Llama-3.1-8B-Instruct"
+ assert model.revision is None
+
+ def test_revision_parse(self):
+ model = Model("org/name:abc123")
+ assert model.revision == "abc123"
+ assert model.reference == "org/name:abc123"
+
+ def test_invalid_references(self):
+ for bad in ("", "no-slash", "a/b/c", "spaces in/name"):
+ with pytest.raises(ModelError):
+ Model(bad)
+
+ def test_store_path_with_env_revision(self, monkeypatch):
+ monkeypatch.setenv("MODEL_REVISION", "deadbeef")
+ model = Model("org/name")
+ assert model.path == Path(
+ "/runpod/model-store/huggingface/org/name/deadbeef"
+ )
+
+ def test_store_path_without_revision(self, monkeypatch):
+ monkeypatch.delenv("MODEL_REVISION", raising=False)
+ model = Model("org/name")
+ assert model.path == Path("/runpod/model-store/huggingface/org/name")
+
+ def test_hf_cache_path(self):
+ model = Model("org/name")
+ assert model.hf_cache_path == Path(
+ "/runpod-volume/huggingface-cache/hub/models--org--name"
+ )
+
+ def test_model_reference_normalization(self):
+ assert model_reference(None) is None
+ assert model_reference("org/name") == "org/name"
+ assert model_reference(Model("org/name:rev")) == "org/name:rev"
+ with pytest.raises(ModelError):
+ model_reference(42)
+
+
+class TestSpecPlumbing:
+ def test_decorator_accepts_model(self):
+ from runpod.apps.app import App
+
+ app = App("modeltest")
+ llama = Model("meta-llama/Llama-3.1-8B-Instruct")
+
+ @app.queue(name="chat", gpu="4090", model=llama)
+ def chat(prompt: str):
+ pass
+
+ assert chat.spec.model is llama
+ assert (
+ chat.spec.to_manifest()["model"]
+ == "meta-llama/Llama-3.1-8B-Instruct"
+ )
+
+ def test_task_decorator_rejects_model(self):
+ from runpod.apps.app import App
+
+ app = App("modeltest")
+
+ # model= is not a parameter of @app.task; passed via a kwargs
+ # dict so the intentional misuse is constructed at runtime
+ kwargs = {"name": "train", "gpu": "4090", "model": Model("org/name")}
+ with pytest.raises(TypeError, match="model"):
+
+ @app.task(**kwargs)
+ def train():
+ pass
+
+ def test_task_spec_rejects_model(self):
+ from runpod.apps.errors import InvalidResourceError
+ from runpod.apps.spec import ResourceKind, ResourceSpec
+
+ with pytest.raises(InvalidResourceError, match="queue and api"):
+ ResourceSpec(
+ kind=ResourceKind.TASK, name="train", model=Model("org/name")
+ )
diff --git a/tests/test_apps/test_monitor.py b/tests/test_apps/test_monitor.py
new file mode 100644
index 00000000..c1cb2719
--- /dev/null
+++ b/tests/test_apps/test_monitor.py
@@ -0,0 +1,273 @@
+"""worker monitor: metrics transitions, log attach, event emission."""
+
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from runpod.apps.monitor import WorkerMonitor, emit, format_worker_counts
+
+
+class Sink:
+ def __init__(self):
+ self.events = []
+
+ def worker_status(self, name, counts):
+ self.events.append(("worker_status", name, counts))
+
+ def worker_ready(self, name, worker_id):
+ self.events.append(("worker_ready", name, worker_id))
+
+ def worker_log(self, name, line):
+ self.events.append(("worker_log", name, line))
+
+
+class TestEmit:
+ def test_missing_handler_skipped(self):
+ emit(object(), "nope", 1)
+
+ def test_none_sink_skipped(self):
+ emit(None, "anything")
+
+ def test_handler_called(self):
+ sink = Sink()
+ emit(sink, "worker_ready", "calc", "w1")
+ assert sink.events == [("worker_ready", "calc", "w1")]
+
+ def test_handler_errors_swallowed(self):
+ class Bad:
+ def worker_ready(self, *a):
+ raise RuntimeError("render bug")
+
+ emit(Bad(), "worker_ready", "calc", "w1")
+
+
+class TestFormatWorkerCounts:
+ def test_only_nonzero(self):
+ s = format_worker_counts({"initializing": 1, "ready": 2, "throttled": 0})
+ assert s == "1 initializing, 2 ready"
+
+ def test_empty(self):
+ assert format_worker_counts({"ready": 0}) == "no workers"
+
+
+class TestOnStatus:
+ def test_worker_id_triggers_ready_and_stream(self):
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink)
+
+ async def run():
+ with patch(
+ "runpod.apps.monitor.PodLogStream.attach"
+ ) as attach, patch(
+ "runpod.apps.monitor.PodLogStream.stop",
+ return_value=asyncio.sleep(0),
+ ):
+ monitor.on_status({"workerId": "w123", "status": "IN_PROGRESS"})
+ monitor.on_status({"workerId": "w123", "status": "IN_PROGRESS"})
+ # one stream per worker, not per status payload
+ assert attach.call_count == 1
+ await monitor.stop()
+
+ asyncio.run(run())
+ assert sink.events == [("worker_ready", "calc", "w123")]
+
+ def test_no_worker_id_no_events(self):
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink)
+ monitor.on_status({"status": "IN_QUEUE"})
+ assert sink.events == []
+
+
+class TestReportCounts:
+ def test_first_steady_snapshot_suppressed(self):
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink)
+ monitor._report_counts({"workers": {"ready": 2, "running": 1}})
+ assert sink.events == []
+
+ def test_first_snapshot_with_initializing_reported(self):
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink)
+ monitor._report_counts({"workers": {"initializing": 1}})
+ assert len(sink.events) == 1
+ assert sink.events[0][2]["initializing"] == 1
+
+ def test_transition_reported_once(self):
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink)
+ monitor._report_counts({"workers": {"initializing": 1}})
+ monitor._report_counts({"workers": {"initializing": 1}})
+ monitor._report_counts({"workers": {"ready": 1}})
+ assert len(sink.events) == 2
+ assert sink.events[1][2]["ready"] == 1
+
+ def test_malformed_payload_ignored(self):
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink)
+ monitor._report_counts({"workers": None})
+ monitor._report_counts({})
+ assert sink.events == []
+
+
+class TestLineFiltering:
+ def test_user_prints_pass_through(self):
+ from runpod.apps.monitor import _filter_line
+
+ assert _filter_line("hello world") == "hello world"
+ assert _filter_line("42") == "42"
+
+ def test_sdk_info_frames_hidden(self):
+ from runpod.apps.monitor import _filter_line
+
+ assert (
+ _filter_line('{"requestId": null, "message": "Jobs in queue: 1", "level": "INFO"}')
+ is None
+ )
+ assert (
+ _filter_line('{"requestId": "abc", "message": "Started.", "level": "INFO"}')
+ is None
+ )
+
+ def test_sdk_error_frames_surface_message(self):
+ from runpod.apps.monitor import _filter_line
+
+ assert (
+ _filter_line('{"requestId": "abc", "message": "boom", "level": "ERROR"}')
+ == "boom"
+ )
+
+ def test_non_frame_json_passes_through(self):
+ from runpod.apps.monitor import _filter_line
+
+ # user code printing json without the sdk frame shape
+ assert _filter_line('{"result": 5}') == '{"result": 5}'
+
+
+class TestPodLogStream:
+ async def test_follow_emits_and_dedups(self):
+ from runpod.apps.monitor import PodLogStream
+
+ sink = Sink()
+ events = [
+ {"ts": "t1", "line": "hello"},
+ {"ts": "t1", "line": "hello"}, # duplicate frame
+ {"ts": "t2", "line": ""}, # blank filtered
+ {"ts": "t3", "line": "world"},
+ ]
+
+ calls = {"n": 0}
+
+ async def fake_stream(pod_id, **kwargs):
+ if calls["n"]:
+ # second attach: park forever so stop() cancels us
+ await asyncio.sleep(3600)
+ calls["n"] += 1
+ for event in events:
+ yield event
+
+ stream = PodLogStream("pod1", "calc", sink)
+ with patch("runpod.apps.logs.stream_pod_logs", fake_stream):
+ stream.attach()
+ await asyncio.sleep(0.05)
+ await stream.stop()
+
+ logs = [e for e in sink.events if e[0] == "worker_log"]
+ assert logs == [
+ ("worker_log", "calc", "hello"),
+ ("worker_log", "calc", "world"),
+ ]
+
+ async def test_stop_snapshots_when_stream_never_attached(self):
+ from datetime import datetime, timedelta, timezone
+
+ from runpod.apps.monitor import PodLogStream
+
+ sink = Sink()
+
+ async def failing_stream(pod_id, **kwargs):
+ raise RuntimeError("403")
+ yield # pragma: no cover
+
+ stream = PodLogStream("pod1", "calc", sink)
+ after = (
+ datetime.now(timezone.utc) + timedelta(seconds=5)
+ ).isoformat()
+ snapshot = {
+ "container": [f"{after} recovered output"],
+ "system": [],
+ }
+ with (
+ patch("runpod.apps.logs.stream_pod_logs", failing_stream),
+ patch(
+ "runpod.apps.logs.pod_logs",
+ AsyncMock(return_value=snapshot),
+ ),
+ ):
+ stream.attach()
+ await asyncio.sleep(0.05)
+ await stream.stop()
+
+ logs = [e for e in sink.events if e[0] == "worker_log"]
+ assert logs == [("worker_log", "calc", "recovered output")]
+
+ async def test_snapshot_skips_lines_before_since(self):
+ from runpod.apps.monitor import PodLogStream
+
+ sink = Sink()
+ stale = "2000-01-01T00:00:00.000000000Z old line"
+ snapshot = {"container": [stale], "system": []}
+ stream = PodLogStream("pod1", "calc", sink)
+ with patch(
+ "runpod.apps.logs.pod_logs",
+ AsyncMock(return_value=snapshot),
+ ):
+ await stream._snapshot()
+
+ assert sink.events == []
+
+ async def test_attach_idempotent(self):
+ from runpod.apps.monitor import PodLogStream
+
+ async def parked(pod_id, **kwargs):
+ await asyncio.sleep(3600)
+ yield # pragma: no cover
+
+ stream = PodLogStream("pod1", "calc", Sink())
+ with patch("runpod.apps.logs.stream_pod_logs", parked):
+ stream.attach()
+ first = stream._task
+ stream.attach()
+ assert stream._task is first
+ stream._lines_emitted = 1 # skip the snapshot fallback
+ await stream.stop()
+ assert stream._task is None
+
+
+class TestMonitorLifecycle:
+ async def test_start_without_metrics_key_is_noop(self):
+ monitor = WorkerMonitor("ep1", "calc", Sink())
+ await monitor.start()
+ assert monitor._tasks == []
+ await monitor.stop()
+
+ async def test_metrics_polling_reports_counts(self):
+ import runpod
+
+ sink = Sink()
+ monitor = WorkerMonitor("ep1", "calc", sink, metrics_key="mk")
+ payload = {"workers": {"initializing": 1, "ready": 0}}
+
+ async def fake_get(url, headers, timeout):
+ assert headers["Authorization"] == "Bearer mk"
+ return payload
+
+ with patch("runpod.apps.targets._get_json", fake_get):
+ await monitor.start()
+ await asyncio.sleep(0.05)
+ await monitor.stop()
+
+ statuses = [e for e in sink.events if e[0] == "worker_status"]
+ assert statuses
+ assert statuses[0][2]["initializing"] == 1
+
diff --git a/tests/test_apps/test_placement.py b/tests/test_apps/test_placement.py
new file mode 100644
index 00000000..9c55c210
--- /dev/null
+++ b/tests/test_apps/test_placement.py
@@ -0,0 +1,168 @@
+"""placement solve: candidates, intersections, maximin ranking."""
+
+import pytest
+
+from runpod.apps.placement import (
+ PlacementError,
+ StockMap,
+ candidates,
+ solve_placement,
+)
+from runpod.apps.spec import ResourceKind, ResourceSpec
+
+
+def _spec(name="r", gpu=None, cpu=None, datacenter=None):
+ return ResourceSpec(
+ kind=ResourceKind.TASK,
+ name=name,
+ gpu=gpu,
+ cpu=cpu,
+ datacenter=datacenter,
+ )
+
+
+def _stock(gpu=None, cpu=None):
+ stock = StockMap(api=object())
+ # task specs query the pod plane; mirror into both tables so the
+ # fixtures stay hardware-shaped rather than plane-shaped
+ stock._gpu = dict(gpu or {})
+ stock._gpu_pod = dict(gpu or {})
+ stock._cpu = cpu or {}
+ return stock
+
+
+class TestCandidates:
+ def test_gpu_stock_filters_dcs(self):
+ stock = _stock(
+ gpu={
+ ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3,
+ ("NVIDIA GeForce RTX 4090", "US-KS-2"): 0,
+ }
+ )
+ dcs = candidates(_spec(gpu=["NVIDIA GeForce RTX 4090"]), stock)
+ assert "EU-RO-1" in dcs
+ assert "US-KS-2" not in dcs
+
+ def test_pool_id_expands_to_devices(self):
+ stock = _stock(gpu={("NVIDIA GeForce RTX 4090", "EU-RO-1"): 2})
+ dcs = candidates(_spec(gpu=["ADA_24"]), stock)
+ assert "EU-RO-1" in dcs
+
+ def test_datacenter_pin_intersects(self):
+ stock = _stock(
+ gpu={
+ ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3,
+ ("NVIDIA GeForce RTX 4090", "US-KS-2"): 3,
+ }
+ )
+ dcs = candidates(
+ _spec(gpu=["NVIDIA GeForce RTX 4090"], datacenter=["US-KS-2"]),
+ stock,
+ )
+ assert dcs == {"US-KS-2"}
+
+ def test_cpu_stock(self):
+ stock = _stock(cpu={("cpu5c-2-4", "EU-RO-1"): 2})
+ dcs = candidates(_spec(cpu=["cpu5c-2-4"]), stock)
+ assert dcs == {"EU-RO-1"}
+
+ def test_any_gpu_allows_everywhere(self):
+ stock = _stock(gpu={("NVIDIA GeForce RTX 4090", "EU-RO-1"): 1})
+ dcs = candidates(_spec(gpu=None), stock)
+ assert "EU-RO-1" in dcs
+
+
+class TestSolvePlacement:
+ def test_intersection_picks_shared_dc(self):
+ stock = _stock(
+ gpu={
+ ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3,
+ ("NVIDIA GeForce RTX 4090", "US-KS-2"): 3,
+ ("NVIDIA H200", "EU-RO-1"): 2,
+ }
+ )
+ dc = solve_placement(
+ [
+ _spec("train", gpu=["NVIDIA H200"]),
+ _spec("infer", gpu=["NVIDIA GeForCE RTX 4090".replace("CE", "ce")]),
+ ],
+ stock,
+ volume_name="models",
+ )
+ assert dc == "EU-RO-1"
+
+ def test_disjoint_hardware_errors_with_details(self):
+ stock = _stock(
+ gpu={
+ ("NVIDIA H200", "EU-RO-1"): 3,
+ ("NVIDIA B200", "US-KS-2"): 3,
+ }
+ )
+ with pytest.raises(PlacementError, match="no datacenter can host"):
+ solve_placement(
+ [
+ _spec("train", gpu=["NVIDIA H200"]),
+ _spec("eval", gpu=["NVIDIA B200"]),
+ ],
+ stock,
+ volume_name="models",
+ )
+
+ def test_existing_dc_is_hard_constraint(self):
+ stock = _stock(gpu={("NVIDIA H200", "EU-RO-1"): 3})
+ dc = solve_placement(
+ [_spec("train", gpu=["NVIDIA H200"])],
+ stock,
+ volume_name="models",
+ existing_dc="EU-RO-1",
+ )
+ assert dc == "EU-RO-1"
+
+ def test_existing_dc_unschedulable_errors(self):
+ stock = _stock(gpu={("NVIDIA H200", "EU-RO-1"): 3})
+ with pytest.raises(PlacementError, match="lives in US-KS-2"):
+ solve_placement(
+ [_spec("train", gpu=["NVIDIA H200"])],
+ stock,
+ volume_name="models",
+ existing_dc="US-KS-2",
+ )
+
+ def test_maximin_prefers_worst_case_stock(self):
+ # both DCs host both resources; EU is (3, 1), US is (2, 2):
+ # maximin picks US because its worst resource is better off
+ stock = _stock(
+ gpu={
+ ("NVIDIA H200", "EU-RO-1"): 3,
+ ("NVIDIA H200", "US-KS-2"): 2,
+ ("NVIDIA B200", "EU-RO-1"): 1,
+ ("NVIDIA B200", "US-KS-2"): 2,
+ }
+ )
+ dc = solve_placement(
+ [
+ _spec("a", gpu=["NVIDIA H200"]),
+ _spec("b", gpu=["NVIDIA B200"]),
+ ],
+ stock,
+ volume_name="v",
+ )
+ assert dc == "US-KS-2"
+
+ def test_mixed_cpu_gpu_sharing(self):
+ stock = _stock(
+ gpu={
+ ("NVIDIA GeForce RTX 4090", "EU-RO-1"): 3,
+ ("NVIDIA GeForce RTX 4090", "US-KS-2"): 3,
+ },
+ cpu={("cpu5c-2-4", "EU-RO-1"): 2},
+ )
+ dc = solve_placement(
+ [
+ _spec("gpures", gpu=["NVIDIA GeForce RTX 4090"]),
+ _spec("cpures", cpu=["cpu5c-2-4"]),
+ ],
+ stock,
+ volume_name="shared",
+ )
+ assert dc == "EU-RO-1"
diff --git a/tests/test_apps/test_queue_worker.py b/tests/test_apps/test_queue_worker.py
new file mode 100644
index 00000000..8ac14f8e
--- /dev/null
+++ b/tests/test_apps/test_queue_worker.py
@@ -0,0 +1,282 @@
+"""unit tests for the generic queue worker runtime."""
+
+import inspect
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from runpod.runtimes.queue import worker
+
+
+class TestModeDetection:
+ def test_resource_name_flash_env(self, monkeypatch):
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+ assert worker._resource_name() == "chat"
+
+ def test_resource_name_runpod_env(self, monkeypatch):
+ monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False)
+ monkeypatch.setenv("RUNPOD_RESOURCE_NAME", "embed")
+ assert worker._resource_name() == "embed"
+
+ def test_resource_name_empty(self, monkeypatch):
+ monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False)
+ monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False)
+ assert worker._resource_name() == ""
+
+ def test_is_deployed_requires_name_and_manifest(self, monkeypatch, tmp_path):
+ monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False)
+ monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False)
+ monkeypatch.setattr(worker, "APP_DIR", str(tmp_path))
+ assert not worker._is_deployed()
+
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+ assert not worker._is_deployed()
+
+ (tmp_path / worker.MANIFEST_NAME).write_text("{}")
+ assert worker._is_deployed()
+
+
+class TestDeployedHandle:
+ def _write_manifest(self, tmp_path, resources):
+ (tmp_path / worker.MANIFEST_NAME).write_text(
+ json.dumps({"resources": resources})
+ )
+
+ def test_load_deployed_handle(self, monkeypatch, tmp_path):
+ module_src = (
+ "import runpod\n"
+ "app = runpod.App('t')\n"
+ "@app.queue(name='chat', gpu='4090')\n"
+ "def chat(prompt: str):\n"
+ " return {'echo': prompt}\n"
+ )
+ (tmp_path / "user_mod_a.py").write_text(module_src)
+ self._write_manifest(
+ tmp_path, [{"name": "chat", "module": "user_mod_a"}]
+ )
+ monkeypatch.setattr(worker, "APP_DIR", str(tmp_path))
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+
+ handle = worker._load_deployed_handle()
+ assert handle.spec.name == "chat"
+
+ def test_load_deployed_handle_missing_resource(self, monkeypatch, tmp_path):
+ self._write_manifest(tmp_path, [{"name": "other", "module": "x"}])
+ monkeypatch.setattr(worker, "APP_DIR", str(tmp_path))
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+
+ with pytest.raises(RuntimeError, match="not in manifest"):
+ worker._load_deployed_handle()
+
+ def test_load_deployed_handle_missing_handle(self, monkeypatch, tmp_path):
+ (tmp_path / "user_mod_b.py").write_text("x = 1\n")
+ self._write_manifest(
+ tmp_path, [{"name": "chat", "module": "user_mod_b"}]
+ )
+ monkeypatch.setattr(worker, "APP_DIR", str(tmp_path))
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "chat")
+
+ with pytest.raises(RuntimeError, match="no @app.queue"):
+ worker._load_deployed_handle()
+
+
+class TestDeployedHandler:
+ def _handle(self, fn):
+ handle = MagicMock()
+ handle._fn = fn
+ return handle
+
+ async def test_sync_function(self):
+ handler = worker._make_deployed_handler(
+ self._handle(lambda a, b: {"sum": a + b})
+ )
+ result = await handler({"input": {"a": 1, "b": 2}})
+ assert result == {"sum": 3}
+
+ async def test_async_function(self):
+ async def fn(x):
+ return x * 2
+
+ handler = worker._make_deployed_handler(self._handle(fn))
+ assert await handler({"input": {"x": 5}}) == 10
+
+ async def test_empty_marker_stripped(self):
+ handler = worker._make_deployed_handler(
+ self._handle(lambda: "ok")
+ )
+ assert await handler({"input": {"__empty": True}}) == "ok"
+
+ async def test_no_input(self):
+ handler = worker._make_deployed_handler(
+ self._handle(lambda: "ok")
+ )
+ assert await handler({}) == "ok"
+
+ async def test_sync_generator(self):
+ def fn(n):
+ for i in range(n):
+ yield i
+
+ handler = worker._make_deployed_handler(self._handle(fn))
+ assert inspect.isgeneratorfunction(handler)
+ assert list(handler({"input": {"n": 3}})) == [0, 1, 2]
+
+ async def test_async_generator(self):
+ async def fn(n):
+ for i in range(n):
+ yield i
+
+ handler = worker._make_deployed_handler(self._handle(fn))
+ assert inspect.isasyncgenfunction(handler)
+ chunks = [c async for c in handler({"input": {"n": 2}})]
+ assert chunks == [0, 1]
+
+
+class TestInitHook:
+ def test_no_init(self):
+ handle = MagicMock(spec=[])
+ worker._run_init(handle) # no _init_fn attr: no-op
+
+ def test_sync_init(self):
+ calls = []
+ handle = MagicMock()
+ handle._init_fn = lambda: calls.append(1)
+ worker._run_init(handle)
+ assert calls == [1]
+
+ def test_async_init(self):
+ calls = []
+
+ async def init():
+ calls.append(1)
+
+ handle = MagicMock()
+ handle._init_fn = init
+ worker._run_init(handle)
+ assert calls == [1]
+
+
+class TestConcurrency:
+ def test_default(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False)
+ assert worker._max_concurrency() == 1
+
+ def test_env_value(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "8")
+ assert worker._max_concurrency() == 8
+
+ def test_invalid_value(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "banana")
+ assert worker._max_concurrency() == 1
+
+ def test_floor_of_one(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "0")
+ assert worker._max_concurrency() == 1
+
+ def test_worker_config_single(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False)
+ config = worker._worker_config(lambda job: job)
+ assert "concurrency_modifier" not in config
+
+ def test_worker_config_concurrent(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_MAX_CONCURRENCY", "4")
+ config = worker._worker_config(lambda job: job)
+ assert config["concurrency_modifier"](1) == 4
+
+ def test_worker_config_plain_handler(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False)
+ config = worker._worker_config(lambda job: job)
+ assert "return_aggregate_stream" not in config
+
+ def test_worker_config_generator_handler(self, monkeypatch):
+ monkeypatch.delenv("RUNPOD_MAX_CONCURRENCY", raising=False)
+
+ def gen_handler(job):
+ yield job
+
+ config = worker._worker_config(gen_handler)
+ assert config["return_aggregate_stream"] is True
+
+
+class TestMain:
+ def test_live_mode(self, monkeypatch):
+ monkeypatch.delenv("FLASH_RESOURCE_NAME", raising=False)
+ monkeypatch.delenv("RUNPOD_RESOURCE_NAME", raising=False)
+ with patch("runpod.serverless.start") as start:
+ worker.main()
+ config = start.call_args[0][0]
+ assert config["handler"] is worker._live_handler
+
+ def test_deployed_mode(self, monkeypatch, tmp_path):
+ module_src = (
+ "import runpod\n"
+ "app = runpod.App('t')\n"
+ "@app.queue(name='greet', gpu='4090')\n"
+ "def greet(name: str):\n"
+ " return f'hi {name}'\n"
+ )
+ (tmp_path / "user_mod_c.py").write_text(module_src)
+ (tmp_path / worker.MANIFEST_NAME).write_text(
+ json.dumps({"resources": [{"name": "greet", "module": "user_mod_c"}]})
+ )
+ monkeypatch.setattr(worker, "APP_DIR", str(tmp_path))
+ monkeypatch.setenv("FLASH_RESOURCE_NAME", "greet")
+
+ with patch("runpod.serverless.start") as start:
+ worker.main()
+ handler = start.call_args[0][0]["handler"]
+ assert inspect.iscoroutinefunction(handler)
+
+ async def test_live_handler_plain_function(self):
+ def fn(x):
+ return {"ok": x}
+
+ with (
+ patch(
+ "runpod.runtimes.executor.resolve_request",
+ return_value=((fn, [1], {}), None),
+ ),
+ patch(
+ "runpod.runtimes.executor.execute_request",
+ return_value={"success": True, "json_result": {"ok": 1}},
+ ) as execute,
+ ):
+ chunks = [c async for c in worker._live_handler({"input": {"foo": 1}})]
+ assert chunks == [{"success": True, "json_result": {"ok": 1}}]
+ execute.assert_called_once_with({"foo": 1})
+
+ async def test_live_handler_resolve_error(self):
+ with patch(
+ "runpod.runtimes.executor.resolve_request",
+ return_value=(None, {"success": False, "error": "boom"}),
+ ):
+ chunks = [c async for c in worker._live_handler({"input": {}})]
+ assert chunks == [{"success": False, "error": "boom"}]
+
+ async def test_live_handler_streams_generator(self):
+ def fn(n):
+ for i in range(n):
+ yield i
+
+ with patch(
+ "runpod.runtimes.executor.resolve_request",
+ return_value=((fn, [], {"n": 2}), None),
+ ):
+ chunks = [c async for c in worker._live_handler({"input": {}})]
+ assert all(c["__stream__"] for c in chunks)
+ assert all(c["success"] for c in chunks)
+ assert len(chunks) == 2
+
+ async def test_live_handler_streams_async_generator(self):
+ async def fn(n):
+ for i in range(n):
+ yield i
+
+ with patch(
+ "runpod.runtimes.executor.resolve_request",
+ return_value=((fn, [], {"n": 3}), None),
+ ):
+ chunks = [c async for c in worker._live_handler({"input": {}})]
+ assert len(chunks) == 3
+ assert all(c["__stream__"] for c in chunks)
diff --git a/tests/test_apps/test_registry.py b/tests/test_apps/test_registry.py
new file mode 100644
index 00000000..f8ecfde6
--- /dev/null
+++ b/tests/test_apps/test_registry.py
@@ -0,0 +1,69 @@
+"""registry credential resolution."""
+
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from runpod.apps.registry import RegistryAuthError, resolve_registry_auth
+
+
+def _api(creds=None):
+ api = AsyncMock()
+ api.list_registry_auths.return_value = creds or []
+ return api
+
+
+class TestResolveRegistryAuth:
+ def test_none_passthrough(self):
+ assert (
+ asyncio.run(resolve_registry_auth(None, api=_api())) is None
+ )
+
+ def test_resolves_by_name(self):
+ api = _api([{"id": "cra-1", "name": "my-ghcr"}])
+ assert (
+ asyncio.run(resolve_registry_auth("my-ghcr", api=api)) == "cra-1"
+ )
+
+ def test_resolves_by_id(self):
+ api = _api([{"id": "cra-1", "name": "my-ghcr"}])
+ assert (
+ asyncio.run(resolve_registry_auth("cra-1", api=api)) == "cra-1"
+ )
+
+ def test_missing_lists_available(self):
+ api = _api([{"id": "cra-1", "name": "other"}])
+ with pytest.raises(
+ RegistryAuthError, match="available: other"
+ ):
+ asyncio.run(resolve_registry_auth("ghost", api=api))
+
+ def test_duplicates_require_id(self):
+ api = _api(
+ [
+ {"id": "cra-1", "name": "dup"},
+ {"id": "cra-2", "name": "dup"},
+ ]
+ )
+ with pytest.raises(RegistryAuthError, match="reference by id"):
+ asyncio.run(resolve_registry_auth("dup", api=api))
+
+
+class TestSpecPlumbing:
+ def test_decorator_accepts_registry_auth(self):
+ from runpod.apps.app import App
+
+ app = App("regtest")
+
+ @app.queue(
+ name="q",
+ cpu=["cpu3c-1-2"],
+ image="ghcr.io/me/x:1",
+ registry_auth="my-ghcr",
+ )
+ def q():
+ pass
+
+ assert q.spec.registry_auth == "my-ghcr"
+ assert q.spec.to_manifest()["registryAuth"] == "my-ghcr"
diff --git a/tests/test_apps/test_retry.py b/tests/test_apps/test_retry.py
new file mode 100644
index 00000000..a973cb3c
--- /dev/null
+++ b/tests/test_apps/test_retry.py
@@ -0,0 +1,102 @@
+"""tests for transient-failure retry in the http layer."""
+
+import json
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from unittest.mock import patch
+
+import aiohttp
+import pytest
+
+from runpod.apps.errors import EndpointNotFound
+from runpod.apps.targets import (
+ RETRY_ATTEMPTS,
+ RETRYABLE_STATUSES,
+ _post_json,
+)
+
+
+@pytest.fixture
+def flaky_server():
+ """local server whose first responses are scripted status codes."""
+ state = {"script": [], "hits": 0}
+
+ class Handler(BaseHTTPRequestHandler):
+ def _respond(self):
+ state["hits"] += 1
+ if state["hits"] <= len(state["script"]):
+ status = state["script"][state["hits"] - 1]
+ self.send_response(status)
+ self.end_headers()
+ self.wfile.write(b"scripted")
+ return
+ body = json.dumps({"ok": True, "hits": state["hits"]}).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ do_GET = do_POST = _respond
+
+ def log_message(self, *args):
+ pass
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ server.state = state
+ server.url = f"http://127.0.0.1:{server.server_address[1]}/run"
+ yield server
+ server.shutdown()
+
+
+async def test_retries_transient_5xx_then_succeeds(flaky_server):
+ flaky_server.state["script"] = [520, 502]
+ with patch("runpod.apps.targets.RETRY_BASE_DELAY", 0.01):
+ data = await _post_json(flaky_server.url, {"x": 1}, {}, timeout=10)
+ assert data["ok"] is True
+ assert data["hits"] == 3
+
+
+async def test_exhausted_retries_raise_last_error(flaky_server):
+ flaky_server.state["script"] = [503] * (RETRY_ATTEMPTS + 2)
+ with patch("runpod.apps.targets.RETRY_BASE_DELAY", 0.01):
+ with pytest.raises(aiohttp.ClientResponseError) as exc_info:
+ await _post_json(flaky_server.url, {"x": 1}, {}, timeout=10)
+ assert exc_info.value.status == 503
+ assert flaky_server.state["hits"] == RETRY_ATTEMPTS
+
+
+async def test_404_not_retried_maps_to_endpoint_not_found(flaky_server):
+ flaky_server.state["script"] = [404]
+ with pytest.raises(EndpointNotFound):
+ await _post_json(
+ flaky_server.url,
+ {"x": 1},
+ {},
+ timeout=10,
+ app_name="a",
+ resource_name="r",
+ )
+ assert flaky_server.state["hits"] == 1
+
+
+async def test_client_4xx_not_retried(flaky_server):
+ flaky_server.state["script"] = [400]
+ with pytest.raises(aiohttp.ClientResponseError) as exc_info:
+ await _post_json(flaky_server.url, {"x": 1}, {}, timeout=10)
+ assert exc_info.value.status == 400
+ assert flaky_server.state["hits"] == 1
+
+
+def test_429_is_retryable():
+ assert 429 in RETRYABLE_STATUSES
+
+
+async def test_connection_error_retried(unused_tcp_port):
+ # nothing listening: pure connection failures, all attempts consumed
+ url = f"http://127.0.0.1:{unused_tcp_port}/run"
+ with patch("runpod.apps.targets.RETRY_BASE_DELAY", 0.01):
+ with pytest.raises(aiohttp.ClientConnectionError):
+ await _post_json(url, {"x": 1}, {}, timeout=5)
diff --git a/tests/test_apps/test_secret.py b/tests/test_apps/test_secret.py
new file mode 100644
index 00000000..fe40d8ea
--- /dev/null
+++ b/tests/test_apps/test_secret.py
@@ -0,0 +1,89 @@
+"""secret references, env rendering, and provision-time validation."""
+
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from runpod.apps.secret import (
+ Secret,
+ SecretError,
+ render_env,
+ secret_names,
+ validate_secrets,
+)
+
+
+class TestSecretRef:
+ def test_reference_syntax(self):
+ assert Secret("hf-token").reference == "{{ RUNPOD_SECRET_hf-token }}"
+
+ def test_invalid_names_raise(self):
+ with pytest.raises(SecretError):
+ Secret("")
+ with pytest.raises(SecretError):
+ Secret("has spaces")
+ with pytest.raises(SecretError):
+ Secret("no}}braces")
+
+ def test_valid_names(self):
+ for name in ("hf-token", "MY_KEY", "a.b-c_d", "x1"):
+ assert Secret(name).name == name
+
+
+class TestRenderEnv:
+ def test_mixed_env(self):
+ rendered = render_env(
+ {"HF_TOKEN": Secret("hf-token"), "MODE": "prod", "N": 3}
+ )
+ assert rendered == {
+ "HF_TOKEN": "{{ RUNPOD_SECRET_hf-token }}",
+ "MODE": "prod",
+ "N": "3",
+ }
+
+ def test_empty(self):
+ assert render_env(None) == {}
+ assert render_env({}) == {}
+
+ def test_secret_names_extraction(self):
+ names = secret_names(
+ {"A": Secret("one"), "B": "plain", "C": Secret("two")}
+ )
+ assert sorted(names) == ["one", "two"]
+ assert secret_names(None) == []
+
+
+class TestValidateSecrets:
+ def test_existing_pass(self):
+ api = AsyncMock()
+ api.list_secrets.return_value = [{"name": "hf-token"}]
+ asyncio.run(validate_secrets(["hf-token"], api=api))
+
+ def test_missing_raises_with_hint(self):
+ api = AsyncMock()
+ api.list_secrets.return_value = [{"name": "other"}]
+ with pytest.raises(SecretError, match="rp secret add"):
+ asyncio.run(validate_secrets(["hf-token"], api=api))
+
+ def test_no_references_no_api_call(self):
+ api = AsyncMock()
+ asyncio.run(validate_secrets([], api=api))
+ api.list_secrets.assert_not_awaited()
+
+
+class TestSpecManifest:
+ def test_manifest_renders_secret_references(self):
+ from runpod.apps.spec import ResourceKind, ResourceSpec
+
+ spec = ResourceSpec(
+ kind=ResourceKind.QUEUE,
+ name="q",
+ cpu=["cpu3c-1-2"],
+ env={"TOKEN": Secret("tok"), "MODE": "x"},
+ )
+ manifest = spec.to_manifest()
+ assert manifest["env"] == {
+ "TOKEN": "{{ RUNPOD_SECRET_tok }}",
+ "MODE": "x",
+ }
diff --git a/tests/test_apps/test_shim.py b/tests/test_apps/test_shim.py
new file mode 100644
index 00000000..5b55a21b
--- /dev/null
+++ b/tests/test_apps/test_shim.py
@@ -0,0 +1,56 @@
+"""tests for the shell launcher shim."""
+
+import base64
+import subprocess
+
+import pytest
+
+from runpod.apps.shim import shell_launcher
+
+
+def test_no_inner_single_quotes():
+ # the host lexes dockerArgs; an inner single quote would break the
+ # outer quoting
+ cmd = shell_launcher("VAR", "/dest.py")
+ assert cmd.startswith("sh -c '")
+ assert cmd.endswith("'")
+ inner = cmd[len("sh -c '") : -1]
+ assert "'" not in inner
+
+
+def test_posix_sh_not_bash():
+ cmd = shell_launcher("VAR", "/dest.py")
+ assert cmd.startswith("sh -c "), "must not require bash"
+
+
+def test_decodes_and_execs_locally(tmp_path):
+ """run the inner script under sh with a payload that writes a marker."""
+ marker = tmp_path / "marker"
+ payload = f"open({str(marker)!r}, 'w').write('ran')"
+ b64 = base64.b64encode(payload.encode()).decode()
+
+ dest = tmp_path / "injected.py"
+ cmd = shell_launcher("TESTVAR", str(dest))
+ inner = cmd[len("sh -c '") : -1]
+
+ result = subprocess.run(
+ ["sh", "-c", inner],
+ env={"TESTVAR": b64, "PATH": "/usr/bin:/bin:/usr/local/bin"},
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+ assert marker.read_text() == "ran"
+
+
+def test_probes_beyond_path():
+ cmd = shell_launcher("VAR", "/dest.py")
+ assert "/opt/conda/bin/python" in cmd
+ assert "/opt/venv/bin/python" in cmd
+
+
+def test_pythonless_image_fails_loudly():
+ cmd = shell_launcher("VAR", "/dest.py")
+ assert "FATAL" in cmd
+ assert "must include python3" in cmd
diff --git a/tests/test_apps/test_targets.py b/tests/test_apps/test_targets.py
new file mode 100644
index 00000000..b660c83e
--- /dev/null
+++ b/tests/test_apps/test_targets.py
@@ -0,0 +1,437 @@
+"""unit tests for invocation targets and their helpers."""
+
+import json
+import threading
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from runpod.apps.errors import RemoteExecutionError
+from runpod.apps.spec import ResourceKind, ResourceSpec
+from runpod.apps.targets import (
+ SENTINEL_ID,
+ LiveTarget,
+ SentinelTarget,
+ _api_key,
+ _headers,
+ _lb_domain,
+ _wait_terminal,
+ args_to_input,
+ unwrap_job_output,
+)
+
+# http.server's per-request sockets are collected lazily; the unraisable
+# checker flags them as ResourceWarnings non-deterministically
+pytestmark = pytest.mark.filterwarnings(
+ "ignore::pytest.PytestUnraisableExceptionWarning"
+)
+
+
+class TestApiKey:
+ def test_env_var(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "sk-env")
+ assert _api_key() == "sk-env"
+
+ def test_module_fallback(self, monkeypatch):
+ import runpod
+
+ monkeypatch.delenv("RUNPOD_API_KEY", raising=False)
+ monkeypatch.setattr(runpod, "api_key", "sk-module")
+ assert _api_key() == "sk-module"
+
+ def test_missing_raises(self, monkeypatch):
+ import runpod
+
+ monkeypatch.delenv("RUNPOD_API_KEY", raising=False)
+ monkeypatch.setattr(runpod, "api_key", None)
+ with pytest.raises(RuntimeError, match="rp login"):
+ _api_key()
+
+
+class TestUrlHelpers:
+ def test_lb_domain(self, monkeypatch):
+ import runpod
+
+ monkeypatch.setattr(
+ runpod, "endpoint_url_base", "https://api.runpod.ai/v2"
+ )
+ assert _lb_domain() == "api.runpod.ai"
+
+ def test_headers(self, monkeypatch):
+ monkeypatch.setenv("RUNPOD_API_KEY", "sk-test")
+ headers = _headers({"X-Extra": "1"})
+ assert headers["Authorization"] == "Bearer sk-test"
+ assert headers["X-Extra"] == "1"
+
+
+class TestArgsToInput:
+ def test_positional_mapping(self):
+ def fn(a, b, c=3):
+ pass
+
+ assert args_to_input(fn, (1, 2), {}) == {"a": 1, "b": 2}
+
+ def test_kwargs_merge(self):
+ def fn(a, b):
+ pass
+
+ assert args_to_input(fn, (1,), {"b": 2}) == {"a": 1, "b": 2}
+
+ def test_too_many_positional(self):
+ def fn(a):
+ pass
+
+ with pytest.raises(TypeError, match="positional"):
+ args_to_input(fn, (1, 2), {})
+
+ def test_empty_marker(self):
+ def fn():
+ pass
+
+ assert args_to_input(fn, (), {}) == {"__empty": True}
+
+
+class TestUnwrapJobOutput:
+ def test_completed(self):
+ assert unwrap_job_output(
+ {"status": "COMPLETED", "output": {"x": 1}}
+ ) == {"x": 1}
+
+ def test_failed_status(self):
+ with pytest.raises(RemoteExecutionError, match="boom"):
+ unwrap_job_output({"status": "FAILED", "error": "boom"})
+
+ def test_error_in_output(self):
+ with pytest.raises(RemoteExecutionError, match="oops"):
+ unwrap_job_output(
+ {"status": "COMPLETED", "output": {"error": "oops"}}
+ )
+
+ def test_missing_output_returns_data(self):
+ data = {"status": "COMPLETED", "value": 7}
+ assert unwrap_job_output(data) == data
+
+
+class TestWaitTerminal:
+ async def test_immediate_terminal(self):
+ data = {"id": "j1", "status": "COMPLETED", "output": 1}
+ result = await _wait_terminal("http://x", data, {}, timeout=5)
+ assert result is data
+
+ async def test_polls_to_completion(self):
+ polls = [
+ {"id": "j1", "status": "IN_PROGRESS"},
+ {"id": "j1", "status": "COMPLETED", "output": 2},
+ ]
+ seen = []
+ with (
+ patch(
+ "runpod.apps.targets._get_json",
+ AsyncMock(side_effect=polls),
+ ),
+ patch("asyncio.sleep", AsyncMock()),
+ ):
+ result = await _wait_terminal(
+ "http://x",
+ {"id": "j1", "status": "IN_QUEUE"},
+ {},
+ timeout=30,
+ on_status=seen.append,
+ )
+ assert result["status"] == "COMPLETED"
+ assert len(seen) == 3
+
+ async def test_no_job_id_returns_data(self):
+ data = {"status": "IN_QUEUE"}
+ result = await _wait_terminal("http://x", data, {}, timeout=5)
+ assert result is data
+
+ async def test_timeout(self):
+ with (
+ patch(
+ "runpod.apps.targets._get_json",
+ AsyncMock(return_value={"id": "j1", "status": "IN_PROGRESS"}),
+ ),
+ patch("asyncio.sleep", AsyncMock()),
+ patch("time.monotonic", side_effect=[0, 100, 200]),
+ ):
+ with pytest.raises(TimeoutError, match="did not complete"):
+ await _wait_terminal(
+ "http://x",
+ {"id": "j1", "status": "IN_QUEUE"},
+ {},
+ timeout=10,
+ )
+
+
+@pytest.fixture
+def local_endpoint(monkeypatch):
+ """local http server standing in for the serverless data plane."""
+ state = {"requests": [], "responses": {}}
+
+ class Handler(BaseHTTPRequestHandler):
+ def _respond(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ body = self.rfile.read(length) if length else b""
+ state["requests"].append(
+ {
+ "path": self.path,
+ "method": self.command,
+ "headers": dict(self.headers),
+ "body": json.loads(body) if body else None,
+ }
+ )
+ reply = state["responses"].get(
+ self.path, {"status": "COMPLETED", "output": {"ok": True}}
+ )
+ payload = json.dumps(reply).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+ do_GET = do_POST = do_PUT = _respond
+
+ def log_message(self, *args):
+ pass
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ import runpod
+
+ monkeypatch.setenv("RUNPOD_API_KEY", "sk-test")
+ monkeypatch.setattr(
+ runpod,
+ "endpoint_url_base",
+ f"http://127.0.0.1:{server.server_address[1]}",
+ )
+ server.state = state
+ yield server
+ server.shutdown()
+
+
+class TestSentinelTarget:
+ async def test_invoke_routes_through_sentinel(self, local_endpoint):
+ target = SentinelTarget("demo", "default", "chat")
+ result = await target.invoke({"input": {"prompt": "hi"}}, timeout=10)
+ assert result == {"ok": True}
+
+ request = local_endpoint.state["requests"][0]
+ assert request["path"] == f"/{SENTINEL_ID}/runsync"
+ assert request["headers"]["X-Flash-App"] == "demo"
+ assert request["headers"]["X-Flash-Environment"] == "default"
+ assert request["headers"]["X-Flash-Endpoint"] == "chat"
+
+ async def test_submit_and_wait(self, local_endpoint):
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/run"] = {
+ "id": "j1",
+ "status": "IN_QUEUE",
+ }
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/status/j1"] = {
+ "id": "j1",
+ "status": "COMPLETED",
+ "output": 42,
+ }
+ target = SentinelTarget("demo", "default", "chat")
+ job = await target.submit({"input": {}})
+ assert job["id"] == "j1"
+ assert await target.wait(job, timeout=10) == 42
+
+ async def test_job_operations(self, local_endpoint):
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/status/j1"] = {
+ "id": "j1",
+ "status": "IN_PROGRESS",
+ }
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/cancel/j1"] = {
+ "id": "j1",
+ "status": "CANCELLED",
+ }
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/retry/j1"] = {
+ "id": "j1",
+ "status": "IN_QUEUE",
+ }
+ target = SentinelTarget("demo", "production", "chat")
+
+ assert (await target.job_status("j1"))["status"] == "IN_PROGRESS"
+ assert (await target.cancel_job("j1"))["status"] == "CANCELLED"
+ assert (await target.retry_job("j1"))["status"] == "IN_QUEUE"
+
+ requests = local_endpoint.state["requests"]
+ assert [request["path"] for request in requests] == [
+ f"/{SENTINEL_ID}/status/j1",
+ f"/{SENTINEL_ID}/cancel/j1",
+ f"/{SENTINEL_ID}/retry/j1",
+ ]
+ assert requests[0]["headers"]["X-Flash-Environment"] == "production"
+
+ async def test_stream_job(self, local_endpoint):
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/stream/j1"] = {
+ "status": "COMPLETED",
+ "stream": [{"output": "a"}, {"output": "b"}],
+ }
+ target = SentinelTarget("demo", "default", "chat")
+
+ chunks = [c async for c in target.stream_job("j1", timeout=10)]
+ assert chunks == ["a", "b"]
+ request = local_endpoint.state["requests"][0]
+ assert request["path"] == f"/{SENTINEL_ID}/stream/j1"
+ assert request["headers"]["X-Flash-App"] == "demo"
+
+ async def test_stream_job_failed(self, local_endpoint):
+ local_endpoint.state["responses"][f"/{SENTINEL_ID}/stream/j1"] = {
+ "status": "FAILED",
+ "error": "boom",
+ }
+ target = SentinelTarget("demo", "default", "chat")
+
+ with pytest.raises(RemoteExecutionError, match="boom"):
+ async for _ in target.stream_job("j1", timeout=10):
+ pass
+
+ def test_payload_is_plain_kwargs(self):
+ target = SentinelTarget("demo", "default", "chat")
+
+ def fn(prompt):
+ pass
+
+ payload = target.build_payload(
+ fn, ResourceSpec(kind=ResourceKind.QUEUE, name="chat"), ("hi",), {}
+ )
+ assert payload == {"input": {"prompt": "hi"}}
+
+
+class TestLiveTarget:
+ def _spec(self):
+ return ResourceSpec(kind=ResourceKind.QUEUE, name="chat")
+
+ def test_payload_carries_source(self):
+ target = LiveTarget("ep123", "chat")
+
+ def fn(prompt):
+ return prompt
+
+ payload = target.build_payload(fn, self._spec(), ("hi",), {})
+ body = payload["input"]
+ assert body["function_name"] == "fn"
+ assert "def fn(" in body["function_code"]
+
+ def test_payload_args_are_plain_json(self):
+ target = LiveTarget("ep123", "chat")
+
+ def fn(prompt, n):
+ return prompt
+
+ payload = target.build_payload(fn, self._spec(), ("hi",), {"n": 2})
+ body = payload["input"]
+ assert body["args"] == ["hi"]
+ assert body["kwargs"] == {"n": 2}
+ assert body["serialization_format"] == "json"
+
+ def test_payload_rejects_non_json_args(self):
+ target = LiveTarget("ep123", "chat")
+
+ def fn(x):
+ return x
+
+ with pytest.raises(TypeError, match="json-serializable"):
+ target.build_payload(fn, self._spec(), (object(),), {})
+
+ def test_unwrap_success_response(self):
+ target = LiveTarget("ep123", "chat")
+ output = {"success": True, "result": None, "json_result": {"x": 1}}
+ assert target.unwrap(
+ {"status": "COMPLETED", "output": output}
+ ) == {"x": 1}
+
+ def test_unwrap_failure_raises(self):
+ target = LiveTarget("ep123", "chat")
+ output = {"success": False, "error": "worker exploded"}
+ with pytest.raises(RemoteExecutionError, match="worker exploded"):
+ target.unwrap({"status": "COMPLETED", "output": output})
+
+ def test_unwrap_passthrough(self):
+ target = LiveTarget("ep123", "chat")
+ assert target.unwrap(
+ {"status": "COMPLETED", "output": {"plain": 1}}
+ ) == {"plain": 1}
+
+ def test_unwrap_aggregated_plain_function(self):
+ # live handlers aggregate; a plain function is one unmarked envelope
+ target = LiveTarget("ep123", "chat")
+ output = [{"success": True, "json_result": {"x": 1}}]
+ assert target.unwrap(
+ {"status": "COMPLETED", "output": output}
+ ) == {"x": 1}
+
+ def test_unwrap_aggregated_generator(self):
+ target = LiveTarget("ep123", "chat")
+ output = [
+ {"success": True, "__stream__": True, "json_result": "a"},
+ {"success": True, "__stream__": True, "json_result": "b"},
+ ]
+ assert target.unwrap(
+ {"status": "COMPLETED", "output": output}
+ ) == ["a", "b"]
+
+ async def test_stream_job_unwraps_chunks(self, local_endpoint):
+ local_endpoint.state["responses"]["/ep123/stream/j1"] = {
+ "status": "COMPLETED",
+ "stream": [
+ {"output": {"success": True, "__stream__": True, "json_result": "a"}},
+ {"output": {"success": True, "__stream__": True, "json_result": "b"}},
+ ],
+ }
+ target = LiveTarget("ep123", "chat")
+
+ chunks = [c async for c in target.stream_job("j1", timeout=10)]
+ assert chunks == ["a", "b"]
+
+ async def test_invoke(self, local_endpoint):
+ target = LiveTarget("ep123", "chat")
+ data = await target.invoke({"input": {"x": 1}}, timeout=10)
+ assert data == {"ok": True}
+ request = local_endpoint.state["requests"][0]
+ assert request["path"] == "/ep123/runsync"
+
+ async def test_job_operations(self, local_endpoint):
+ local_endpoint.state["responses"]["/ep123/status/j1"] = {
+ "id": "j1",
+ "status": "IN_PROGRESS",
+ }
+ local_endpoint.state["responses"]["/ep123/cancel/j1"] = {
+ "id": "j1",
+ "status": "CANCELLED",
+ }
+ local_endpoint.state["responses"]["/ep123/retry/j1"] = {
+ "id": "j1",
+ "status": "IN_QUEUE",
+ }
+ target = LiveTarget("ep123", "chat")
+
+ assert (await target.job_status("j1"))["status"] == "IN_PROGRESS"
+ assert (await target.cancel_job("j1"))["status"] == "CANCELLED"
+ assert (await target.retry_job("j1"))["status"] == "IN_QUEUE"
+
+ async def test_sync_source_skips_unchanged(self, monkeypatch):
+ target = LiveTarget("ep123", "chat")
+
+ def backing():
+ return 1
+
+ target.attach_source(backing, "chat", self._spec())
+ post = AsyncMock(return_value={})
+ with patch("runpod.apps.targets._post_json", post):
+ await target._sync_source(timeout=10)
+ await target._sync_source(timeout=10)
+ assert post.await_count == 1
+
+ async def test_sync_source_noop_without_attachment(self):
+ target = LiveTarget("ep123", "chat")
+ post = AsyncMock()
+ with patch("runpod.apps.targets._post_json", post):
+ await target._sync_source(timeout=10)
+ post.assert_not_awaited()
diff --git a/tests/test_apps/test_task_runner_http.py b/tests/test_apps/test_task_runner_http.py
new file mode 100644
index 00000000..645b2d0f
--- /dev/null
+++ b/tests/test_apps/test_task_runner_http.py
@@ -0,0 +1,238 @@
+"""integration tests for the task runner http server.
+
+boots the real ThreadingHTTPServer on a random port and exercises the
+protocol over actual sockets. engine behavior (dependency install,
+serialization, stdout capture) is covered in test_executor.py.
+"""
+
+import base64
+import json
+import threading
+import urllib.error
+import urllib.request
+from http.server import ThreadingHTTPServer
+
+import cloudpickle
+import pytest
+
+from runpod.runtimes.task import runner as task_runner
+from runpod.runtimes.task.runner import Handler
+
+# http.server's per-request sockets are collected lazily; the unraisable
+# checker flags them as ResourceWarnings non-deterministically
+pytestmark = pytest.mark.filterwarnings(
+ "ignore::pytest.PytestUnraisableExceptionWarning"
+)
+
+TOKEN = "test-token"
+
+
+@pytest.fixture()
+def server(monkeypatch):
+ monkeypatch.setattr(task_runner, "TOKEN", TOKEN)
+ monkeypatch.setattr(
+ task_runner, "_job_state", {"status": "NONE", "response": None}
+ )
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ httpd.daemon_threads = True
+ thread = threading.Thread(target=httpd.serve_forever, daemon=True)
+ thread.start()
+ yield f"http://127.0.0.1:{httpd.server_port}"
+ httpd.shutdown()
+ thread.join(timeout=5)
+ httpd.server_close()
+
+
+def _request(url, method="GET", body=None, token=TOKEN):
+ req = urllib.request.Request(url, method=method)
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+ data = None
+ if body is not None:
+ data = json.dumps(body).encode()
+ req.add_header("Content-Type", "application/json")
+ with urllib.request.urlopen(req, data=data, timeout=10) as resp:
+ return resp.status, json.loads(resp.read())
+
+
+def _b64(value):
+ return base64.b64encode(cloudpickle.dumps(value)).decode()
+
+
+def _unb64(value):
+ return cloudpickle.loads(base64.b64decode(value))
+
+
+def test_ping_unauthenticated(server):
+ status, body = _request(f"{server}/ping", token=None)
+ assert status == 200
+ assert body == {"ready": True}
+
+
+def test_execute_requires_auth(server):
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
+ _request(f"{server}/execute", method="POST", body={}, token="wrong")
+ assert exc_info.value.code == 401
+
+
+def test_execute_roundtrip(server):
+ status, body = _request(
+ f"{server}/execute",
+ method="POST",
+ body={
+ "function_name": "mul",
+ "function_code": "def mul(a, b):\n return a * b",
+ "args": [_b64(6), _b64(7)],
+ "kwargs": {},
+ },
+ )
+ assert status == 200
+ assert body["success"] is True
+ assert _unb64(body["result"]) == 42
+
+
+def test_submit_and_result(server):
+ status, body = _request(
+ f"{server}/submit",
+ method="POST",
+ body={
+ "function_name": "quick",
+ "function_code": "def quick():\n return 'done'",
+ "args": [],
+ "kwargs": {},
+ "serialization_format": "json",
+ },
+ )
+ assert status == 200
+ assert body == {"status": "RUNNING"}
+
+ import time
+
+ deadline = time.monotonic() + 10
+ while time.monotonic() < deadline:
+ status, body = _request(f"{server}/result")
+ if body["status"] == "DONE":
+ break
+ time.sleep(0.1)
+
+ assert body["status"] == "DONE"
+ assert body["response"]["success"] is True
+ assert body["response"]["json_result"] == "done"
+
+
+def test_unknown_path_404(server):
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
+ _request(f"{server}/nope")
+ assert exc_info.value.code == 404
+
+
+def test_single_file_source_is_self_contained():
+ """the shipped runner must run without the runpod package installed."""
+ import subprocess
+ import sys
+
+ from runpod.apps.tasks import _runner_source
+
+ source = _runner_source()
+ assert "from runpod.runtimes.executor import" in source
+ # compile in an empty namespace with runpod imports failing
+ probe = (
+ "import sys\n"
+ "sys.modules['runpod'] = None\n" # forces the ImportError branch
+ "sys.argv = ['runner']\n"
+ + source.replace('if __name__ == "__main__":', "if False:")
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", probe], capture_output=True, text=True
+ )
+ assert result.returncode == 0, result.stderr
+
+
+class TestWatchdog:
+ def test_running_job_never_killed(self):
+ from runpod.runtimes.task.runner import _should_self_terminate
+
+ assert not _should_self_terminate("RUNNING", 0, 10_000, 600)
+
+ def test_abandoned_before_submit(self):
+ from runpod.runtimes.task.runner import _should_self_terminate
+
+ assert _should_self_terminate("NONE", 0, 601, 600)
+
+ def test_uncollected_result(self):
+ from runpod.runtimes.task.runner import _should_self_terminate
+
+ assert _should_self_terminate("DONE", 0, 601, 600)
+
+ def test_live_client_keeps_pod(self):
+ from runpod.runtimes.task.runner import _should_self_terminate
+
+ # polls every ~2s: last contact is always recent
+ assert not _should_self_terminate("DONE", 599, 600, 600)
+
+ def test_no_contact_recorded_yet(self):
+ from runpod.runtimes.task.runner import _should_self_terminate
+
+ assert not _should_self_terminate("NONE", None, 10_000, 600)
+
+ def test_authed_requests_touch_contact(self, server, monkeypatch):
+ monkeypatch.setattr(task_runner, "_last_contact", {"ts": None})
+ _request(f"{server}/result")
+ assert task_runner._last_contact["ts"] is not None
+
+
+class TestSelfTermination:
+ def test_terminate_self_calls_graphql_then_exits(self, monkeypatch):
+ import contextlib
+ import io as _io
+
+ calls = []
+
+ @contextlib.contextmanager
+ def _response():
+ yield _io.BytesIO(b"{}")
+
+ def fake_urlopen(req, timeout=None):
+ calls.append((req.full_url, req.data))
+ return _response()
+
+ exits = []
+ monkeypatch.setenv("RUNPOD_POD_ID", "pod-1")
+ monkeypatch.setenv("RUNPOD_API_KEY", "pod-scoped-key")
+ monkeypatch.setattr(
+ "urllib.request.urlopen", fake_urlopen
+ )
+ monkeypatch.setattr(
+ task_runner.os, "_exit", lambda code: exits.append(code)
+ )
+ task_runner._terminate_self()
+
+ assert exits == [0]
+ url, body = calls[0]
+ assert url.endswith("/graphql")
+ assert b"podTerminate" in body
+ assert b"pod-1" in body
+
+ def test_terminate_self_exits_without_credentials(self, monkeypatch):
+ exits = []
+ monkeypatch.delenv("RUNPOD_POD_ID", raising=False)
+ monkeypatch.delenv("RUNPOD_API_KEY", raising=False)
+ monkeypatch.setattr(
+ task_runner.os, "_exit", lambda code: exits.append(code)
+ )
+ task_runner._terminate_self()
+ assert exits == [0]
+
+ def test_terminate_self_survives_api_failure(self, monkeypatch):
+ def fail(req, timeout=None):
+ raise OSError("network down")
+
+ exits = []
+ monkeypatch.setenv("RUNPOD_POD_ID", "pod-1")
+ monkeypatch.setenv("RUNPOD_API_KEY", "k")
+ monkeypatch.setattr("urllib.request.urlopen", fail)
+ monkeypatch.setattr(
+ task_runner.os, "_exit", lambda code: exits.append(code)
+ )
+ task_runner._terminate_self()
+ assert exits == [0]
diff --git a/tests/test_apps/test_tasks.py b/tests/test_apps/test_tasks.py
new file mode 100644
index 00000000..91656f10
--- /dev/null
+++ b/tests/test_apps/test_tasks.py
@@ -0,0 +1,520 @@
+"""tests for the task runner and pod task transport."""
+
+# the sync bridge finalizes coroutines on a background loop thread;
+# AsyncMock coroutines observed there trip the unraisable checker
+# non-deterministically under full-suite gc timing
+import pytest as _pytest
+
+pytestmark = _pytest.mark.filterwarnings(
+ "ignore::pytest.PytestUnraisableExceptionWarning"
+)
+
+import base64
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import cloudpickle
+import pytest
+
+import runpod
+from runpod.apps import App
+from runpod.apps.app import _clear_registry
+from runpod.apps.errors import RemoteExecutionError
+from runpod.apps.spec import ResourceKind, ResourceSpec
+from runpod.runtimes.executor import execute_request
+from runpod.apps.tasks import _pod_input, unwrap_task_response
+from runpod.apps.targets import PodTarget
+
+
+@pytest.fixture(autouse=True)
+def clean_registry():
+ _clear_registry()
+ yield
+ _clear_registry()
+
+
+def _b64(value):
+ return base64.b64encode(cloudpickle.dumps(value)).decode("utf-8")
+
+
+def _unb64(value):
+ return cloudpickle.loads(base64.b64decode(value))
+
+
+class TestExecuteRequest:
+ def test_simple_function(self):
+ response = execute_request(
+ {
+ "function_name": "add",
+ "function_code": "def add(a, b):\n return a + b",
+ "args": [_b64(2), _b64(3)],
+ "kwargs": {},
+ }
+ )
+ assert response["success"] is True
+ assert _unb64(response["result"]) == 5
+
+ def test_json_format(self):
+ response = execute_request(
+ {
+ "function_name": "add",
+ "function_code": "def add(a, b):\n return a + b",
+ "args": [2, 3],
+ "kwargs": {},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"] is True
+ assert response["json_result"] == 5
+
+ def test_async_function(self):
+ response = execute_request(
+ {
+ "function_name": "hello",
+ "function_code": (
+ "async def hello(name):\n return f'hi {name}'"
+ ),
+ "args": [],
+ "kwargs": {"name": _b64("world")},
+ }
+ )
+ assert response["success"] is True
+ assert _unb64(response["result"]) == "hi world"
+
+ def test_stdout_captured(self):
+ response = execute_request(
+ {
+ "function_name": "loud",
+ "function_code": "def loud():\n print('noise')\n return 1",
+ "args": [],
+ "kwargs": {},
+ "serialization_format": "json",
+ }
+ )
+ assert response["success"] is True
+ assert "noise" in response["stdout"]
+
+ def test_exception_reported(self):
+ response = execute_request(
+ {
+ "function_name": "boom",
+ "function_code": "def boom():\n raise ValueError('bad')",
+ "args": [],
+ "kwargs": {},
+ }
+ )
+ assert response["success"] is False
+ assert "ValueError" in response["error"]
+
+ def test_missing_function(self):
+ response = execute_request(
+ {
+ "function_name": "nope",
+ "function_code": "x = 1",
+ "args": [],
+ "kwargs": {},
+ }
+ )
+ assert response["success"] is False
+ assert "not found" in response["error"]
+
+
+class TestPodInput:
+ def _spec(self, **kw):
+ defaults = dict(kind=ResourceKind.TASK, name="t")
+ defaults.update(kw)
+ return ResourceSpec(**defaults)
+
+ def test_cpu_pod_input(self):
+ pod = _pod_input(self._spec(cpu=["cpu3c-1-2"]), "tok", "t")
+ assert pod["instanceIds"] == ["cpu3c-1-2"]
+ from runpod.apps.images import local_python_version
+
+ assert (
+ pod["imageName"]
+ == f"runpod/task:py{local_python_version()}-latest"
+ )
+ assert pod["ports"] == "8080/http"
+ assert pod["terminateAfter"]
+ env = {e["key"]: e["value"] for e in pod["env"]}
+ assert env["RUNPOD_TASK_TOKEN"] == "tok"
+ # runtime images have the runner baked in; no env payload
+ assert "RUNPOD_TASK_RUNNER_B64" not in env
+ assert "dockerArgs" not in pod
+
+ def test_gpu_pod_input(self):
+ pod = _pod_input(
+ self._spec(gpu=["NVIDIA GeForce RTX 4090"], gpu_count=2), "tok", "t"
+ )
+ assert pod["gpuTypeIdList"] == ["NVIDIA GeForce RTX 4090"]
+ assert pod["gpuCount"] == 2
+ # gpu runtime image matched to the local python version
+ from runpod.apps.images import local_python_version
+
+ assert (
+ pod["imageName"]
+ == f"runpod/task-gpu:py{local_python_version()}-latest"
+ )
+
+ def test_custom_image_bootstraps_runner(self):
+ pod = _pod_input(
+ self._spec(cpu=["cpu3c-1-2"], image="my/image:1", volume="vol-1"),
+ "tok",
+ "t",
+ )
+ assert pod["imageName"] == "my/image:1"
+ # volumes resolve at TaskExecution.start (placement solve),
+ # not in the static pod input
+ assert "networkVolumeId" not in pod
+ # custom images get the env-injection fallback
+ env = {e["key"]: e["value"] for e in pod["env"]}
+ assert "RUNPOD_TASK_RUNNER_B64" in env
+ decoded = base64.b64decode(env["RUNPOD_TASK_RUNNER_B64"]).decode()
+ assert "def execute_request" in decoded
+ assert "task_runner.py" in pod["dockerArgs"]
+
+ def test_datacenter_forwarded(self):
+ pod = _pod_input(
+ self._spec(cpu=["cpu3c-1-2"], datacenter=["EU-RO-1"]), "tok", "t"
+ )
+ assert pod["dataCenterIds"] == ["EU-RO-1"]
+
+
+class TestUnwrapTaskResponse:
+ def test_success_cloudpickle(self):
+ assert unwrap_task_response({"success": True, "result": _b64(42)}) == 42
+
+ def test_success_json(self):
+ assert (
+ unwrap_task_response({"success": True, "json_result": {"a": 1}})
+ == {"a": 1}
+ )
+
+ def test_failure_raises(self):
+ with pytest.raises(RemoteExecutionError, match="boom"):
+ unwrap_task_response({"success": False, "error": "boom"})
+
+
+class TestPodTargetPayload:
+ def test_payload_carries_source_and_args(self):
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu3c-1-2", dependencies=["numpy"])
+ def t(x):
+ return x
+
+ target = PodTarget(t.spec, t._fn)
+ payload = target.build_payload(t._fn, t.spec, (5,), {})
+ assert payload["function_name"] == "t"
+ assert "def t(x):" in payload["function_code"]
+ assert _unb64(payload["args"][0]) == 5
+ assert payload["dependencies"] == ["numpy"]
+
+ def test_task_remote_runs_full_lifecycle(self):
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu3c-1-2")
+ def t(x):
+ return x * 2
+
+ with patch("runpod.apps.tasks.TaskExecution") as MockExec:
+ instance = MockExec.return_value
+ instance.start = AsyncMock()
+ instance.wait_ready = AsyncMock()
+ instance.execute = AsyncMock(
+ return_value={"success": True, "result": _b64(10)}
+ )
+ instance.terminate = AsyncMock()
+
+ result = t.remote(5)
+
+ assert result == 10
+ instance.start.assert_awaited_once()
+ instance.wait_ready.assert_awaited_once()
+ instance.terminate.assert_awaited_once()
+
+ def test_task_terminates_pod_on_failure(self):
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu3c-1-2")
+ def t():
+ pass
+
+ with patch("runpod.apps.tasks.TaskExecution") as MockExec:
+ instance = MockExec.return_value
+ instance.start = AsyncMock()
+ instance.wait_ready = AsyncMock(
+ side_effect=TimeoutError("never ready")
+ )
+ instance.terminate = AsyncMock()
+
+ with pytest.raises(TimeoutError):
+ t.remote()
+
+ instance.terminate.assert_awaited_once()
+
+ def test_task_spawn_returns_job(self):
+ app = App("a")
+
+ @app.task(name="t", cpu="cpu3c-1-2")
+ def t():
+ pass
+
+ with patch("runpod.apps.tasks.TaskExecution") as MockExec:
+ instance = MockExec.return_value
+ instance.start = AsyncMock()
+ instance.wait_ready = AsyncMock()
+ instance.submit = AsyncMock()
+ instance.pod_id = "pod-1"
+
+ job = t.spawn()
+
+ from runpod.apps.tasks import TaskJob
+
+ assert isinstance(job, TaskJob)
+ assert job.pod_id == "pod-1"
+ instance.submit.assert_awaited_once()
+
+
+class TestPollResult:
+ """poll_result must tolerate proxy propagation 404s."""
+
+ def _server(self, responses):
+ """a local server that pops one canned (status, body) per hit."""
+ import http.server
+ import threading
+
+ hits = {"count": 0}
+
+ class H(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ hits["count"] += 1
+ status, body = responses[min(hits["count"], len(responses)) - 1]
+ payload = json.dumps(body).encode()
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(payload)))
+ self.end_headers()
+ self.wfile.write(payload)
+
+ def log_message(self, *args):
+ pass
+
+ httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H)
+ httpd.daemon_threads = True
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ return httpd, hits
+
+ def _poll(self, url):
+ import asyncio
+
+ from runpod.apps.tasks import TaskExecution
+
+ spec = ResourceSpec(kind=ResourceKind.TASK, name="t", cpu=["cpu3c-1-2"])
+ execution = TaskExecution(spec)
+ execution.pod_id = "fake"
+ with (
+ patch("runpod.apps.tasks._proxy_url", return_value=url),
+ patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()),
+ ):
+ return asyncio.run(execution.poll_result())
+
+ def test_retries_transient_404_then_returns_result(self):
+ httpd, hits = self._server(
+ [
+ (404, {"error": "not found"}),
+ (404, {"error": "not found"}),
+ (200, {"status": "DONE", "response": {"success": True}}),
+ ]
+ )
+ try:
+ url = f"http://127.0.0.1:{httpd.server_port}"
+ assert self._poll(url) == {"success": True}
+ assert hits["count"] == 3
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
+
+ def test_persistent_404_raises(self):
+ import aiohttp
+
+ httpd, hits = self._server([(404, {"error": "not found"})])
+ try:
+ url = f"http://127.0.0.1:{httpd.server_port}"
+ with pytest.raises(aiohttp.ClientResponseError):
+ self._poll(url)
+ assert hits["count"] == 6
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
+
+ def test_running_returns_none(self):
+ httpd, _ = self._server([(200, {"status": "RUNNING", "response": None})])
+ try:
+ url = f"http://127.0.0.1:{httpd.server_port}"
+ assert self._poll(url) is None
+ finally:
+ httpd.shutdown()
+ httpd.server_close()
+
+
+class TestGpuPoolExpansion:
+ def test_pool_id_expands_to_device_names(self):
+ from runpod.apps.tasks import _device_names
+
+ assert _device_names(["ADA_24"]) == ["NVIDIA GeForce RTX 4090"]
+
+ def test_device_name_passes_through(self):
+ from runpod.apps.tasks import _device_names
+
+ assert _device_names(["NVIDIA L40S"]) == ["NVIDIA L40S"]
+
+ def test_none_expands_to_all_devices(self):
+ # the pod api has no "any" wildcard; unconstrained gpu means
+ # every known device name
+ from runpod.apps.gpu import POOLS_TO_TYPES
+ from runpod.apps.tasks import _device_names
+
+ names = _device_names(None)
+ assert "any" not in names
+ all_devices = {
+ t.value for types in POOLS_TO_TYPES.values() for t in types
+ }
+ assert set(names) == all_devices
+
+ def test_mixed_pool_and_device(self):
+ from runpod.apps.tasks import _device_names
+
+ names = _device_names(["AMPERE_48", "NVIDIA L40S"])
+ assert "NVIDIA A40" in names and "NVIDIA L40S" in names
+
+
+class TestTaskExecutionLifecycle:
+ def _spec(self, **overrides):
+ from runpod.apps.spec import ResourceKind, ResourceSpec
+
+ defaults = dict(kind=ResourceKind.TASK, name="t", cpu=["cpu3c-1-2"])
+ defaults.update(overrides)
+ return ResourceSpec(**defaults)
+
+ async def test_start_deploys_pod(self):
+ from runpod.apps.tasks import TaskExecution
+
+ api = MagicMock()
+ api.deploy_task_pod = AsyncMock(return_value={"id": "pod-9"})
+ execution = TaskExecution(self._spec(), api=api)
+ await execution.start()
+ assert execution.pod_id == "pod-9"
+ assert api.deploy_task_pod.call_args[1]["is_cpu"] is True
+
+ async def test_start_resolves_registry_auth(self):
+ from runpod.apps.tasks import TaskExecution
+
+ api = MagicMock()
+ api.deploy_task_pod = AsyncMock(return_value={"id": "pod-9"})
+ execution = TaskExecution(
+ self._spec(image="private/img", registry_auth="dh"), api=api
+ )
+ with patch(
+ "runpod.apps.registry.resolve_registry_auth",
+ AsyncMock(return_value="auth-1"),
+ ):
+ await execution.start()
+ pod = api.deploy_task_pod.call_args[0][0]
+ assert pod["containerRegistryAuthId"] == "auth-1"
+
+ async def test_terminate_swallows_api_errors(self):
+ from runpod.apps.tasks import TaskExecution
+
+ api = MagicMock()
+ api.terminate_pod = AsyncMock(side_effect=RuntimeError("api down"))
+ execution = TaskExecution(self._spec(), api=api)
+ execution.pod_id = "pod-9"
+ await execution.terminate() # must not raise
+ assert execution.pod_id is None
+
+ async def test_terminate_noop_without_pod(self):
+ from runpod.apps.tasks import TaskExecution
+
+ api = MagicMock()
+ api.terminate_pod = AsyncMock()
+ execution = TaskExecution(self._spec(), api=api)
+ await execution.terminate()
+ api.terminate_pod.assert_not_awaited()
+
+ async def test_execute_polls_to_done(self):
+ from runpod.apps.tasks import TaskExecution
+
+ execution = TaskExecution(self._spec(), api=MagicMock())
+ execution.pod_id = "pod-9"
+ execution.submit = AsyncMock()
+ polls = [None, {"success": True, "json_result": 4}]
+ execution.poll_result = AsyncMock(side_effect=polls)
+ with patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()):
+ response = await execution.execute({"fn": "t"}, timeout=60)
+ assert response == {"success": True, "json_result": 4}
+
+ async def test_execute_timeout(self):
+ from runpod.apps.tasks import TaskExecution
+
+ execution = TaskExecution(self._spec(), api=MagicMock())
+ execution.pod_id = "pod-9"
+ execution.submit = AsyncMock()
+ execution.poll_result = AsyncMock(return_value=None)
+ with (
+ patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()),
+ patch(
+ "runpod.apps.tasks.time.monotonic",
+ side_effect=[0, 0, 100],
+ ),
+ ):
+ with pytest.raises(TimeoutError, match="did not finish"):
+ await execution.execute({"fn": "t"}, timeout=10)
+
+
+class TestTaskJob:
+ def _job(self):
+ from runpod.apps.tasks import TaskExecution, TaskJob
+
+ execution = MagicMock(spec=TaskExecution)
+ execution.pod_id = "pod-9"
+ execution.terminate = AsyncMock()
+ return TaskJob(execution), execution
+
+ async def test_wait_returns_result_and_terminates(self):
+ job, execution = self._job()
+ execution.poll_result = AsyncMock(
+ side_effect=[None, {"success": True, "json_result": 9}]
+ )
+ with patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()):
+ result = await job.wait()
+ assert result == 9
+ execution.terminate.assert_awaited_once()
+
+ async def test_wait_timeout_keeps_pod(self):
+ job, execution = self._job()
+ execution.poll_result = AsyncMock(return_value=None)
+ with (
+ patch("runpod.apps.tasks.asyncio.sleep", AsyncMock()),
+ patch(
+ "runpod.apps.tasks.time.monotonic", side_effect=[0, 100]
+ ),
+ ):
+ with pytest.raises(TimeoutError):
+ await job.wait(timeout=10)
+ execution.terminate.assert_not_awaited()
+
+ async def test_wait_after_done_returns_cached(self):
+ job, execution = self._job()
+ execution.poll_result = AsyncMock(
+ return_value={"success": True, "json_result": 9}
+ )
+ assert await job.wait() == 9
+ assert await job.wait() == 9
+ assert execution.poll_result.await_count == 1
+
+ async def test_cancel_terminates(self):
+ job, execution = self._job()
+ await job.cancel()
+ execution.terminate.assert_awaited_once()
+ assert job._done
diff --git a/tests/test_apps/test_volume.py b/tests/test_apps/test_volume.py
new file mode 100644
index 00000000..bda7cc44
--- /dev/null
+++ b/tests/test_apps/test_volume.py
@@ -0,0 +1,180 @@
+"""volume references and provision-time resolution."""
+
+import asyncio
+from unittest.mock import AsyncMock
+
+import pytest
+
+from runpod.apps.spec import ResourceKind, ResourceSpec
+from runpod.apps.volume import (
+ Volume,
+ VolumeError,
+ VolumeResolver,
+ volume_list,
+)
+
+
+def _spec(name="r", gpu=None, cpu=None):
+ return ResourceSpec(
+ kind=ResourceKind.TASK, name=name, gpu=gpu, cpu=cpu
+ )
+
+
+def _api(volumes=None, created=None):
+ api = AsyncMock()
+ api.list_network_volumes.return_value = volumes or []
+ api.create_network_volume.return_value = created or {
+ "id": "nv-new",
+ "name": "models",
+ "size": 50,
+ "dataCenterId": "EU-RO-1",
+ }
+ # stock queries: everything available everywhere
+ api.gpu_stock_status.return_value = "High"
+ api.cpu_stock_status.return_value = "High"
+ return api
+
+
+class TestVolumeRef:
+ def test_path_follows_context(self, monkeypatch):
+ from pathlib import Path
+
+ # task pods mount at /workspace
+ monkeypatch.delenv("RUNPOD_ENDPOINT_ID", raising=False)
+ assert Volume("models").path == Path("/workspace")
+ # endpoint workers mount at /runpod-volume
+ monkeypatch.setenv("RUNPOD_ENDPOINT_ID", "ep-1")
+ assert Volume("models").path == Path("/runpod-volume")
+
+ def test_empty_name_raises(self):
+ with pytest.raises(VolumeError):
+ Volume("")
+
+ def test_volume_list_normalizes(self):
+ vols = volume_list([Volume("a"), "b"])
+ assert [v.name for v in vols] == ["a", "b"]
+ assert volume_list(None) == []
+ assert [v.name for v in volume_list("single")] == ["single"]
+
+
+class TestVolumeResolver:
+ def test_existing_by_name(self):
+ api = _api(
+ volumes=[
+ {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"}
+ ]
+ )
+ resolver = VolumeResolver(api)
+ resolved = asyncio.run(
+ resolver.resolve(Volume("models"), [_spec(gpu=None)])
+ )
+ assert resolved == {"id": "nv-1", "dataCenterId": "EU-RO-1"}
+ api.create_network_volume.assert_not_awaited()
+
+ def test_existing_by_id(self):
+ api = _api(
+ volumes=[
+ {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"}
+ ]
+ )
+ resolver = VolumeResolver(api)
+ resolved = asyncio.run(
+ resolver.resolve(Volume("nv-1"), [_spec(gpu=None)])
+ )
+ assert resolved["id"] == "nv-1"
+
+ def test_missing_creates_with_placement(self):
+ api = _api()
+ resolver = VolumeResolver(api)
+ resolved = asyncio.run(
+ resolver.resolve(Volume("models"), [_spec(gpu=None)])
+ )
+ assert resolved["id"] == "nv-new"
+ api.create_network_volume.assert_awaited_once()
+
+ def test_missing_no_create_raises(self):
+ api = _api()
+ resolver = VolumeResolver(api)
+ with pytest.raises(VolumeError, match="create=False"):
+ asyncio.run(
+ resolver.resolve(
+ Volume("models", create=False), [_spec(gpu=None)]
+ )
+ )
+
+ def test_duplicate_names_raise(self):
+ api = _api(
+ volumes=[
+ {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"},
+ {"id": "nv-2", "name": "models", "size": 50, "dataCenterId": "US-KS-2"},
+ ]
+ )
+ resolver = VolumeResolver(api)
+ with pytest.raises(VolumeError, match="reference by id"):
+ asyncio.run(
+ resolver.resolve(Volume("models"), [_spec(gpu=None)])
+ )
+
+ def test_resolution_cached_per_name(self):
+ api = _api(
+ volumes=[
+ {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"}
+ ]
+ )
+ resolver = VolumeResolver(api)
+
+ async def run():
+ await resolver.resolve(Volume("models"), [_spec(gpu=None)])
+ await resolver.resolve(Volume("models"), [_spec(gpu=None)])
+
+ asyncio.run(run())
+ assert api.list_network_volumes.await_count == 1
+
+ def test_created_event_emitted(self):
+ events = []
+
+ class Sink:
+ def volume_created(self, name, size, dc):
+ events.append((name, size, dc))
+
+ api = _api()
+ resolver = VolumeResolver(api, events=Sink())
+ asyncio.run(resolver.resolve(Volume("models"), [_spec(gpu=None)]))
+ assert len(events) == 1
+ name, size, dc = events[0]
+ assert (name, size) == ("models", 50)
+ assert dc # placement picks a concrete datacenter
+
+
+class TestTaskVolume:
+ def test_single_volume_only(self):
+ from runpod.apps.tasks import TaskExecution
+
+ spec = ResourceSpec(
+ kind=ResourceKind.TASK,
+ name="t",
+ cpu=["cpu3c-1-2"],
+ volume=[Volume("a"), Volume("b")],
+ )
+ execution = TaskExecution(spec, api=_api())
+ with pytest.raises(VolumeError, match="exactly one volume"):
+ asyncio.run(execution._attach_volume({}))
+
+ def test_pod_pins_to_volume_dc(self):
+ from runpod.apps.tasks import TaskExecution
+
+ api = _api(
+ volumes=[
+ {"id": "nv-1", "name": "models", "size": 50, "dataCenterId": "EU-RO-1"}
+ ]
+ )
+ spec = ResourceSpec(
+ kind=ResourceKind.TASK,
+ name="t",
+ cpu=["cpu3c-1-2"],
+ volume=Volume("models"),
+ )
+ execution = TaskExecution(spec, api=api)
+ pod = asyncio.run(execution._attach_volume({}))
+ assert pod["networkVolumeId"] == "nv-1"
+ assert pod["dataCenterIds"] == ["EU-RO-1"]
diff --git a/tests/test_apps/test_watch.py b/tests/test_apps/test_watch.py
new file mode 100644
index 00000000..62193ba1
--- /dev/null
+++ b/tests/test_apps/test_watch.py
@@ -0,0 +1,62 @@
+"""tests for the dev-session file watcher."""
+
+import time
+
+from runpod.apps.watch import FileWatcher
+
+
+def test_no_change_initially(tmp_path):
+ (tmp_path / "a.py").write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ assert watcher.changed() is False
+
+
+def test_detects_modification(tmp_path):
+ f = tmp_path / "a.py"
+ f.write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ time.sleep(0.01)
+ f.write_text("x = 2")
+ # mtime granularity can be coarse; force it
+ import os
+
+ os.utime(f, (time.time() + 1, time.time() + 1))
+ assert watcher.changed() is True
+ assert watcher.changed() is False
+
+
+def test_detects_new_file(tmp_path):
+ (tmp_path / "a.py").write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ (tmp_path / "b.py").write_text("y = 2")
+ assert watcher.changed() is True
+
+
+def test_detects_deleted_file(tmp_path):
+ f = tmp_path / "a.py"
+ f.write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ f.unlink()
+ assert watcher.changed() is True
+
+
+def test_ignores_non_python(tmp_path):
+ (tmp_path / "a.py").write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ (tmp_path / "notes.txt").write_text("hi")
+ assert watcher.changed() is False
+
+
+def test_ignores_skip_dirs(tmp_path):
+ (tmp_path / "a.py").write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ venv = tmp_path / ".venv"
+ venv.mkdir()
+ (venv / "pkg.py").write_text("z = 3")
+ assert watcher.changed() is False
+
+
+def test_wait_for_change_timeout(tmp_path):
+ (tmp_path / "a.py").write_text("x = 1")
+ watcher = FileWatcher([tmp_path])
+ assert watcher.wait_for_change(poll_interval=0.05, timeout=0.2) is False
diff --git a/tests/test_cli/test_cli_groups/test_config_commands.py b/tests/test_cli/test_cli_groups/test_config_commands.py
deleted file mode 100644
index 16da467a..00000000
--- a/tests/test_cli/test_cli_groups/test_config_commands.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""
-Runpod | Tests | CLI | Commands
-"""
-
-import unittest
-from unittest.mock import patch
-
-from click.testing import CliRunner
-
-from runpod.cli.entry import runpod_cli
-
-
-class TestCommands(unittest.TestCase):
- """A collection of tests for the CLI commands."""
-
- def setUp(self):
- self.runner = CliRunner()
-
- def test_config_wizard(self):
- """Tests the config command."""
- with patch("click.echo") as mock_echo, patch(
- "runpod.cli.groups.config.commands.set_credentials"
- ) as mock_set_credentials, patch(
- "runpod.cli.groups.config.commands.check_credentials"
- ) as mock_check_creds, patch(
- "click.confirm", return_value=True
- ) as mock_confirm, patch(
- "click.prompt", return_value="KEY"
- ) as mock_prompt:
-
- # Assuming credentials aren't set (doesn't prompt for overwrite)
- mock_check_creds.return_value = (False, None)
-
- # Successful Call with Direct Key
- result = self.runner.invoke(
- runpod_cli, ["config", "--profile", "test", "KEY"]
- )
- assert result.exit_code == 0
- mock_set_credentials.assert_called_with("KEY", "test", overwrite=True)
- assert mock_echo.call_count == 1
-
- # Successful Call with Prompted Key (since direct key isn't provided)
- result = self.runner.invoke(runpod_cli, ["config", "--profile", "test"])
- assert result.exit_code == 0
- mock_set_credentials.assert_called_with("KEY", "test", overwrite=True)
- mock_prompt.assert_called_with(
- " > Runpod API Key", hide_input=False, confirmation_prompt=False
- ) # pylint: disable=line-too-long
-
- # Simulating existing credentials, prompting for overwrite
- mock_check_creds.return_value = (True, None)
- result = self.runner.invoke(runpod_cli, ["config", "--profile", "test"])
- mock_confirm.assert_called_with(
- "Credentials already set for profile: test. Overwrite?", abort=True
- )
-
- # Unsuccessful Call
- mock_set_credentials.side_effect = ValueError()
- result = self.runner.invoke(
- runpod_cli, ["config", "--profile", "test", "KEY"]
- )
- assert result.exit_code == 1
-
- def test_check_flag(self):
- """Tests the --check flag."""
- with patch(
- "runpod.cli.groups.config.commands.check_credentials"
- ) as mock_check_creds:
- # Assuming credentials are set
- mock_check_creds.return_value = (True, None)
- result = self.runner.invoke(
- runpod_cli, ["config", "--check", "--profile", "test"]
- )
- assert result.exit_code == 0
-
- # Assuming credentials aren't set
- mock_check_creds.return_value = (False, "Credentials not found.")
- result = self.runner.invoke(
- runpod_cli, ["config", "--check", "--profile", "test"]
- )
- assert result.exit_code == 1
-
- def test_output_messages(self):
- """Tests the output messages for the config command."""
- with patch("click.echo") as mock_echo, patch(
- "runpod.cli.groups.config.commands.set_credentials"
- ) as mock_set_credentials, patch(
- "runpod.cli.groups.config.commands.check_credentials",
- return_value=(False, None),
- ) as mock_check_creds: # pylint: disable=line-too-long
- result = self.runner.invoke(
- runpod_cli, ["config", "KEY", "--profile", "test"]
- )
- mock_set_credentials.assert_called_with("KEY", "test", overwrite=True)
- mock_echo.assert_any_call(
- "Credentials set for profile: test in ~/.runpod/config.toml"
- )
- assert result.exit_code == 0
- assert mock_check_creds.call_count == 1
-
- def test_api_key_prompt(self):
- """Tests the API key prompt."""
- with patch("click.prompt", return_value="KEY") as mock_prompt, patch(
- "runpod.cli.groups.config.commands.set_credentials"
- ) as mock_set_credentials, patch(
- "runpod.cli.groups.config.commands.check_credentials",
- return_value=(False, None)
- ):
- result = self.runner.invoke(runpod_cli, ["config", "--profile", "test"])
- mock_prompt.assert_called_with(
- " > Runpod API Key", hide_input=False, confirmation_prompt=False
- ) # pylint: disable=line-too-long
- mock_set_credentials.assert_called_with("KEY", "test", overwrite=True)
- assert result.exit_code == 0
diff --git a/tests/test_cli/test_cli_groups/test_exec_commands.py b/tests/test_cli/test_cli_groups/test_exec_commands.py
deleted file mode 100644
index d8a0edf6..00000000
--- a/tests/test_cli/test_cli_groups/test_exec_commands.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-Tests for Runpod CLI exec commands.
-"""
-
-import tempfile
-import unittest
-from unittest.mock import patch
-
-import click
-from click.testing import CliRunner
-
-from runpod.cli.entry import runpod_cli
-
-
-class TestExecCommands(unittest.TestCase):
- """Tests for Runpod CLI exec commands."""
- def setUp(self):
- self.runner = CliRunner()
- self.runner = click.testing.CliRunner()
-
- def test_remote_python_with_provided_pod_id(self):
- """Tests the remote_python command when pod_id is provided directly."""
- with tempfile.NamedTemporaryFile() as temp_file, patch(
- "runpod.cli.groups.exec.commands.python_over_ssh"
- ) as mock_python_over_ssh:
- result = self.runner.invoke(
- runpod_cli,
- ["exec", "python", "--pod_id", "sample_pod_id", temp_file.name],
- )
- assert result.exit_code == 0
- mock_python_over_ssh.assert_called_with("sample_pod_id", temp_file.name)
-
- def test_remote_python_without_provided_pod_id_stored(self):
- """Tests the remote_python command when pod_id is retrieved from storage."""
- with tempfile.NamedTemporaryFile() as temp_file, patch(
- "runpod.cli.groups.exec.commands.python_over_ssh"
- ) as mock_python_over_ssh, patch(
- "runpod.cli.groups.exec.commands.get_session_pod",
- return_value="stored_pod_id",
- ) as mock_get_pod_id: # pylint: disable=line-too-long
- mock_python_over_ssh.return_value = None
- result = self.runner.invoke(runpod_cli, ["exec", "python", temp_file.name])
- assert result.exit_code == 0
- mock_get_pod_id.assert_called_once()
- mock_python_over_ssh.assert_called_with("stored_pod_id", temp_file.name)
-
- def test_remote_python_without_provided_pod_id_prompt(self):
- """Tests the remote_python command when pod_id is prompted to user."""
- with tempfile.NamedTemporaryFile() as temp_file, patch(
- "runpod.cli.groups.exec.commands.python_over_ssh"
- ) as mock_python_over_ssh, patch(
- "runpod.cli.groups.exec.commands.get_session_pod",
- return_value="prompted_pod_id",
- ) as mock_get_pod_id: # pylint: disable=line-too-long
- mock_python_over_ssh.return_value = None
- result = self.runner.invoke(runpod_cli, ["exec", "python", temp_file.name])
- assert result.exit_code == 0
- mock_get_pod_id.assert_called_once()
- mock_python_over_ssh.assert_called_with("prompted_pod_id", temp_file.name)
diff --git a/tests/test_cli/test_cli_groups/test_exec_functions.py b/tests/test_cli/test_cli_groups/test_exec_functions.py
deleted file mode 100644
index 511ebe30..00000000
--- a/tests/test_cli/test_cli_groups/test_exec_functions.py
+++ /dev/null
@@ -1,29 +0,0 @@
-""" Tests for CLI group `exec functions` """
-
-import unittest
-from unittest.mock import MagicMock, patch
-
-from runpod.cli.groups.exec import functions
-
-
-class TestExecFunctions(unittest.TestCase):
- """Tests for CLI group `exec functions`"""
-
- @patch("runpod.cli.groups.exec.functions.ssh_cmd.SSHConnection")
- def test_python_over_ssh(self, mock_ssh_connection):
- """
- Test `python_over_ssh`
- """
- mock_ssh = MagicMock()
- mock_ssh_connection.return_value = mock_ssh
-
- pod_id = "pod_id"
- file_name = "file_name"
-
- functions.python_over_ssh(pod_id, file_name)
-
- self.assertTrue(functions.python_over_ssh)
- mock_ssh_connection.assert_called_once_with(pod_id)
- mock_ssh.put_file.assert_called_once_with(file_name, f"/root/{file_name}")
- mock_ssh.run_commands.assert_called_once_with([f"python3.10 /root/{file_name}"])
- mock_ssh.close.assert_called_once_with()
diff --git a/tests/test_cli/test_cli_groups/test_exec_helpers.py b/tests/test_cli/test_cli_groups/test_exec_helpers.py
deleted file mode 100644
index 248a684c..00000000
--- a/tests/test_cli/test_cli_groups/test_exec_helpers.py
+++ /dev/null
@@ -1,74 +0,0 @@
-""" Unit tests for the runpod.cli.groups.exec.helpers module. """
-
-import unittest
-from unittest.mock import mock_open, patch
-
-from runpod.cli.groups.exec.helpers import POD_ID_FILE, get_session_pod
-
-
-class TestGetSessionPod(unittest.TestCase):
- """Unit tests for get_session_pod"""
-
- def setUp(self):
- self.mocked_pod_id = "sample_pod_id"
-
- @patch("os.path.exists")
- @patch("runpod.cli.groups.exec.helpers.get_pod")
- def test_existing_pod_id_file_and_valid_pod_id(self, mock_get_pod, mock_exists):
- """Test get_session_pod when the pod_id file exists and the pod_id is valid"""
- mock_exists.return_value = True
- mock_get_pod.return_value = True
- with patch("builtins.open", mock_open(read_data=self.mocked_pod_id)):
- result = get_session_pod()
- self.assertEqual(result, self.mocked_pod_id)
-
- @patch("os.path.exists")
- @patch("runpod.cli.groups.exec.helpers.get_pod")
- def test_existing_pod_id_file_and_invalid_pod_id(self, mock_get_pod, mock_exists):
- """Test get_session_pod when the pod_id file exists and the pod_id is invalid"""
- mock_exists.return_value = True
- mock_get_pod.return_value = None
- with patch("builtins.open", mock_open(read_data="invalid_pod_id")):
- with patch("click.prompt", return_value=self.mocked_pod_id):
- result = get_session_pod()
- self.assertEqual(result, self.mocked_pod_id)
-
- @patch("os.path.exists")
- @patch("runpod.cli.groups.exec.helpers.get_pod")
- def test_no_pod_id_file(self, mock_get_pod, mock_exists):
- """Test get_session_pod when the pod_id file doesn't exist"""
- mock_exists.return_value = False
- mock_get_pod.return_value = None
- with patch("click.prompt", return_value=self.mocked_pod_id):
- result = get_session_pod()
- self.assertEqual(result, self.mocked_pod_id)
-
- @patch("os.path.exists")
- @patch("runpod.cli.groups.exec.helpers.get_pod")
- def test_pod_id_file_written_to_when_not_existing(self, mock_get_pod, mock_exists):
- """Test get_session_pod when the pod_id file doesn't exist"""
- mock_exists.return_value = False
- mock_get_pod.return_value = None
- mocked = mock_open()
- with patch("builtins.open", mocked):
- with patch("click.prompt", return_value=self.mocked_pod_id):
- get_session_pod()
- mocked.assert_called_once_with(POD_ID_FILE, "w", encoding="UTF-8")
- handle = mocked()
- handle.write.assert_called_once_with(self.mocked_pod_id)
-
- @patch("os.path.exists")
- @patch("runpod.cli.groups.exec.helpers.get_pod")
- def test_pod_id_file_written_to_when_invalid_pod_id_in_file(
- self, mock_get_pod, mock_exists
- ):
- """Test get_session_pod when the pod_id file exists and the pod_id is invalid"""
- mock_exists.return_value = True
- mock_get_pod.return_value = None
- mocked = mock_open(read_data="invalid_pod_id")
- with patch("builtins.open", mocked):
- with patch("click.prompt", return_value=self.mocked_pod_id):
- get_session_pod()
- mocked.assert_called_with(POD_ID_FILE, "w", encoding="UTF-8")
- handle = mocked()
- handle.write.assert_called_with(self.mocked_pod_id)
diff --git a/tests/test_cli/test_cli_groups/test_pod_commands.py b/tests/test_cli/test_cli_groups/test_pod_commands.py
index 1eefbe79..2ce00b2b 100644
--- a/tests/test_cli/test_cli_groups/test_pod_commands.py
+++ b/tests/test_cli/test_cli_groups/test_pod_commands.py
@@ -184,7 +184,7 @@ def test_sync_pods_no_ssh_keys(self, mock_ssh_connection, mock_echo, mock_uuid):
# Verify error message was shown
mock_echo.assert_any_call("❌ No SSH keys found in your Runpod account!")
mock_echo.assert_any_call("🔑 To create an SSH key, run:")
- mock_echo.assert_any_call(" runpod ssh add-key")
+ mock_echo.assert_any_call(" rp ssh add")
@patch("runpod.cli.groups.pod.commands.uuid.uuid4")
@patch("runpod.cli.groups.pod.commands.click.echo")
diff --git a/tests/test_cli/test_cli_groups/test_project_commands.py b/tests/test_cli/test_cli_groups/test_project_commands.py
deleted file mode 100644
index 54a9fab0..00000000
--- a/tests/test_cli/test_cli_groups/test_project_commands.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""
-Runpod | CLI | Groups | Project | Commands | Tests
-"""
-
-import unittest
-from unittest.mock import patch
-
-from click.testing import CliRunner
-
-from runpod.cli.groups.project.commands import (
- deploy_project,
- new_project_wizard,
- start_project_pod,
-)
-
-
-class TestProjectCLI(unittest.TestCase):
- """A collection of tests for the Project CLI commands."""
-
- def setUp(self):
- self.runner = CliRunner()
-
- def test_new_project_wizard_no_network_volumes(self):
- """
- Tests the new_project_wizard command with no network volumes.
- """
- with patch("runpod.cli.groups.project.commands.get_user") as mock_get_user:
- mock_get_user.return_value = {"networkVolumes": []}
-
- result = self.runner.invoke(new_project_wizard)
-
- self.assertEqual(result.exit_code, 1)
- self.assertIn("You do not have any network volumes.", result.output)
-
- def test_new_project_wizard_success(self):
- """
- Tests the new_project_wizard command.
- """
- with patch("click.prompt") as mock_prompt, patch(
- "click.confirm", return_value=True
- ) as mock_confirm, patch(
- "runpod.cli.groups.project.commands.create_new_project"
- ) as mock_create, patch(
- "runpod.cli.groups.project.commands.get_user"
- ) as mock_get_user, patch(
- "runpod.cli.groups.project.commands.cli_select"
- ) as mock_select:
- mock_get_user.return_value = {
- "networkVolumes": [
- {
- "id": "XYZ_VOLUME",
- "name": "XYZ_VOLUME",
- "size": 100,
- "dataCenterId": "XYZ",
- }
- ]
- } # pylint: disable=line-too-long
- mock_prompt.side_effect = ["TestProject", "11.8.0", "3.10"]
- mock_select.return_value = {"volume-id": "XYZ_VOLUME"}
-
- result = self.runner.invoke(
- new_project_wizard,
- ["--type", "llama2", "--model", "meta-llama/Llama-2-7b"],
- ) # pylint: disable=line-too-long
-
- self.assertEqual(result.exit_code, 0)
- mock_confirm.assert_called_with("Do you want to continue?", abort=True)
- mock_create.assert_called()
- mock_prompt.assert_called()
- mock_create.assert_called_with(
- "TestProject",
- "XYZ_VOLUME",
- "11.8.0",
- "3.10",
- "llama2",
- "meta-llama/Llama-2-7b",
- False,
- ) # pylint: disable=line-too-long
- self.assertIn("Project TestProject created successfully!", result.output)
-
- def test_new_project_wizard_success_init_current_dir(self):
- """
- Tests the new_project_wizard command with the --init flag.
- """
- with patch("click.prompt") as mock_prompt, patch(
- "click.confirm", return_value=True
- ) as mock_confirm, patch(
- "runpod.cli.groups.project.commands.create_new_project"
- ) as mock_create, patch(
- "runpod.cli.groups.project.commands.get_user"
- ) as mock_get_user, patch(
- "runpod.cli.groups.project.commands.cli_select"
- ) as mock_select, patch(
- "os.getcwd"
- ) as mock_getcwd:
- mock_get_user.return_value = {
- "networkVolumes": [
- {
- "id": "XYZ_VOLUME",
- "name": "XYZ_VOLUME",
- "size": 100,
- "dataCenterId": "XYZ",
- }
- ]
- } # pylint: disable=line-too-long
- mock_select.return_value = {"volume-id": "XYZ_VOLUME"}
- mock_prompt.side_effect = ["XYZ_VOLUME", "11.8.0", "3.10"]
-
- self.runner.invoke(new_project_wizard, ["--init"])
- assert mock_confirm.called
- assert mock_create.called
- assert mock_getcwd.called
-
- def test_new_project_wizard_invalid_name(self):
- """
- Tests the new_project_wizard command with an invalid project name.
- """
- with patch("runpod.cli.groups.project.commands.get_user") as mock_get_user:
- mock_get_user.return_value = {"networkVolumes": ["XYZ_VOLUME"]}
-
- result = self.runner.invoke(new_project_wizard, ["--name", "Invalid/Name"])
-
- self.assertEqual(result.exit_code, 2)
- self.assertIn("Project name contains an invalid character", result.output)
-
- def test_start_project_pod(self):
- """
- Tests the start_project_pod command.
- """
- with patch("click.confirm", return_value=True) as mock_confirm, patch(
- "runpod.cli.groups.project.commands.start_project"
- ) as mock_start:
- mock_start.return_value = None
- result = self.runner.invoke(start_project_pod)
-
- mock_confirm.assert_called_with("Do you want to continue?", abort=True)
- self.assertEqual(result.exit_code, 0)
- self.assertIn("Starting project development pod...", result.output)
-
- @patch("runpod.cli.groups.project.commands.click.echo")
- @patch("runpod.cli.groups.project.commands.create_project_endpoint")
- def test_deploy_project(self, mock_create_project_endpoint, mock_click_echo):
- """Test the deploy_project function."""
- mock_create_project_endpoint.return_value = "test_endpoint_id"
-
- result = self.runner.invoke(deploy_project)
-
- mock_create_project_endpoint.assert_called_once()
-
- mock_click_echo.assert_any_call("Deploying project...")
- mock_click_echo.assert_any_call("The following urls are available:")
- mock_click_echo.assert_any_call(
- " - https://api.runpod.ai/v2/test_endpoint_id/runsync"
- )
- mock_click_echo.assert_any_call(
- " - https://api.runpod.ai/v2/test_endpoint_id/run"
- )
- mock_click_echo.assert_any_call(
- " - https://api.runpod.ai/v2/test_endpoint_id/health"
- )
-
- self.assertEqual(result.exit_code, 0)
diff --git a/tests/test_cli/test_cli_groups/test_project_functions.py b/tests/test_cli/test_cli_groups/test_project_functions.py
deleted file mode 100644
index 667d71e6..00000000
--- a/tests/test_cli/test_cli_groups/test_project_functions.py
+++ /dev/null
@@ -1,338 +0,0 @@
-""" Test functions in runpod.cli.groups.project.functions module. """
-
-import os
-import shutil
-import unittest
-from unittest.mock import mock_open, patch
-
-from runpod.cli.groups.project.functions import (
- STARTER_TEMPLATES,
- create_new_project,
- create_project_endpoint,
- start_project,
-)
-
-
-class TestCreateNewProject(unittest.TestCase):
- """Test the create_new_project function."""
-
- def tearDown(self):
- toml_file_location = os.path.join(os.getcwd(), "test_project")
- if os.path.exists(toml_file_location):
- shutil.rmtree(toml_file_location)
-
- @patch("os.makedirs")
- @patch("os.path.exists", return_value=False)
- @patch("os.getcwd", return_value="/current/path")
- @patch("runpod.cli.groups.project.functions.copy_template_files")
- def test_create_project_folder(
- self, mock_copy_template_files, mock_getcwd, mock_exists, mock_makedirs
- ): # pylint: disable=line-too-long
- """Test that a new project folder is created if init_current_dir is False."""
- with patch("builtins.open", new_callable=mock_open):
- create_new_project("test_project", "volume_id", "11.1.1", "3.8")
- mock_makedirs.assert_called_once_with("/current/path/test_project")
- assert mock_copy_template_files.called
- assert mock_getcwd.called
- assert mock_exists.called
-
- @patch("os.makedirs")
- @patch("os.path.exists", return_value=False)
- @patch("os.getcwd", return_value="/tmp/testdir")
- @patch("builtins.open", new_callable=mock_open)
- def test_create_new_project_init_current_dir(
- self, mock_file_open, mock_getcwd, mock_path_exists, mock_makedirs
- ): # pylint: disable=line-too-long
- """Test that a new project folder is not created if init_current_dir is True."""
- project_name = "test_project"
- runpod_volume_id = "12345"
- cuda_version = "11.1.1"
- python_version = "3.9"
-
- create_new_project(
- project_name,
- runpod_volume_id,
- cuda_version,
- python_version,
- init_current_dir=True,
- )
- mock_makedirs.assert_not_called()
- mock_file_open.assert_called_with(
- "/tmp/testdir/runpod.toml", "w", encoding="UTF-8"
- )
- assert mock_getcwd.called
- assert mock_path_exists.called is False
-
- @patch("os.makedirs")
- @patch("os.path.exists", return_value=False)
- @patch("os.getcwd", return_value="/current/path")
- @patch("runpod.cli.groups.project.functions.copy_template_files")
- def test_copy_template_files(
- self, mock_copy_template_files, mock_getcwd, mock_exists, mock_makedirs
- ): # pylint: disable=line-too-long
- """Test that template files are copied to the new project folder."""
- with patch("builtins.open", new_callable=mock_open):
- create_new_project("test_project", "volume_id", "11.1.1", "3.8")
- mock_copy_template_files.assert_called_once_with(
- STARTER_TEMPLATES + "/default", "/current/path/test_project"
- ) # pylint: disable=line-too-long
- assert mock_getcwd.called
- assert mock_exists.called
- assert mock_makedirs.called
-
- @patch("os.path.exists", return_value=True)
- @patch(
- "builtins.open",
- new_callable=mock_open,
- read_data="data with <> placeholder",
- ) # pylint: disable=line-too-long
- def test_replace_placeholders_in_handler(
- self, mock_open_file, mock_exists
- ): # pylint: disable=line-too-long
- """Test that placeholders in handler.py are replaced if model_name is given."""
- with patch("runpod.cli.groups.project.functions.copy_template_files"):
- create_new_project(
- "test_project", "volume_id", "11.8.0", "3.8", model_name="my_model"
- )
- assert mock_open_file.called
- assert mock_exists.called
-
- @patch("os.path.exists", return_value=False)
- @patch("builtins.open", new_callable=mock_open)
- def test_create_runpod_toml(self, mock_open_file, mock_exists):
- """Test that runpod.toml configuration file is created."""
- with patch("runpod.cli.groups.project.functions.copy_template_files"):
- create_new_project("test_project", "volume_id", "11.8.0", "3.8")
- toml_file_location = os.path.join(os.getcwd(), "test_project", "runpod.toml")
- mock_open_file.assert_called_with(
- toml_file_location, "w", encoding="UTF-8"
- ) # pylint: disable=line-too-long
- assert mock_exists.called
-
- @patch("os.path.exists", return_value=True)
- @patch("builtins.open", new_callable=mock_open, read_data="<> placeholder")
- def test_update_requirements_file(self, mock_open_file, mock_exists):
- """Test that placeholders in requirements.txt are replaced correctly."""
- with patch("runpod.cli.groups.project.functions.__version__", "dev"), patch(
- "runpod.cli.groups.project.functions.copy_template_files"
- ):
- create_new_project("test_project", "volume_id", "11.8.0", "3.8")
- assert mock_open_file.called
- assert mock_exists.called
-
- @patch("os.path.exists", return_value=True)
- @patch("builtins.open", new_callable=mock_open, read_data="<> placeholder")
- def test_update_requirements_file_non_dev(self, mock_open_file, mock_exists):
- """Test that placeholders in requirements.txt are replaced for non-dev versions."""
- with patch("runpod.cli.groups.project.functions.__version__", "1.0.0"), patch(
- "runpod.cli.groups.project.functions.copy_template_files"
- ):
- create_new_project("test_project", "volume_id", "11.8.0", "3.8")
- assert mock_open_file.called
- assert mock_exists.called
-
-
-class TestStartProject(unittest.TestCase):
- """Test the start_project function."""
-
- @patch("runpod.cli.groups.project.functions.load_project_config")
- @patch("runpod.cli.groups.project.functions.get_project_pod")
- @patch("runpod.cli.groups.project.functions.attempt_pod_launch")
- @patch("runpod.cli.groups.project.functions.get_pod")
- @patch("runpod.cli.groups.project.functions.SSHConnection")
- @patch("os.getcwd", return_value="/current/path")
- def test_start_nonexistent_successfully(
- self,
- mock_getcwd,
- mock_ssh_connection,
- mock_get_pod,
- mock_attempt_pod_launch,
- mock_get_project_pod,
- mock_load_project_config,
- ): # pylint: disable=line-too-long, too-many-arguments
- """Test that a project is launched successfully."""
- mock_load_project_config.return_value = {
- "project": {
- "uuid": "123456",
- "name": "test_project",
- "volume_mount_path": "/mount/path",
- "env_vars": {"ENV_VAR": "value"},
- "gpu": "NVIDIA GPU",
- },
- "runtime": {
- "python_version": "3.8",
- "handler_path": "handler.py",
- "requirements_path": "requirements.txt",
- },
- }
-
- mock_get_project_pod.return_value = False
-
- mock_attempt_pod_launch.return_value = {
- "id": "new_pod_id",
- "desiredStatus": "PENDING",
- "runtime": None,
- }
-
- mock_get_pod.return_value = {
- "id": "new_pod_id",
- "desiredStatus": "RUNNING",
- "runtime": "ONLINE",
- }
-
- mock_ssh_instance = mock_ssh_connection.return_value
- mock_ssh_instance.__enter__.return_value = mock_ssh_instance
- mock_ssh_instance.run_commands.return_value = None
-
- with patch(
- "runpod.cli.groups.project.functions.sync_directory"
- ) as mock_sync_directory:
- start_project()
-
- mock_attempt_pod_launch.assert_called()
- mock_get_pod.assert_called_with("new_pod_id")
- mock_ssh_connection.assert_called_with("new_pod_id")
- mock_ssh_instance.run_commands.assert_called()
- assert mock_getcwd.called
- assert mock_sync_directory.called
-
- @patch("runpod.cli.groups.project.functions.get_project_pod")
- @patch("runpod.cli.groups.project.functions.attempt_pod_launch")
- def test_failed_pod_launch(self, mock_attempt_pod, mock_get_pod):
- """Test that a project is not launched if pod launch fails."""
- mock_attempt_pod.return_value = None
- mock_get_pod.return_value = None
-
- with patch("builtins.print") as mock_print, patch(
- "runpod.cli.groups.project.functions.load_project_config"
- ):
-
- start_project()
- mock_print.assert_called_with(
- "Selected GPU types unavailable, try again later or use a different type."
- ) # pylint: disable=line-too-long
-
-
-class TestStartProjectAPI(unittest.TestCase):
- """Test the start_project_api function."""
-
- @patch("runpod.cli.groups.project.functions.load_project_config")
- @patch("runpod.cli.groups.project.functions.get_project_pod")
- @patch("runpod.cli.groups.project.functions.SSHConnection")
- @patch("os.getcwd", return_value="/current/path")
- @patch("runpod.cli.groups.project.functions.sync_directory")
- def test_start_project_api_successfully(
- self,
- mock_sync_directory,
- mock_getcwd,
- mock_ssh_connection,
- mock_get_project_pod,
- mock_load_project_config,
- ): # pylint: disable=line-too-long, too-many-arguments
- """Test that a project API is started successfully."""
- mock_load_project_config.return_value = {
- "project": {
- "uuid": "123456",
- "name": "test_project",
- "volume_mount_path": "/mount/path",
- },
- "runtime": {
- "python_version": "3.8",
- "handler_path": "handler.py",
- "requirements_path": "requirements.txt",
- },
- }
-
- mock_get_project_pod.return_value = {"id": "pod_id"}
-
- mock_ssh_instance = mock_ssh_connection.return_value
- mock_ssh_instance.__enter__.return_value = mock_ssh_instance
- mock_ssh_instance.run_commands.return_value = None
-
- start_project()
-
- mock_get_project_pod.assert_called_with("123456")
- mock_ssh_connection.assert_called_with({"id": "pod_id"})
- mock_sync_directory.assert_called_with(
- mock_ssh_instance, "/current/path", "/mount/path/123456/dev"
- )
- mock_ssh_instance.run_commands.assert_called()
- assert mock_getcwd.called
-
-
-class TestCreateProjectEndpoint(unittest.TestCase):
- """Test the create_project_endpoint function."""
-
- @patch("runpod.cli.groups.project.functions.SSHConnection")
- @patch("runpod.cli.groups.project.functions.load_project_config")
- @patch("runpod.cli.groups.project.functions.create_template")
- @patch("runpod.cli.groups.project.functions.create_endpoint")
- @patch("runpod.cli.groups.project.functions.update_endpoint_template")
- @patch("runpod.cli.groups.project.functions.get_project_pod")
- @patch("runpod.cli.groups.project.functions.get_project_endpoint")
- def test_create_project_endpoint(
- self,
- mock_get_project_endpoint,
- mock_get_project_pod,
- mock_update_endpoint,
- mock_create_endpoint, # pylint: disable=too-many-arguments,line-too-long
- mock_create_template,
- mock_load_project_config,
- mock_ssh_connection,
- ): # pylint: disable=line-too-long
- """Test that a project endpoint is created successfully."""
- mock_get_project_endpoint.return_value = False
-
- mock_get_project_pod.return_value = None
- with patch(
- "runpod.cli.groups.project.functions._launch_dev_pod"
- ) as mock_launch_dev_pod:
- mock_launch_dev_pod.return_value = None
- assert create_project_endpoint() is None
-
- mock_get_project_pod.return_value = {"id": "test_pod_id"}
- mock_load_project_config.return_value = {
- "project": {
- "name": "test_project",
- "volume_mount_path": "/runpod-volume/123456",
- "uuid": "123456",
- "env_vars": {"TEST_VAR": "value"},
- "base_image": "test_image",
- "container_disk_size_gb": 10,
- "storage_id": "test_storage_id",
- },
- "runtime": {
- "python_version": "3.8",
- "handler_path": "handler.py",
- "requirements_path": "requirements.txt",
- },
- }
- mock_create_template.return_value = {"id": "test_template_id"}
- mock_create_endpoint.return_value = {"id": "test_endpoint_id"}
-
- mock_ssh_instance = mock_ssh_connection.return_value
- mock_ssh_instance.__enter__.return_value = mock_ssh_instance
- mock_ssh_instance.run_commands.return_value = None
-
- with patch("runpod.cli.groups.project.functions.datetime") as mock_datetime:
- mock_datetime.now.return_value = "123456"
- result = create_project_endpoint()
-
- self.assertEqual(result, "test_endpoint_id")
- mock_create_template.assert_called_with(
- name="test_project-endpoint | 123456 | 123456",
- image_name="test_image",
- container_disk_in_gb=10,
- docker_start_cmd='bash -c ". /runpod-volume/123456/prod/venv/bin/activate && python -u /runpod-volume/123456/prod/test_project/handler.py"', # pylint: disable=line-too-long
- env={"TEST_VAR": "value"},
- is_serverless=True,
- )
- mock_create_endpoint.assert_called_with(
- name="test_project-endpoint | 123456",
- template_id="test_template_id",
- network_volume_id="test_storage_id",
- )
-
- mock_update_endpoint.return_value = {"id": "test_endpoint_id"}
- mock_get_project_endpoint.return_value = {"id": "test_endpoint_id"}
- self.assertEqual(create_project_endpoint(), "test_endpoint_id")
diff --git a/tests/test_cli/test_cli_groups/test_project_helpers.py b/tests/test_cli/test_cli_groups/test_project_helpers.py
deleted file mode 100644
index 9b3437c8..00000000
--- a/tests/test_cli/test_cli_groups/test_project_helpers.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""Tests for the project helpers."""
-
-import unittest
-from unittest.mock import mock_open, patch
-
-import click
-
-from runpod import error as rp_error
-from runpod.cli.groups.project.helpers import (
- attempt_pod_launch,
- copy_template_files,
- get_project_endpoint,
- get_project_pod,
- load_project_config,
- validate_project_name,
-)
-
-
-class TestHelpers(unittest.TestCase):
- """Test the project helpers."""
-
- def test_validate_project_name_valid(self):
- """Test the validate_project_name function with valid input."""
- name = "validProjectName"
- result = validate_project_name(name)
- self.assertEqual(result, name)
-
- def test_validate_project_name_invalid(self):
- """Test the validate_project_name function with invalid input."""
- name = "invalid:name"
- with self.assertRaises(click.BadParameter):
- validate_project_name(name)
-
- @patch("runpod.cli.groups.project.helpers.get_pods")
- def test_get_project_pod_exists(self, mock_get_pods):
- """Test the get_project_pod function when the project pod exists."""
- mock_get_pods.return_value = [{"name": "test-1234", "id": "pod_id"}]
- result = get_project_pod("1234")
- self.assertEqual(result, "pod_id")
-
- @patch("runpod.cli.groups.project.helpers.get_pods")
- def test_get_project_pod_not_exists(self, mock_get_pods):
- """Test the get_project_pod function when the project pod doesn't exist."""
- mock_get_pods.return_value = [{"name": "another-5678", "id": "another_pod_id"}]
- result = get_project_pod("1234")
- self.assertIsNone(result)
-
- @patch("runpod.cli.groups.project.helpers.get_endpoints")
- def test_get_project_endpoint_exists(self, mock_get_endpoints):
- """Test the get_project_endpoint function when the project endpoint exists."""
- mock_get_endpoints.return_value = []
- assert get_project_endpoint("1234") is None
-
- mock_get_endpoints.return_value = [{"name": "test-1234", "id": "endpoint_id"}]
- result = get_project_endpoint("1234")
- self.assertEqual(result, {"name": "test-1234", "id": "endpoint_id"})
-
- @patch("os.listdir")
- @patch("os.path.isdir", return_value=False)
- @patch("shutil.copy2")
- def test_copy_template_files(self, mock_copy, mock_isdir, mock_listdir):
- """Test the copy_template_files function."""
- mock_listdir.return_value = ["file1.txt", "file2.txt"]
- copy_template_files("/template", "/destination")
- self.assertEqual(mock_copy.call_count, 2)
- assert mock_isdir.called
-
- @patch("os.listdir")
- @patch("os.path.isdir", return_value=True)
- @patch("shutil.copytree")
- def test_copy_template_files_dir(self, mock_copy, mock_isdir, mock_listdir):
- """Test the copy_template_files function."""
- mock_listdir.return_value = ["file1.txt", "file2.txt"]
- copy_template_files("/template", "/destination")
- self.assertEqual(mock_copy.call_count, 2)
- assert mock_isdir.called
-
- @patch("runpod.cli.groups.project.helpers.create_pod")
- def test_attempt_pod_launch_success(self, mock_create_pod):
- """Test the attempt_pod_launch function when it succeeds."""
- mock_create_pod.return_value = "pod_id"
- config = {
- "project": {
- "name": "test",
- "uuid": "1234",
- "base_image": "base_image",
- "gpu_types": ["gpu_type"],
- "gpu_count": "1",
- "ports": "ports",
- "storage_id": "storage_id",
- "volume_mount_path": "volume_mount_path",
- "container_disk_size_gb": "1",
- }
- }
- environment_variables = {"key": "value"}
- result = attempt_pod_launch(config, environment_variables)
- self.assertEqual(result, "pod_id")
-
- mock_create_pod.side_effect = rp_error.QueryError("error")
- assert attempt_pod_launch(config, environment_variables) is None
-
- @patch("os.path.exists", return_value=True)
- @patch("builtins.open", new_callable=mock_open, read_data="[project]\nname='test'")
- def test_load_project_config(self, mock_file, mock_exists):
- """Test the load_project_config function."""
- config = load_project_config()
- self.assertEqual(config["project"]["name"], "test")
- assert mock_exists.called
- assert mock_file.called
-
- with patch("os.path.exists", return_value=False), self.assertRaises(
- FileNotFoundError
- ):
- load_project_config()
diff --git a/tests/test_cli/test_cli_groups/test_ssh_dispatch.py b/tests/test_cli/test_cli_groups/test_ssh_dispatch.py
new file mode 100644
index 00000000..fa74cc13
--- /dev/null
+++ b/tests/test_cli/test_cli_groups/test_ssh_dispatch.py
@@ -0,0 +1,41 @@
+"""rp ssh: pod-id dispatch and subcommand resolution."""
+
+from unittest.mock import MagicMock, patch
+
+from click.testing import CliRunner
+
+from runpod.rp_cli.main import cli
+
+
+class TestSSHDispatch:
+ def test_pod_id_routes_to_connect(self):
+ runner = CliRunner()
+ with patch(
+ "runpod.cli.utils.ssh_cmd.SSHConnection"
+ ) as connection:
+ connection.return_value = MagicMock()
+ result = runner.invoke(cli, ["ssh", "pod-abc123"])
+ assert "Connecting to pod pod-abc123" in result.output
+ connection.assert_called_once_with("pod-abc123")
+
+ def test_connect_subcommand(self):
+ runner = CliRunner()
+ with patch(
+ "runpod.cli.utils.ssh_cmd.SSHConnection"
+ ) as connection:
+ connection.return_value = MagicMock()
+ result = runner.invoke(cli, ["ssh", "connect", "pod-abc123"])
+ assert "Connecting to pod pod-abc123" in result.output
+
+ def test_named_subcommands_still_resolve(self):
+ runner = CliRunner()
+ with patch(
+ "runpod.cli.groups.ssh.commands.get_user_pub_keys", return_value=[]
+ ):
+ result = runner.invoke(cli, ["ssh", "list"])
+ assert result.exit_code == 0
+
+ def test_bare_ssh_shows_help(self):
+ runner = CliRunner()
+ result = runner.invoke(cli, ["ssh"])
+ assert "Commands:" in result.output
diff --git a/tests/test_cli/test_cli_utils/test_info.py b/tests/test_cli/test_cli_utils/test_info.py
index 8f2d4ecc..464c96a8 100644
--- a/tests/test_cli/test_cli_utils/test_info.py
+++ b/tests/test_cli/test_cli_utils/test_info.py
@@ -41,5 +41,5 @@ def test_get_pod_ssh_ip_port_timeout(self):
mock_get_pod.return_value = {}
- with pytest.raises(TimeoutError):
+ with pytest.raises(ValueError, match="not found"):
get_pod_ssh_ip_port("pod_id", timeout=0.1)
diff --git a/tests/test_cli/test_cli_utils/test_sync.py b/tests/test_cli/test_cli_utils/test_sync.py
deleted file mode 100644
index adfc998f..00000000
--- a/tests/test_cli/test_cli_utils/test_sync.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""Tests for runpod.cli.utils.rp_sync module."""
-
-import time
-import unittest
-from unittest.mock import ANY, MagicMock, patch
-
-from runpod.cli import STOP_EVENT
-from runpod.cli.utils.rp_sync import WatcherHandler, start_watcher, sync_directory
-
-
-class TestWatcherHandler(unittest.TestCase):
- """Tests for the WatcherHandler class."""
-
- @patch("runpod.cli.utils.rp_sync.should_ignore")
- def test_on_any_event_with_ignored_file(self, mock_should_ignore):
- """Test that the action function is not called when the file is ignored."""
- mock_should_ignore.return_value = True
- mock_action_function = MagicMock()
- handler = WatcherHandler(mock_action_function, "some_path")
-
- event_mock = MagicMock()
- event_mock.is_directory = False
- event_mock.src_path = "some_path/ignored_file.txt"
-
- handler.on_any_event(event_mock)
- mock_action_function.assert_not_called()
-
- @patch("runpod.cli.utils.rp_sync.should_ignore")
- def test_on_any_event_with_not_ignored_file(self, mock_should_ignore):
- """Test that the action function is called when the file is not ignored."""
- mock_should_ignore.return_value = False
- mock_action_function = MagicMock()
- handler = WatcherHandler(mock_action_function, "some_path")
-
- event_mock = MagicMock()
- event_mock.is_directory = False
- event_mock.src_path = "some_path/not_ignored_file.txt"
-
- handler.on_any_event(event_mock)
- handler.on_any_event(event_mock) # Call it twice to test the debouncer
- time.sleep(2)
- mock_action_function.assert_called_once()
-
- def test_on_any_event_with_directory(self):
- """Test that the action function is not called when the event is a directory."""
- mock_action_function = MagicMock()
- handler = WatcherHandler(mock_action_function, "some_path")
-
- event_mock = MagicMock()
- event_mock.is_directory = True
-
- handler.on_any_event(event_mock)
- time.sleep(2)
- mock_action_function.assert_not_called()
-
-
-class TestSyncDirectory(unittest.TestCase):
- """Tests for the sync_directory function."""
-
- @patch("runpod.cli.utils.rp_sync.threading.Thread")
- @patch("runpod.cli.utils.rp_sync.start_watcher")
- def test_sync_directory(self, mock_start_watcher, mock_thread_class):
- """Test that the sync_directory function calls the start_watcher function."""
- mock_ssh_client = MagicMock()
-
- local_path = "local_path"
- remote_path = "remote_path"
-
- sync_directory(mock_ssh_client, local_path, remote_path)
-
- target_function = mock_thread_class.call_args[1]["target"]
- target_function()
-
- mock_start_watcher.assert_called_once()
-
- @patch("runpod.cli.utils.rp_sync.threading.Thread")
- @patch("runpod.cli.utils.rp_sync.start_watcher")
- def test_sync_directory_sync_function(self, mock_start_watcher, mock_thread_class):
- """Test that the sync_directory function calls the start_watcher function."""
- mock_ssh_client = MagicMock()
-
- local_path = "local_path"
- remote_path = "remote_path"
-
- sync_function = sync_directory(mock_ssh_client, local_path, remote_path)
- sync_function()
-
- mock_ssh_client.rsync.assert_called_once_with(
- local_path, remote_path, quiet=True
- )
-
- mock_thread_class.assert_called_once()
- mock_thread_class.assert_called_with(
- target=mock_start_watcher, daemon=True, args=(ANY, local_path)
- )
-
- assert mock_start_watcher.called is False
-
-
-class TestStartWatcher(unittest.TestCase):
- """Tests for the start_watcher function."""
-
- @patch("runpod.cli.utils.rp_sync.Observer")
- @patch("runpod.cli.utils.rp_sync.WatcherHandler")
- def test_start_watcher(self, mock_watch_handler, mock_observer_class):
- """Test that the start_watcher function starts the watcher correctly."""
- fake_action = MagicMock()
- local_path = "/path/to/watch"
-
- mock_observer_instance = mock_observer_class.return_value
-
- STOP_EVENT.clear()
- with patch("runpod.cli.utils.rp_sync.time.sleep") as mock_sleep:
-
- def side_effect(*args, **kwargs):
- del args, kwargs
- STOP_EVENT.set()
-
- mock_sleep.side_effect = side_effect
- start_watcher(fake_action, local_path)
-
- mock_watch_handler.assert_called_once_with(fake_action, local_path)
-
- mock_observer_instance.schedule.assert_called_once_with(
- mock_watch_handler.return_value, local_path, recursive=True
- )
-
- mock_observer_instance.start.assert_called_once()
- mock_observer_instance.stop.assert_called_once()
- mock_observer_instance.join.assert_called_once()
diff --git a/tests/test_cli/test_console.py b/tests/test_cli/test_console.py
new file mode 100644
index 00000000..4fe4717a
--- /dev/null
+++ b/tests/test_cli/test_console.py
@@ -0,0 +1,229 @@
+"""rendering tests for the rp cli console module."""
+
+import io
+
+import pytest
+from rich.console import Console
+
+from runpod.rp_cli import console as ui
+
+
+@pytest.fixture(autouse=True)
+def capture_console(monkeypatch):
+ """swap the module console for one writing to a buffer."""
+ buffer = io.StringIO()
+ test_console = Console(
+ file=buffer,
+ highlight=False,
+ theme=ui.theme,
+ force_terminal=False,
+ width=120,
+ )
+ monkeypatch.setattr(ui, "console", test_console)
+ yield buffer
+
+
+def _out(buffer):
+ return buffer.getvalue()
+
+
+class TestGenericLines:
+ def test_info(self, capture_console):
+ ui.info("hello")
+ assert "hello" in _out(capture_console)
+
+ def test_success(self, capture_console):
+ ui.success("done")
+ assert "✓ done" in _out(capture_console)
+
+ def test_error(self, capture_console):
+ ui.error("bad")
+ assert "✗ bad" in _out(capture_console)
+
+ def test_warn(self, capture_console):
+ ui.warn("careful")
+ assert "! careful" in _out(capture_console)
+
+
+class TestHelpers:
+ def test_endpoint_url(self):
+ assert ui.endpoint_url("ep1").endswith("/ep1?tab=overview")
+
+ def test_endpoint_link_markup(self):
+ assert "ep1 ↗" in ui.endpoint_link("ep1")
+
+ def test_name_width_padding(self):
+ ui.set_name_width(["a", "longer"])
+ assert ui._padded("a") == "a "
+ ui.set_name_width([])
+ assert ui._padded("a") == "a"
+
+ def test_kind_badge_known_and_unknown(self):
+ assert "queue" in ui.kind_badge("queue")
+ assert "other" in ui.kind_badge("other")
+
+ def test_fmt_elapsed(self):
+ assert ui._fmt_elapsed(0.5) == "500ms"
+ assert ui._fmt_elapsed(3.21) == "3.2s"
+ assert ui._fmt_elapsed(150) == "2m30s"
+
+ def test_bar_bounds(self):
+ assert "━" in ui._bar(0.5)
+ ui._bar(-1)
+ ui._bar(2)
+
+ def test_sand_spinner_registered(self):
+ from rich.spinner import SPINNERS
+
+ assert ui.RUNPOD_SAND_SPINNER in SPINNERS
+ assert (
+ SPINNERS[ui.RUNPOD_SAND_SPINNER]["frames"]
+ == ui.RUNPOD_SAND_SPINNER_FRAMES
+ )
+
+
+class TestBanners:
+ def test_dev_banner(self, capture_console):
+ ui.dev_banner(["demo"], "main.py")
+ assert "demo in main.py" in _out(capture_console)
+
+ def test_deploy_plan(self, capture_console):
+ ui.deploy_plan([("demo", "main.py", 2), ("other", "", 1)])
+ out = _out(capture_console)
+ assert "2 apps" in out
+ assert "main.py, 2 resources" in out
+ assert "1 resource" in out
+
+ def test_deploy_banner(self, capture_console):
+ ui.deploy_banner("demo", "prod", [("chat", "queue")], source="main.py")
+ out = _out(capture_console)
+ assert "demo" in out
+ assert "prod" in out
+ assert "chat" in out
+
+ def test_deploy_banner_no_resources(self, capture_console):
+ ui.deploy_banner("demo", "prod", [])
+ assert "(no resources)" in _out(capture_console)
+
+ def test_resources_table(self, capture_console):
+ ui.resources_table(
+ [
+ ("chat", "queue", "4090", "ep1"),
+ ("train", "task", "H100", "per-call"),
+ ]
+ )
+ out = _out(capture_console)
+ assert "chat" in out
+ assert "per-call" in out
+
+ def test_resources_table_empty(self, capture_console):
+ ui.resources_table([])
+ assert _out(capture_console) == ""
+
+ def test_reload_banner(self, capture_console):
+ ui.reload_banner("main.py")
+ assert "reloading" in _out(capture_console)
+
+ def test_entrypoint_lines(self, capture_console):
+ ui.entrypoint_header("main")
+ ui.entrypoint_success(1.2)
+ ui.entrypoint_failure(3.4, "boom")
+ out = _out(capture_console)
+ assert "main()" in out
+ assert "done" in out
+ assert "boom" in out
+
+
+class TestDeployEvents:
+ def test_phase_lifecycle(self, capture_console):
+ events = ui.DeployEvents()
+ events.phase("vendor")
+ events.vendor_progress(3, "torch")
+ events.phase("upload")
+ events.upload_progress(512 * 1024, 1024 * 1024)
+ events.phase("endpoints")
+ events.endpoint_ready("chat", "ep1")
+ events.close()
+ assert events.endpoints == {"chat": "ep1"}
+
+ def test_progress_updates_ignored_outside_phase(self):
+ events = ui.DeployEvents()
+ events.vendor_progress(1, "torch")
+ events.upload_progress(1, 2)
+ events.close()
+
+
+class TestDevEvents:
+ def test_request_lifecycle(self, capture_console):
+ events = ui.DevEvents()
+ events.dispatch("chat", "queued")
+ events.worker_ready("chat", "worker123456789")
+ events.worker_log("chat", "processing [1/3]")
+ events.worker_status("chat", {"initializing": 1})
+ events.task_status("chat", "pod starting")
+ events.request_completed("chat", 2.5)
+ events.request_failed("chat", 1.0)
+ out = _out(capture_console)
+ assert "→ chat()" in out
+ assert "worker worker123456 ready" in out
+ assert "processing [1/3]" in out # markup escaped, shown verbatim
+ assert "waiting: 1 initializing" in out
+ assert "pod starting" in out
+ assert "✓ chat()" in out
+ assert "✗ chat()" in out
+
+ def test_provision_rows_without_progress(self, capture_console):
+ events = ui.DevEvents()
+ events.provisioning("chat", "queue", "4090")
+ events.adopted("chat", "ep1")
+ events.ready("chat", "ep1")
+ events.deleted("chat")
+ out = _out(capture_console)
+ assert "provisioning queue on 4090" in out
+ assert "− chat" in out
+
+ def test_session_progress_lifecycle(self, capture_console):
+ events = ui.DevEvents()
+ events.session_starting()
+ events.provisioning("chat", "queue", "4090")
+ events.ready("chat", "ep1")
+ events.session_started()
+ assert events._progress is None
+
+ def test_refresh_diff(self, capture_console):
+ events = ui.DevEvents()
+ events.resource_added("new", "queue", "4090")
+ events.resource_changed("chat", ["gpu"])
+ events.resource_changed("chat", [])
+ events.resource_removed("old")
+ events.volume_created("data", 10, "US-KS-2")
+ out = _out(capture_console)
+ assert "+ new" in out
+ assert "~ chat" in out
+ assert "− old" in out
+ assert "volume data" in out
+
+
+class TestCleanupEvents:
+ def test_zero_total_is_silent(self, capture_console):
+ events = ui.CleanupEvents()
+ events.cleanup_started(0)
+ events.deleting("x")
+ events.deleted("x")
+ events.close()
+
+ def test_delete_progress(self, capture_console):
+ events = ui.CleanupEvents()
+ events.cleanup_started(2)
+ events.deleting("ep1")
+ events.deleted("ep1")
+ events.delete_failed("ep2")
+ events.close()
+ assert "could not delete ep2" in _out(capture_console)
+
+
+class TestTimer:
+ def test_elapsed(self):
+ with ui.Timer() as t:
+ assert t.so_far >= 0
+ assert t.elapsed >= 0
diff --git a/tests/test_cli/test_deploy_command.py b/tests/test_cli/test_deploy_command.py
new file mode 100644
index 00000000..8a019656
--- /dev/null
+++ b/tests/test_cli/test_deploy_command.py
@@ -0,0 +1,223 @@
+"""tests for rp deploy and rp dev command wiring."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from click.testing import CliRunner
+
+from runpod.apps.deploy import DeployResult
+from runpod.rp_cli.main import cli
+
+
+@pytest.fixture(autouse=True)
+def clean_entrypoints():
+ from runpod.apps.entrypoint import _clear_entrypoints
+
+ _clear_entrypoints()
+ yield
+ _clear_entrypoints()
+
+APP_SOURCE = '''
+import runpod
+
+app = runpod.App("demo")
+
+
+@app.queue(name="chat", gpu="4090")
+def chat(prompt: str):
+ return prompt
+'''
+
+TWO_APP_SOURCE = APP_SOURCE + '''
+
+other = runpod.App("other")
+
+
+@other.queue(name="embed", gpu="4090")
+def embed(text: str):
+ return text
+'''
+
+ENTRYPOINT_SOURCE = APP_SOURCE + '''
+
+@runpod.local_entrypoint
+def main():
+ pass
+'''
+
+
+def _runner():
+ return CliRunner()
+
+
+def _result(name="demo", endpoints=None):
+ return DeployResult(
+ app_name=name,
+ build_id="b1",
+ environment_id="env1",
+ resources=["chat"],
+ endpoints=endpoints or {"chat": "ep1"},
+ )
+
+
+class TestDeploy:
+ def test_deploys_discovered_app(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ deploy_app = AsyncMock(return_value=_result())
+ with patch("runpod.apps.deploy.deploy_app", deploy_app):
+ result = _runner().invoke(cli, ["deploy"])
+ assert result.exit_code == 0, result.output
+ assert "demo/default" in result.output
+ assert "ep1" in result.output
+ deploy_app.assert_awaited_once()
+
+ def test_env_flag(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ deploy_app = AsyncMock(return_value=_result())
+ with patch("runpod.apps.deploy.deploy_app", deploy_app):
+ result = _runner().invoke(cli, ["deploy", "--env", "prod"])
+ assert result.exit_code == 0
+ assert deploy_app.call_args[1]["env_name"] == "prod"
+ assert "demo/prod" in result.output
+
+ def test_multi_app_plan(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(TWO_APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ deploy_app = AsyncMock(
+ side_effect=[_result("demo"), _result("other", {"embed": "ep2"})]
+ )
+ with patch("runpod.apps.deploy.deploy_app", deploy_app):
+ result = _runner().invoke(cli, ["deploy"])
+ assert result.exit_code == 0, result.output
+ assert "2 apps" in result.output
+ assert deploy_app.await_count == 2
+
+ def test_missing_target(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ result = _runner().invoke(cli, ["deploy", "missing_dir"])
+ assert result.exit_code == 1
+ assert "does not exist" in result.output
+
+ def test_no_apps_found(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text("x = 1\n")
+ monkeypatch.chdir(tmp_path)
+ result = _runner().invoke(cli, ["deploy"])
+ assert result.exit_code == 1
+ assert "no runpod.App found" in result.output
+
+ def test_engine_error_is_clean(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ deploy_app = AsyncMock(side_effect=RuntimeError("upload failed"))
+ with patch("runpod.apps.deploy.deploy_app", deploy_app):
+ result = _runner().invoke(cli, ["deploy"])
+ assert result.exit_code == 1
+ assert "upload failed" in result.output
+ assert "Traceback" not in result.output
+
+ def test_build_only(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ artifact = tmp_path / "demo-artifact.tar.gz"
+ artifact.write_bytes(b"x" * 100)
+ build = MagicMock(return_value=artifact)
+ with patch("runpod.apps.deploy.build_artifact", build):
+ result = _runner().invoke(cli, ["deploy", "--build-only"])
+ assert result.exit_code == 0, result.output
+ assert "demo-artifact.tar.gz" in result.output
+ assert build.call_args[1]["output"] == artifact
+
+ def test_exclude_passthrough(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ deploy_app = AsyncMock(return_value=_result())
+ with patch("runpod.apps.deploy.deploy_app", deploy_app):
+ result = _runner().invoke(
+ cli, ["deploy", "--exclude", "torch,numpy"]
+ )
+ assert result.exit_code == 0
+ assert deploy_app.call_args[1]["exclude"] == ["torch", "numpy"]
+
+ def test_python_version_fallback_warns(self, tmp_path, monkeypatch):
+ (tmp_path / "main.py").write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ deploy_app = AsyncMock(return_value=_result())
+ with (
+ patch("runpod.apps.deploy.deploy_app", deploy_app),
+ patch(
+ "runpod.apps.images.local_python_version",
+ side_effect=RuntimeError("3.9 unsupported"),
+ ),
+ ):
+ result = _runner().invoke(cli, ["deploy"])
+ assert result.exit_code == 0
+ assert "no runtime image" in result.output
+
+
+class TestDev:
+ def test_once_runs_entrypoint_and_stops(self, tmp_path, monkeypatch):
+ module = tmp_path / "main.py"
+ module.write_text(ENTRYPOINT_SOURCE)
+ monkeypatch.chdir(tmp_path)
+
+ session = MagicMock()
+ session.start = AsyncMock()
+ session.stop = AsyncMock()
+ session.refresh = AsyncMock()
+ session._endpoints = {"dev-demo-chat": "ep1"}
+
+ def make_session(apps, events=None):
+ session.apps = apps
+ return session
+
+ with patch("runpod.apps.dev.DevSession", side_effect=make_session):
+ result = _runner().invoke(cli, ["dev", str(module), "--once"])
+ assert result.exit_code == 0, result.output
+ session.start.assert_awaited_once()
+ session.stop.assert_awaited_once()
+
+ def test_once_entrypoint_failure_exits_nonzero(self, tmp_path, monkeypatch):
+ module = tmp_path / "main.py"
+ module.write_text(
+ APP_SOURCE
+ + "\n@runpod.local_entrypoint\ndef main():\n"
+ + " raise RuntimeError('assertion blew up')\n"
+ )
+ monkeypatch.chdir(tmp_path)
+
+ session = MagicMock()
+ session.start = AsyncMock()
+ session.stop = AsyncMock()
+ session._endpoints = {}
+
+ def make_session(apps, events=None):
+ session.apps = apps
+ return session
+
+ with patch("runpod.apps.dev.DevSession", side_effect=make_session):
+ result = _runner().invoke(cli, ["dev", str(module), "--once"])
+ assert result.exit_code == 1
+ session.stop.assert_awaited_once()
+
+ def test_module_without_entrypoint(self, tmp_path, monkeypatch):
+ module = tmp_path / "main.py"
+ module.write_text(APP_SOURCE)
+ monkeypatch.chdir(tmp_path)
+ result = _runner().invoke(cli, ["dev", str(module), "--once"])
+ assert result.exit_code == 1
+ assert "local_entrypoint" in result.output
+
+ def test_module_without_app(self, tmp_path, monkeypatch):
+ module = tmp_path / "main.py"
+ module.write_text("x = 1\n")
+ monkeypatch.chdir(tmp_path)
+ result = _runner().invoke(cli, ["dev", str(module), "--once"])
+ assert result.exit_code == 1
+ assert "no runpod.App" in result.output
+
+ def test_missing_module(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ result = _runner().invoke(cli, ["dev", "missing.py", "--once"])
+ assert result.exit_code == 2
diff --git a/tests/test_cli/test_init_command.py b/tests/test_cli/test_init_command.py
new file mode 100644
index 00000000..48d969ea
--- /dev/null
+++ b/tests/test_cli/test_init_command.py
@@ -0,0 +1,82 @@
+"""rp init: project scaffolding."""
+
+from pathlib import Path
+
+from click.testing import CliRunner
+
+from runpod.apps.init import create_project, detect_conflicts
+from runpod.rp_cli.main import cli
+
+
+class TestCreateProject:
+ def test_writes_skeleton(self, tmp_path):
+ written = create_project(tmp_path, "my-app")
+ names = {p.name for p in written}
+ assert names == {"main.py", "requirements.txt", ".runpodignore"}
+ main = (tmp_path / "main.py").read_text()
+ assert 'App("my-app")' in main
+ assert "@runpod.local_entrypoint" in main
+
+ def test_skeleton_module_is_valid_python(self, tmp_path):
+ create_project(tmp_path, "my-app")
+ compile((tmp_path / "main.py").read_text(), "main.py", "exec")
+
+ def test_existing_files_kept_without_overwrite(self, tmp_path):
+ (tmp_path / "main.py").write_text("original")
+ written = create_project(tmp_path, "my-app")
+ assert (tmp_path / "main.py").read_text() == "original"
+ assert Path(tmp_path / "requirements.txt") not in written or True
+ assert (tmp_path / "requirements.txt").exists()
+
+ def test_overwrite_replaces_files(self, tmp_path):
+ (tmp_path / "main.py").write_text("original")
+ create_project(tmp_path, "my-app", overwrite=True)
+ assert 'App("my-app")' in (tmp_path / "main.py").read_text()
+
+ def test_creates_directory(self, tmp_path):
+ target = tmp_path / "new-project"
+ create_project(target, "new-project")
+ assert (target / "main.py").exists()
+
+
+class TestDetectConflicts:
+ def test_empty_dir_no_conflicts(self, tmp_path):
+ assert detect_conflicts(tmp_path) == []
+
+ def test_existing_files_reported(self, tmp_path):
+ (tmp_path / "main.py").write_text("x")
+ assert detect_conflicts(tmp_path) == ["main.py"]
+
+
+class TestInitCommand:
+ def test_init_new_project(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ runner = CliRunner()
+ result = runner.invoke(cli, ["init", "demo"])
+ assert result.exit_code == 0, result.output
+ assert (tmp_path / "demo" / "main.py").exists()
+
+ def test_init_current_directory(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ runner = CliRunner()
+ result = runner.invoke(cli, ["init", "."])
+ assert result.exit_code == 0, result.output
+ assert (tmp_path / "main.py").exists()
+ assert tmp_path.name in (tmp_path / "main.py").read_text()
+
+ def test_init_conflicts_fail_without_force(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "main.py").write_text("keep me")
+ runner = CliRunner()
+ result = runner.invoke(cli, ["init", "."])
+ assert result.exit_code != 0
+ assert "main.py" in result.output
+ assert (tmp_path / "main.py").read_text() == "keep me"
+
+ def test_init_force_overwrites(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "main.py").write_text("old")
+ runner = CliRunner()
+ result = runner.invoke(cli, ["init", ".", "--force"])
+ assert result.exit_code == 0, result.output
+ assert "App(" in (tmp_path / "main.py").read_text()
diff --git a/tests/test_cli/test_management_commands.py b/tests/test_cli/test_management_commands.py
new file mode 100644
index 00000000..131aceff
--- /dev/null
+++ b/tests/test_cli/test_management_commands.py
@@ -0,0 +1,357 @@
+"""tests for app/env/secret/registry/logs cli commands."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from click.testing import CliRunner
+
+from runpod.apps.manage import UndeployResult
+from runpod.rp_cli.main import cli
+
+
+def _runner():
+ return CliRunner()
+
+
+class TestAppCommands:
+ def test_list_empty(self):
+ with patch("runpod.apps.manage.list_apps", AsyncMock(return_value=[])):
+ result = _runner().invoke(cli, ["app", "list"])
+ assert result.exit_code == 0
+ assert "no apps deployed" in result.output
+
+ def test_list(self):
+ apps = [
+ {
+ "name": "demo",
+ "flashEnvironments": [{"name": "default"}, {"name": "prod"}],
+ },
+ {"name": "other", "flashEnvironments": []},
+ ]
+ with patch(
+ "runpod.apps.manage.list_apps", AsyncMock(return_value=apps)
+ ):
+ result = _runner().invoke(cli, ["app", "list"])
+ assert result.exit_code == 0
+ assert "demo" in result.output
+ assert "default, prod" in result.output
+
+ def test_get(self):
+ entry = {
+ "name": "demo",
+ "flashEnvironments": [
+ {"name": "default", "activeBuildId": "build123456789"}
+ ],
+ }
+ with patch(
+ "runpod.apps.manage.get_app", AsyncMock(return_value=entry)
+ ):
+ result = _runner().invoke(cli, ["app", "get", "demo"])
+ assert result.exit_code == 0
+ assert "build1234567" in result.output
+
+ def test_get_error(self):
+ with patch(
+ "runpod.apps.manage.get_app",
+ AsyncMock(side_effect=RuntimeError("no app named 'demo'")),
+ ):
+ result = _runner().invoke(cli, ["app", "get", "demo"])
+ assert result.exit_code == 1
+ assert "no app named" in result.output
+
+ def test_delete_confirmed(self):
+ outcome = UndeployResult(endpoints_deleted=2, failures=[])
+ with patch(
+ "runpod.apps.manage.delete_app", AsyncMock(return_value=outcome)
+ ):
+ result = _runner().invoke(cli, ["app", "delete", "demo", "--yes"])
+ assert result.exit_code == 0
+
+ def test_delete_aborts_without_confirmation(self):
+ result = _runner().invoke(cli, ["app", "delete", "demo"], input="n\n")
+ assert result.exit_code == 1
+
+ def test_delete_failures_keep_app(self):
+ outcome = UndeployResult(endpoints_deleted=0, failures=["ep1 stuck"])
+ with patch(
+ "runpod.apps.manage.delete_app", AsyncMock(return_value=outcome)
+ ):
+ result = _runner().invoke(cli, ["app", "delete", "demo", "--yes"])
+ assert result.exit_code == 1
+ assert "undeploy incomplete" in result.output
+
+
+class TestEnvCommands:
+ def test_list(self):
+ entry = {
+ "flashEnvironments": [
+ {
+ "name": "default",
+ "activeBuildId": "build123456789",
+ "createdAt": "2026-01-02T03:04:05Z",
+ }
+ ]
+ }
+ with patch(
+ "runpod.apps.manage.get_app", AsyncMock(return_value=entry)
+ ):
+ result = _runner().invoke(
+ cli, ["env", "list", "--app", "demo"]
+ )
+ assert result.exit_code == 0
+ assert "default" in result.output
+
+ def test_list_empty(self):
+ with patch(
+ "runpod.apps.manage.get_app",
+ AsyncMock(return_value={"flashEnvironments": []}),
+ ):
+ result = _runner().invoke(cli, ["env", "list", "--app", "demo"])
+ assert result.exit_code == 0
+ assert "no environments" in result.output
+
+ def test_get(self):
+ entry = {
+ "name": "prod",
+ "activeBuildId": "build123456789",
+ "endpoints": [{"name": "chat", "id": "ep1"}],
+ }
+ with patch(
+ "runpod.apps.manage.get_environment",
+ AsyncMock(return_value=entry),
+ ):
+ result = _runner().invoke(
+ cli, ["env", "get", "prod", "--app", "demo"]
+ )
+ assert result.exit_code == 0
+ assert "demo/prod" in result.output
+ assert "chat" in result.output
+
+ def test_add(self):
+ client = MagicMock()
+ client.create_environment = AsyncMock(return_value={"id": "env1"})
+ with (
+ patch("runpod.apps.api.AppsApiClient", return_value=client),
+ patch(
+ "runpod.apps.manage.get_app",
+ AsyncMock(return_value={"id": "app1"}),
+ ),
+ ):
+ result = _runner().invoke(
+ cli, ["env", "add", "staging", "--app", "demo"]
+ )
+ assert result.exit_code == 0
+ client.create_environment.assert_awaited_once_with("app1", "staging")
+
+ def test_delete(self):
+ outcome = UndeployResult(endpoints_deleted=1, failures=[])
+ with patch(
+ "runpod.apps.manage.undeploy_environment",
+ AsyncMock(return_value=outcome),
+ ):
+ result = _runner().invoke(
+ cli, ["env", "delete", "prod", "--app", "demo", "--yes"]
+ )
+ assert result.exit_code == 0
+
+ def test_resolve_app_name_fails_without_unique_app(self, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ result = _runner().invoke(cli, ["env", "list"])
+ assert result.exit_code == 1
+ assert "--app" in result.output
+
+
+class TestUndeploy:
+ def test_undeploy(self):
+ outcome = UndeployResult(endpoints_deleted=3, failures=[])
+ with patch(
+ "runpod.apps.manage.undeploy_environment",
+ AsyncMock(return_value=outcome),
+ ):
+ result = _runner().invoke(
+ cli, ["undeploy", "--app", "demo", "--yes"]
+ )
+ assert result.exit_code == 0
+ assert "3 endpoints removed" in result.output
+
+ def test_undeploy_failures(self):
+ outcome = UndeployResult(endpoints_deleted=0, failures=["boom"])
+ with patch(
+ "runpod.apps.manage.undeploy_environment",
+ AsyncMock(return_value=outcome),
+ ):
+ result = _runner().invoke(
+ cli, ["undeploy", "--app", "demo", "--yes"]
+ )
+ assert result.exit_code == 1
+
+
+class TestSecretCommands:
+ def _client(self):
+ client = MagicMock()
+ client.create_secret = AsyncMock(return_value={"id": "s1"})
+ client.list_secrets = AsyncMock(
+ return_value=[
+ {"id": "s1", "name": "tok", "description": "a token"}
+ ]
+ )
+ client.delete_secret = AsyncMock(return_value=True)
+ return client
+
+ def test_add(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli, ["secret", "add", "tok", "--value", "v"]
+ )
+ assert result.exit_code == 0
+ client.create_secret.assert_awaited_once_with("tok", "v", "")
+
+ def test_add_prompts(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli, ["secret", "add"], input="tok\nv\n"
+ )
+ assert result.exit_code == 0
+
+ def test_list(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(cli, ["secret", "list"])
+ assert result.exit_code == 0
+ assert "tok" in result.output
+
+ def test_list_empty(self):
+ client = self._client()
+ client.list_secrets = AsyncMock(return_value=[])
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(cli, ["secret", "list"])
+ assert "no secrets" in result.output
+
+ def test_delete(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli, ["secret", "delete", "tok", "--yes"]
+ )
+ assert result.exit_code == 0
+ client.delete_secret.assert_awaited_once_with("s1")
+
+ def test_delete_unknown(self):
+ client = self._client()
+ client.list_secrets = AsyncMock(return_value=[])
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli, ["secret", "delete", "tok", "--yes"]
+ )
+ assert result.exit_code == 1
+ assert "no secret named" in result.output
+
+
+class TestRegistryCommands:
+ def _client(self):
+ client = MagicMock()
+ client.create_registry_auth = AsyncMock(return_value={"id": "r1"})
+ client.list_registry_auths = AsyncMock(
+ return_value=[{"id": "r1", "name": "dockerhub"}]
+ )
+ client.delete_registry_auth = AsyncMock(return_value=True)
+ return client
+
+ def test_add(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli,
+ [
+ "registry", "add", "dockerhub",
+ "--username", "u", "--password", "p",
+ ],
+ )
+ assert result.exit_code == 0
+ client.create_registry_auth.assert_awaited_once_with(
+ "dockerhub", "u", "p"
+ )
+
+ def test_list(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(cli, ["registry", "list"])
+ assert result.exit_code == 0
+ assert "dockerhub" in result.output
+
+ def test_delete(self):
+ client = self._client()
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli, ["registry", "delete", "dockerhub", "--yes"]
+ )
+ assert result.exit_code == 0
+ client.delete_registry_auth.assert_awaited_once_with("r1")
+
+ def test_delete_unknown(self):
+ client = self._client()
+ client.list_registry_auths = AsyncMock(return_value=[])
+ with patch("runpod.apps.api.AppsApiClient", return_value=client):
+ result = _runner().invoke(
+ cli, ["registry", "delete", "nope", "--yes"]
+ )
+ assert result.exit_code == 1
+
+
+class TestLogsCommand:
+ def test_snapshot(self):
+ logs = {"system": ["booted"], "container": ["hello"]}
+ with patch(
+ "runpod.apps.logs.pod_logs", AsyncMock(return_value=logs)
+ ):
+ result = _runner().invoke(cli, ["logs", "pod1"])
+ assert result.exit_code == 0
+ assert "[system] booted" in result.output
+ assert "[container] hello" in result.output
+
+ def test_follow(self):
+ async def fake_stream(pod_id, **kwargs):
+ yield {"source": "container", "line": "streamed"}
+
+ with patch("runpod.apps.logs.stream_pod_logs", fake_stream):
+ result = _runner().invoke(cli, ["logs", "pod1", "--follow"])
+ assert result.exit_code == 0
+ assert "[container] streamed" in result.output
+
+
+class TestLoginCommand:
+ def test_api_key_flag(self):
+ with patch(
+ "runpod.cli.groups.config.functions.set_credentials"
+ ) as creds:
+ result = _runner().invoke(
+ cli, ["login", "--api-key", "sk-test"]
+ )
+ assert result.exit_code == 0
+ creds.assert_called_once_with("sk-test", overwrite=True)
+
+ def test_browser_flow(self):
+ with (
+ patch(
+ "runpod.apps.auth.browser_login",
+ AsyncMock(return_value="sk-granted"),
+ ),
+ patch(
+ "runpod.cli.groups.config.functions.set_credentials"
+ ) as creds,
+ ):
+ result = _runner().invoke(cli, ["login", "--no-open"])
+ assert result.exit_code == 0
+ creds.assert_called_once_with("sk-granted", overwrite=True)
+
+ def test_browser_flow_error(self):
+ from runpod.apps.auth import LoginError
+
+ with patch(
+ "runpod.apps.auth.browser_login",
+ AsyncMock(side_effect=LoginError("expired")),
+ ):
+ result = _runner().invoke(cli, ["login", "--no-open"])
+ assert result.exit_code == 1
+ assert "expired" in result.output
diff --git a/tests/test_cli/test_update_command.py b/tests/test_cli/test_update_command.py
new file mode 100644
index 00000000..7c941e65
--- /dev/null
+++ b/tests/test_cli/test_update_command.py
@@ -0,0 +1,264 @@
+"""rp update and the passive update check."""
+
+import json
+import threading
+from unittest.mock import MagicMock, patch
+
+import pytest
+from click.testing import CliRunner
+
+from runpod.rp_cli import update as upd
+from runpod.rp_cli.main import cli
+
+
+class TestParseVersion:
+ def test_release(self):
+ assert upd.parse_version("1.8.0") == (1, 8, 0)
+
+ def test_dev_suffix_ignored(self):
+ assert upd.parse_version("1.8.0.dev3") == (1, 8, 0)
+
+ def test_two_part(self):
+ assert upd.parse_version("2.0") == (2, 0)
+
+
+class TestCompareVersions:
+ def test_orders(self):
+ assert upd.compare_versions((1, 0), (2, 0)) < 0
+ assert upd.compare_versions((2, 1), (2, 0)) > 0
+
+ def test_pads_shorter(self):
+ assert upd.compare_versions((2, 0), (2, 0, 0)) == 0
+
+
+class TestInstallCommand:
+ def test_prefers_uv(self):
+ with patch.object(upd.shutil, "which", return_value="/usr/bin/uv"):
+ cmd = upd.install_command("1.2.3")
+ assert cmd[0] == "uv"
+ assert "runpod==1.2.3" in cmd
+
+ def test_falls_back_to_pip(self):
+ with patch.object(upd.shutil, "which", return_value=None):
+ cmd = upd.install_command("1.2.3")
+ assert cmd[1:4] == ["-m", "pip", "install"]
+
+
+class TestUpdateCommand:
+ def test_already_current(self):
+ runner = CliRunner()
+ with patch.object(
+ upd, "fetch_pypi_metadata", return_value=("9.9.9", {"9.9.9"})
+ ), patch.object(upd, "current_version", return_value="9.9.9"), patch(
+ "runpod.rp_cli.main.cli.callback"
+ ):
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0, result.output
+ assert "nothing to do" in result.output
+
+ def test_unknown_version_fails(self):
+ runner = CliRunner()
+ with patch.object(
+ upd, "fetch_pypi_metadata", return_value=("9.9.9", {"9.9.9"})
+ ):
+ result = runner.invoke(cli, ["update", "--version", "0.0.0.404"])
+ assert result.exit_code != 0
+ assert "not found" in result.output
+
+ def test_installs_target(self):
+ runner = CliRunner()
+ with patch.object(
+ upd, "fetch_pypi_metadata", return_value=("9.9.9", {"9.9.9"})
+ ), patch.object(upd, "current_version", return_value="1.0.0"), patch.object(
+ upd, "run_install"
+ ) as install:
+ result = runner.invoke(cli, ["update"])
+ assert result.exit_code == 0, result.output
+ install.assert_called_once_with("9.9.9")
+
+
+class TestBackgroundCheckCache:
+ def test_fresh_cache_skips_fetch(self, tmp_path, monkeypatch):
+ cache = tmp_path / "update_check.json"
+ monkeypatch.setattr(upd, "CACHE_PATH", cache)
+ from datetime import datetime, timezone
+
+ cache.write_text(
+ json.dumps(
+ {
+ "last_checked_utc": datetime.now(timezone.utc).isoformat(),
+ "latest_version": "99.0.0",
+ }
+ )
+ )
+ monkeypatch.setattr(upd, "_newer_version", None)
+ with patch.object(upd, "fetch_pypi_metadata") as fetch, patch.object(
+ upd, "current_version", return_value="1.0.0"
+ ):
+ upd._run_check()
+ fetch.assert_not_called()
+ assert upd._newer_version == "99.0.0"
+
+ def test_stale_cache_refetches(self, tmp_path, monkeypatch):
+ cache = tmp_path / "update_check.json"
+ monkeypatch.setattr(upd, "CACHE_PATH", cache)
+ cache.write_text(
+ json.dumps(
+ {
+ "last_checked_utc": "2000-01-01T00:00:00+00:00",
+ "latest_version": "0.1.0",
+ }
+ )
+ )
+ monkeypatch.setattr(upd, "_newer_version", None)
+ with patch.object(
+ upd, "fetch_pypi_metadata", return_value=("99.0.0", {"99.0.0"})
+ ), patch.object(upd, "current_version", return_value="1.0.0"):
+ upd._run_check()
+ assert upd._newer_version == "99.0.0"
+ assert json.loads(cache.read_text())["latest_version"] == "99.0.0"
+
+ def test_current_version_newer_no_notice(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(upd, "CACHE_PATH", tmp_path / "update_check.json")
+ monkeypatch.setattr(upd, "_newer_version", None)
+ with patch.object(
+ upd, "fetch_pypi_metadata", return_value=("1.0.0", {"1.0.0"})
+ ), patch.object(upd, "current_version", return_value="2.0.0"):
+ upd._run_check()
+ assert upd._newer_version is None
+
+ def test_check_never_raises(self, tmp_path, monkeypatch):
+ monkeypatch.setattr(upd, "CACHE_PATH", tmp_path / "update_check.json")
+ with patch.object(
+ upd, "fetch_pypi_metadata", side_effect=RuntimeError("boom")
+ ), patch.object(upd, "current_version", return_value="1.0.0"):
+ upd._run_check()
+
+
+class TestFetchPypiMetadata:
+ def _response(self, payload: bytes):
+ import contextlib
+ import io
+
+ @contextlib.contextmanager
+ def _cm(url, timeout=None):
+ yield io.BytesIO(payload)
+
+ return _cm
+
+ def test_parses_metadata(self, monkeypatch):
+ import json as _json
+
+ payload = _json.dumps(
+ {"info": {"version": "1.9.0"}, "releases": {"1.8.0": [], "1.9.0": []}}
+ ).encode()
+ monkeypatch.setattr(
+ upd.urllib.request, "urlopen", self._response(payload)
+ )
+ latest, releases = upd.fetch_pypi_metadata()
+ assert latest == "1.9.0"
+ assert releases == {"1.8.0", "1.9.0"}
+
+ def test_network_error(self, monkeypatch):
+ import urllib.error
+
+ def _raise(url, timeout=None):
+ raise urllib.error.URLError("no route")
+
+ monkeypatch.setattr(upd.urllib.request, "urlopen", _raise)
+ with pytest.raises(ConnectionError, match="could not reach pypi"):
+ upd.fetch_pypi_metadata()
+
+ def test_http_error(self, monkeypatch):
+ import urllib.error
+
+ def _raise(url, timeout=None):
+ raise urllib.error.HTTPError(url, 503, "unavailable", {}, None)
+
+ monkeypatch.setattr(upd.urllib.request, "urlopen", _raise)
+ with pytest.raises(RuntimeError, match="HTTP 503"):
+ upd.fetch_pypi_metadata()
+
+ def test_garbage_response(self, monkeypatch):
+ monkeypatch.setattr(
+ upd.urllib.request, "urlopen", self._response(b"not json")
+ )
+ with pytest.raises(RuntimeError, match="unexpected response"):
+ upd.fetch_pypi_metadata()
+
+ def test_missing_version_key(self, monkeypatch):
+ monkeypatch.setattr(
+ upd.urllib.request, "urlopen", self._response(b"{}")
+ )
+ with pytest.raises(RuntimeError, match="missing version"):
+ upd.fetch_pypi_metadata()
+
+
+class TestRunInstall:
+ def test_success(self, monkeypatch):
+ from unittest.mock import MagicMock
+
+ result = MagicMock(returncode=0)
+ run = MagicMock(return_value=result)
+ monkeypatch.setattr(upd.subprocess, "run", run)
+ upd.run_install("1.9.0")
+ assert "runpod==1.9.0" in run.call_args[0][0]
+
+ def test_failure_raises(self, monkeypatch):
+ from unittest.mock import MagicMock
+
+ result = MagicMock(returncode=1, stderr="resolution failed")
+ monkeypatch.setattr(
+ upd.subprocess, "run", MagicMock(return_value=result)
+ )
+ with pytest.raises(RuntimeError, match="resolution failed"):
+ upd.run_install("1.9.0")
+
+
+class TestUpdateNotice:
+ def test_notice_printed_when_newer(self, monkeypatch, capsys):
+ monkeypatch.setattr(upd, "_newer_version", "9.9.9")
+ upd._check_done.set()
+ upd._print_update_notice()
+ err = capsys.readouterr().err
+ assert "9.9.9" in err
+ assert "rp update" in err
+
+ def test_no_notice_when_current(self, monkeypatch, capsys):
+ monkeypatch.setattr(upd, "_newer_version", None)
+ upd._check_done.set()
+ upd._print_update_notice()
+ assert capsys.readouterr().err == ""
+
+ def test_no_notice_when_check_incomplete(self, monkeypatch, capsys):
+ monkeypatch.setattr(upd, "_newer_version", "9.9.9")
+ monkeypatch.setattr(upd, "_check_done", threading.Event())
+ upd._print_update_notice()
+ assert capsys.readouterr().err == ""
+
+
+class TestStartBackgroundCheck:
+ def test_starts_once(self, monkeypatch):
+ monkeypatch.setattr(upd, "_started", False)
+ monkeypatch.setattr(upd, "_is_interactive", lambda: True)
+ threads = []
+ monkeypatch.setattr(
+ upd.threading,
+ "Thread",
+ lambda **kw: threads.append(kw) or MagicMock(),
+ )
+ upd.start_background_check()
+ upd.start_background_check()
+ assert len(threads) == 1
+
+ def test_skips_non_tty(self, monkeypatch):
+ monkeypatch.setattr(upd, "_started", False)
+ monkeypatch.setattr(upd, "_is_interactive", lambda: False)
+ threads = []
+ monkeypatch.setattr(
+ upd.threading,
+ "Thread",
+ lambda **kw: threads.append(kw) or MagicMock(),
+ )
+ upd.start_background_check()
+ assert threads == []
diff --git a/tests/test_init.py b/tests/test_init.py
index 91242328..52339fce 100644
--- a/tests/test_init.py
+++ b/tests/test_init.py
@@ -98,6 +98,10 @@ def test_all_covers_expected_public_api(self):
'check_credentials', 'get_credentials', 'set_credentials',
# Endpoint classes
'AsyncioEndpoint', 'AsyncioJob', 'Endpoint',
+ # Apps surface
+ 'Api', 'App', 'CpuInstanceType', 'DataCenter', 'EndpointNotFound', 'GpuGroup',
+ 'GpuType', 'Job', 'Model', 'Queue', 'Secret', 'Volume', 'delete', 'get',
+ 'init', 'is_local', 'local_entrypoint', 'patch', 'post', 'put', 'schedule',
# Serverless module
'serverless',
# Logger class
diff --git a/tests/test_shared/test_auth.py b/tests/test_shared/test_auth.py
index 00f85848..2cb93def 100644
--- a/tests/test_shared/test_auth.py
+++ b/tests/test_shared/test_auth.py
@@ -3,6 +3,7 @@
"""
import importlib
+import os
import unittest
from unittest.mock import mock_open, patch
@@ -24,6 +25,9 @@ def test_use_file_credentials(self, _mock_exists, mock_file):
"""
import runpod # pylint: disable=import-outside-toplevel
- importlib.reload(runpod)
+ # the env var outranks file credentials; clear it so the file wins
+ with patch.dict(os.environ):
+ os.environ.pop("RUNPOD_API_KEY", None)
+ importlib.reload(runpod)
self.assertEqual(runpod.api_key, "RUNPOD_API_KEY")
assert mock_file.called