Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### Breaking Changes

* Native `@app.api` routes validate paths and signatures during class decoration. Invalid declarations raise immediately during module import and prevent worker startup. GET handlers take only `self`. POST, PUT, PATCH, and DELETE handlers take `self` and at most one required positional JSON-object body. Its annotation can be omitted, `dict`, `typing.Dict`, `Any`, `dict[str, Any]`, or `typing.Dict[str, Any]`. Migrate routes that need path parameters, query parameters, or body schemas to a zero-argument `@app.api` factory returning a FastAPI app.
* Production endpoint environment variables reserve `RUNPOD_API_KEY` and runtime identity fields. Workers authenticate Flash calls with platform-issued runtime credentials; application credentials must use non-reserved environment names.
* Deployed workers can invoke Flash resources only within their own app and environment, including through `Queue`, `Api`, and `Task` stubs. Local clients use account credentials subject to their permissions.
* Remote queues, tasks, and JSON API calls require finite JSON-compatible arguments and results in local, dev, and deployed execution. Dev workers receive source separately from the JSON argument contract. Explicit `.local()` calls run Python directly.
Expand Down
28 changes: 27 additions & 1 deletion examples/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@ active tasks can run indefinitely, including detached work after client exit.
`remote()` and `job.wait()` impose no execution timeout by default. pod deletion
requires a working control-plane API and pod-scoped credentials.

## Native API routes

Native `@app.api` classes use literal route paths. GET handlers take only `self`.
POST, PUT, PATCH, and DELETE handlers may also take one required positional
argument for the complete JSON object:

```python
from typing import Any

@app.api()
class Service:
@post("/echo")
def echo(self, payload: dict[str, Any]):
return payload
```

The body annotation may be omitted, `dict`, `typing.Dict`, `Any`,
`dict[str, Any]`, or `typing.Dict[str, Any]`. Imported aliases work with eager,
postponed, and lazy annotations. These annotations describe an unvalidated JSON
object, not a field schema. Defaults, keyword-only arguments, variadics,
non-string dictionary keys, and typed body schemas are unsupported.

Invalid declarations fail during module import and prevent worker startup.
For path parameters, query parameters, or schema validation, use a
zero-argument `@app.api` factory returning a FastAPI app and declare routes
on that app.

## Storage

`NetworkVolume(name_or_id, size=50, datacenter=None, create=True)` references
Expand All @@ -56,4 +83,3 @@ binds declared references and resolved IDs before importing user code. Access
raises if the volume is unmounted or has multiple bindings. Evaluate `.path`
inside remote functions, not during local module discovery. Calling `.local()`
does not mount remote storage on the client machine.

9 changes: 9 additions & 0 deletions runpod/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ def web():
server = FastAPI()
...
return server

native routes use literal paths. GET methods take only self.
POST/PUT/PATCH/DELETE methods take self and optionally one required
positional argument containing the complete JSON object. its
annotation may be omitted, dict, typing.Dict, Any, dict[str, Any],
or typing.Dict[str, Any], including imported aliases. defaults,
keyword-only arguments, variadics, non-string dictionary keys,
and body schemas are unsupported.
use a fastapi factory for path/query binding and field validation.
"""

def decorator(target: Any) -> ApiHandle:
Expand Down
83 changes: 81 additions & 2 deletions runpod/apps/handles.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def load_model(): ... # worker startup hook

import asyncio
import inspect
import sys
from types import SimpleNamespace
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -29,6 +31,9 @@ def load_model(): ... # worker startup hook
List,
Optional,
Type,
get_args,
get_origin,
get_type_hints,
)

from .._serialization import normalize_json
Expand Down Expand Up @@ -313,6 +318,79 @@ async def aio(self, path: str, body: Any = None, **kwargs: Any) -> Any:
return await target.request(self._method, path, body, **kwargs)


def _validate_native_route(cls: Type, name: str, method: str, path: str) -> None:
member = inspect.getattr_static(cls, name)
guidance = (
f"native API route {method} {path} ({cls.__name__}.{name}): "
"use an instance method with no arguments after self, or one required "
"positional JSON-object argument for POST/PUT/PATCH/DELETE. "
"The body annotation must be omitted, dict, typing.Dict, Any, "
"dict[str, Any], or typing.Dict[str, Any]. "
"Use an @app.api FastAPI factory for typed path/query parameters "
"or body schemas."
)
if not inspect.isfunction(member):
raise InvalidResourceError(guidance)

member = inspect.unwrap(member)
annotate = getattr(member, "__annotate__", None)
if sys.version_info >= (3, 14) and annotate is not None:
from annotationlib import Format

signature = inspect.signature(member, annotation_format=Format.STRING)
else:
signature = inspect.signature(member)
parameters = list(signature.parameters.values())
positional = (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
if not parameters or parameters[0].kind not in positional:
raise InvalidResourceError(guidance)
parameters = parameters[1:]
if len(parameters) > (0 if method == "GET" else 1):
raise InvalidResourceError(guidance)
if not parameters:
return

body = parameters[0]
if body.kind not in positional or body.default is not inspect.Parameter.empty:
raise InvalidResourceError(guidance)
annotation = body.annotation
Comment thread
KAJdev marked this conversation as resolved.
Resolved
if isinstance(annotation, str):
localns = dict(vars(cls))
if inspect.isfunction(annotate):
for variable, cell in zip(
annotate.__code__.co_freevars, annotate.__closure__ or ()
):
try:
localns[variable] = cell.cell_contents
except ValueError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
continue
try:
annotation = get_type_hints(
SimpleNamespace(__annotations__={body.name: annotation}),
globalns=member.__globals__,
localns=localns,
include_extras=True,
)[body.name]
except (NameError, TypeError, SyntaxError, AttributeError):
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
annotation = None
valid_annotation = annotation in (inspect.Parameter.empty, dict, Dict, Any) or (
get_origin(annotation) is dict and get_args(annotation) == (str, Any)
)
if not valid_annotation:
annotation_label = (
body.annotation
if isinstance(body.annotation, str)
else inspect.formatannotation(body.annotation)
)
raise InvalidResourceError(
f"body argument {body.name!r} has unsupported annotation "
f"{annotation_label}. {guidance}"
)


class ApiHandle:
"""handle for @app.api classes and asgi factories.

Expand Down Expand Up @@ -353,10 +431,11 @@ def __init__(self, app: "App", target: Any, spec: ResourceSpec):
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)
for name, member in inspect.getmembers(cls):
route = route_of(member) or route_of(inspect.getattr_static(cls, name))
if route is not None:
method, path = route
_validate_native_route(cls, name, method, path)
Comment thread
KAJdev marked this conversation as resolved.
Resolved
if (method, path) in seen:
raise InvalidResourceError(
f"duplicate route {method} {path} on {cls.__name__}: "
Expand Down
5 changes: 5 additions & 0 deletions runpod/apps/markers.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ def validate_route(method: str, path: str) -> None:
raise ValueError(f"method must be one of {sorted(_VALID_METHODS)}")
if not isinstance(path, str) or not path.startswith("/"):
raise ValueError(f"path must start with '/', got: {path!r}")
if "{" in path or "}" in path:
raise ValueError(
f"native API route {method} {path} requires a literal path. "
"Use an @app.api FastAPI factory for path parameters."
)
if path in RESERVED_PATHS:
raise ValueError(
f"path {path!r} is reserved by the worker runtime "
Expand Down
Loading
Loading