From b255ee912d104a97ed163dd22770fe8e8d9bf986 Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:30:14 -0800 Subject: [PATCH 1/4] fix(apps): reject unsupported native API route parameters --- README.md | 6 + docs/apps/native-api.md | 84 +++++++++++ runpod/apps/app.py | 7 + runpod/apps/handles.py | 57 ++++++- runpod/apps/markers.py | 5 + tests/test_apps/test_native_api.py | 230 +++++++++++++++++++++++++++++ 6 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 docs/apps/native-api.md create mode 100644 tests/test_apps/test_native_api.py diff --git a/README.md b/README.md index 819b47422..d524cbdf7 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,12 @@ rp flash dev main.py # live dev session: edit, re-run, logs stream back rp flash deploy # deploy production endpoints ``` +## API endpoints + +`@app.api` native class routes accept no arguments or one complete JSON object. +Use a FastAPI factory for typed path/query parameters and body schemas. See the +[native API signature contract and factory example](docs/apps/native-api.md). + ## Contributing Pull requests and issues are welcome — see the [contributing guide](CONTRIBUTING.md) to get started. diff --git a/docs/apps/native-api.md b/docs/apps/native-api.md new file mode 100644 index 000000000..df3706338 --- /dev/null +++ b/docs/apps/native-api.md @@ -0,0 +1,84 @@ +# Native API routes + +`@app.api` accepts a class of native routes or a zero-argument factory returning +an ASGI application. Native routes receive either no input or a complete JSON +object. Use a FastAPI factory for typed path and query parameters, individual +body fields, or schema validation. + +```python +from runpod import App, get, post + +app = App("inference") + +@app.api(cpu="cpu3c-1-2") +class Inference: + @get("/health") + def health(self): + return {"ok": True} + + @post("/generate") + async def generate(self, body: dict): + return {"prompt": body.get("prompt")} +``` + +## Signature contract + +Native routes must be ordinary instance methods. Synchronous and asynchronous +methods are supported, including inherited methods. Static methods and class +methods are unsupported. + +| Marker | Parameters after the instance argument | +| --- | --- | +| `@get` | None | +| `@post`, `@put`, `@patch`, `@delete` | None, or one required positional argument | + +The single argument can have any name and receives the entire JSON object, not +a field with that name. Its annotation must be absent, `dict`, `typing.Dict`, or +`typing.Any` (including their string forms). These annotations do not declare a +body schema. Parameterized dictionaries such as `dict[str, int]`, Pydantic +models, `TypedDict`, scalar types, and `Annotated` metadata are unsupported. +Native handlers must validate their own fields. Return annotations do not +validate responses. + +Default values, keyword-only arguments, `*args`, and `**kwargs` are unsupported. +Paths must be literal, such as `/generate`, not templates such as `/items/{id}`. +Native methods do not bind query parameters. Unsupported declarations raise an +error while the application is imported, before the resource is registered. + +Routes with a body argument require a JSON object. Missing bodies, malformed +JSON, `null`, arrays, and scalar values receive HTTP 422. Object fields are +passed through without coercion or field validation. Methods without a body +argument do not consume the request body. These request semantics apply to both +live development and deployed workers with the matching API runtime release. + +## Typed input with FastAPI + +An ASGI factory gives FastAPI ownership of parameter binding and validation. +Declare `fastapi` as a resource dependency so it is available on the worker. + +```python +from runpod import App + +app = App("typed-api") + +@app.api(cpu="cpu3c-1-2", dependencies=["fastapi"]) +def web(): + from fastapi import Body, FastAPI + + server = FastAPI() + + @server.post("/items/{item_id}") + async def update( + item_id: int, + limit: int = 10, + prompt: str = Body(...), + count: int = Body(...), + ): + return {"id": item_id, "limit": limit, "prompt": prompt, "count": count} + + return server +``` + +`POST /items/7?limit=2` with `{"prompt": "hello", "count": 3}` binds all four +arguments. An invalid integer path or query value, or a missing or invalid body +field, receives HTTP 422 from FastAPI. diff --git a/runpod/apps/app.py b/runpod/apps/app.py index b64855559..b82d6d59f 100644 --- a/runpod/apps/app.py +++ b/runpod/apps/app.py @@ -226,6 +226,13 @@ 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, or Any. defaults, + keyword-only arguments, variadics, and body schemas are unsupported. + use a fastapi factory for path/query binding and field validation. """ def decorator(target: Any) -> ApiHandle: diff --git a/runpod/apps/handles.py b/runpod/apps/handles.py index e4e804f61..f1d2d1311 100644 --- a/runpod/apps/handles.py +++ b/runpod/apps/handles.py @@ -20,6 +20,7 @@ def load_model(): ... # worker startup hook import asyncio import inspect +import sys from typing import ( TYPE_CHECKING, Any, @@ -307,6 +308,57 @@ 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, or Any. " + "Use an @app.api FastAPI factory for typed path/query parameters " + "or body schemas." + ) + if not inspect.isfunction(member): + raise InvalidResourceError(guidance) + + if sys.version_info >= (3, 14): + from annotationlib import Format + + signature = inspect.signature(member, annotation_format=Format.FORWARDREF) + 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 + if isinstance(annotation, str): + annotation = annotation.strip("'\"") + valid_annotation = annotation in { + "dict", + "Dict", + "typing.Dict", + "Any", + "typing.Any", + } + else: + valid_annotation = annotation in (inspect.Parameter.empty, dict, Dict, Any) + if not valid_annotation: + raise InvalidResourceError(guidance) + + class ApiHandle: """handle for @app.api classes and asgi factories. @@ -347,10 +399,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) if (method, path) in seen: raise InvalidResourceError( f"duplicate route {method} {path} on {cls.__name__}: " diff --git a/runpod/apps/markers.py b/runpod/apps/markers.py index 84d1ad651..62be3e45a 100644 --- a/runpod/apps/markers.py +++ b/runpod/apps/markers.py @@ -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 " diff --git a/tests/test_apps/test_native_api.py b/tests/test_apps/test_native_api.py new file mode 100644 index 000000000..890de246e --- /dev/null +++ b/tests/test_apps/test_native_api.py @@ -0,0 +1,230 @@ +"""native API declaration contracts.""" + +import sys +import typing + +import pytest + +from runpod import App +from runpod.apps.app import _clear_registry +from runpod.apps.errors import InvalidResourceError +from runpod.apps.markers import delete, get, patch, post, put + + +@pytest.fixture(autouse=True) +def clean_registry(): + _clear_registry() + yield + _clear_registry() + + +@pytest.mark.parametrize("marker", [get, post, put, patch, delete]) +@pytest.mark.parametrize("path", ["/items/{item_id}", "/files/{path:path}"]) +def test_path_parameters_rejected_at_marker(marker, path): + with pytest.raises(ValueError, match="FastAPI factory"): + marker(path) + + +@pytest.mark.parametrize("marker", [get, post, put, patch, delete]) +@pytest.mark.parametrize("async_method", [False, True]) +def test_no_argument_routes(marker, async_method): + async def asynchronous(self): + return {"ok": True} + + def synchronous(self): + return {"ok": True} + + method = asynchronous if async_method else synchronous + cls = type("Service", (), {"route": marker("/ok")(method)}) + app = App("test") + handle = app.api()(cls) + assert handle.spec.routes[0].handler_name == "route" + assert app.resources["Service"] is handle + + +@pytest.mark.parametrize("marker", [post, put, patch, delete]) +@pytest.mark.parametrize( + "signature", + [ + "self, payload", + "self, payload: dict", + "self, payload: typing.Dict", + "self, payload: typing.Any", + "self, payload: 'dict'", + "self, payload: 'typing.Dict'", + "self, payload: 'typing.Any'", + "self, payload: 'Dict'", + "self, payload: 'Any'", + "self, payload: dict, /", + ], +) +@pytest.mark.parametrize("postponed", [False, True]) +def test_single_json_object_argument(marker, signature, postponed): + namespace = {"typing": typing} + source = "from __future__ import annotations\n" if postponed else "" + source += f"async def route({signature}) -> 'Result':\n return payload\n" + exec(source, namespace) + cls = type("Service", (), {"route": marker("/echo")(namespace["route"])}) + handle = App("test").api()(cls) + assert handle.spec.routes[0].handler_name == "route" + + +@pytest.mark.parametrize( + "signature", ["self, q", "self, q: int = 10", "self, body: dict"] +) +def test_get_arguments_rejected_before_registration(signature): + namespace = {} + exec(f"def route({signature}):\n pass\n", namespace) + cls = type("Service", (), {"route": get("/search")(namespace["route"])}) + app = App("test") + with pytest.raises(InvalidResourceError, match=r"GET /search \(Service.route\)"): + app.api()(cls) + assert app.resources == {} + + +@pytest.mark.parametrize("marker", [post, put, patch, delete]) +@pytest.mark.parametrize( + "signature", + [ + "", + "*, self", + "self, prompt, count", + "self, prompt: str, count: int = 1", + "self, body=None", + "self, body: dict = {}", + "self, *, body", + "self, *args", + "self, **kwargs", + "self, body, *args", + "self, body, **kwargs", + "self, value: int", + "self, value: str", + "self, value: list", + "self, value: dict[str, int]", + "self, value: typing.Dict[str, typing.Any]", + "self, value: typing.Optional[dict]", + "self, value: typing.Annotated[dict, 'body']", + "self, value: 'RequestModel'", + "self, value: 'typing.Dict[str, int]'", + ], +) +def test_unsupported_body_signatures_rejected_before_registration(marker, signature): + namespace = {"typing": typing} + exec(f"def route({signature}):\n pass\n", namespace) + cls = type("Service", (), {"route": marker("/generate")(namespace["route"])}) + app = App("test") + with pytest.raises(InvalidResourceError, match="FastAPI factory"): + app.api()(cls) + assert app.resources == {} + + +@pytest.mark.parametrize("descriptor", [staticmethod, classmethod]) +@pytest.mark.parametrize("marker_outermost", [False, True]) +def test_non_instance_methods_rejected(descriptor, marker_outermost): + def route(self): + return {"ok": True} + + member = ( + get("/ok")(descriptor(route)) + if marker_outermost + else descriptor(get("/ok")(route)) + ) + cls = type("Service", (), {"route": member}) + app = App("test") + with pytest.raises(InvalidResourceError, match="instance method"): + app.api()(cls) + assert app.resources == {} + + +def test_inherited_native_routes_are_validated(): + class Base: + @get("/ok") + def route(self): + return {"ok": True} + + class Service(Base): + pass + + app = App("test") + assert app.api()(Service).spec.routes[0].handler_name == "route" + + class InvalidBase: + @get("/search") + def search(self, q="default"): + return q + + class InvalidService(InvalidBase): + pass + + with pytest.raises(InvalidResourceError, match="InvalidService.search"): + app.api()(InvalidService) + assert "InvalidService" not in app.resources + + +def test_typed_body_classes_require_factory(): + from pydantic import BaseModel + + class RequestModel(BaseModel): + prompt: str + + class RequestDict(typing.TypedDict): + prompt: str + + for annotation in (RequestModel, RequestDict): + + def route(self, body): + return body + + route.__annotations__["body"] = annotation + cls = type("Service", (), {"route": post("/generate")(route)}) + with pytest.raises(InvalidResourceError, match="body schemas"): + App("test").api()(cls) + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason="requires deferred annotations") +def test_deferred_return_annotations_do_not_require_class_name_binding(): + app = App("test") + namespace = {"app": app, "get": get} + exec( + "@app.api()\n" + "class Service:\n" + " @get('/ok')\n" + " def route(self) -> Service:\n" + " return self\n", + namespace, + ) + assert namespace["Service"].spec.routes[0].handler_name == "route" + + +def test_factory_routes_are_not_native_declarations(): + from fastapi import Body, FastAPI + from fastapi.testclient import TestClient + + app = App("test") + + @app.api() + def web(): + server = FastAPI() + + @server.post("/items/{item_id}") + def update( + item_id: int, + limit: int = 10, + prompt: str = Body(...), + count: int = Body(...), + ): + return {"id": item_id, "limit": limit, "prompt": prompt, "count": count} + + return server + + with TestClient(web._asgi_factory()) as client: + response = client.post("/items/7?limit=2", json={"prompt": "hello", "count": 3}) + assert response.status_code == 200 + assert response.json() == {"id": 7, "limit": 2, "prompt": "hello", "count": 3} + for path, body in ( + ("/items/bad", {"prompt": "hello", "count": 3}), + ("/items/7?limit=bad", {"prompt": "hello", "count": 3}), + ("/items/7", {"prompt": "hello"}), + ("/items/7", {"prompt": "hello", "count": "bad"}), + ): + assert client.post(path, json=body).status_code == 422 From 1948b44ac4cdec73e0be00f909e6eddfca2a48b6 Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:46:14 -0800 Subject: [PATCH 2/4] chore: keep native API changes focused on implementation --- README.md | 6 --- docs/apps/native-api.md | 84 ----------------------------------------- 2 files changed, 90 deletions(-) delete mode 100644 docs/apps/native-api.md diff --git a/README.md b/README.md index d524cbdf7..819b47422 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,6 @@ rp flash dev main.py # live dev session: edit, re-run, logs stream back rp flash deploy # deploy production endpoints ``` -## API endpoints - -`@app.api` native class routes accept no arguments or one complete JSON object. -Use a FastAPI factory for typed path/query parameters and body schemas. See the -[native API signature contract and factory example](docs/apps/native-api.md). - ## Contributing Pull requests and issues are welcome — see the [contributing guide](CONTRIBUTING.md) to get started. diff --git a/docs/apps/native-api.md b/docs/apps/native-api.md deleted file mode 100644 index df3706338..000000000 --- a/docs/apps/native-api.md +++ /dev/null @@ -1,84 +0,0 @@ -# Native API routes - -`@app.api` accepts a class of native routes or a zero-argument factory returning -an ASGI application. Native routes receive either no input or a complete JSON -object. Use a FastAPI factory for typed path and query parameters, individual -body fields, or schema validation. - -```python -from runpod import App, get, post - -app = App("inference") - -@app.api(cpu="cpu3c-1-2") -class Inference: - @get("/health") - def health(self): - return {"ok": True} - - @post("/generate") - async def generate(self, body: dict): - return {"prompt": body.get("prompt")} -``` - -## Signature contract - -Native routes must be ordinary instance methods. Synchronous and asynchronous -methods are supported, including inherited methods. Static methods and class -methods are unsupported. - -| Marker | Parameters after the instance argument | -| --- | --- | -| `@get` | None | -| `@post`, `@put`, `@patch`, `@delete` | None, or one required positional argument | - -The single argument can have any name and receives the entire JSON object, not -a field with that name. Its annotation must be absent, `dict`, `typing.Dict`, or -`typing.Any` (including their string forms). These annotations do not declare a -body schema. Parameterized dictionaries such as `dict[str, int]`, Pydantic -models, `TypedDict`, scalar types, and `Annotated` metadata are unsupported. -Native handlers must validate their own fields. Return annotations do not -validate responses. - -Default values, keyword-only arguments, `*args`, and `**kwargs` are unsupported. -Paths must be literal, such as `/generate`, not templates such as `/items/{id}`. -Native methods do not bind query parameters. Unsupported declarations raise an -error while the application is imported, before the resource is registered. - -Routes with a body argument require a JSON object. Missing bodies, malformed -JSON, `null`, arrays, and scalar values receive HTTP 422. Object fields are -passed through without coercion or field validation. Methods without a body -argument do not consume the request body. These request semantics apply to both -live development and deployed workers with the matching API runtime release. - -## Typed input with FastAPI - -An ASGI factory gives FastAPI ownership of parameter binding and validation. -Declare `fastapi` as a resource dependency so it is available on the worker. - -```python -from runpod import App - -app = App("typed-api") - -@app.api(cpu="cpu3c-1-2", dependencies=["fastapi"]) -def web(): - from fastapi import Body, FastAPI - - server = FastAPI() - - @server.post("/items/{item_id}") - async def update( - item_id: int, - limit: int = 10, - prompt: str = Body(...), - count: int = Body(...), - ): - return {"id": item_id, "limit": limit, "prompt": prompt, "count": count} - - return server -``` - -`POST /items/7?limit=2` with `{"prompt": "hello", "count": 3}` binds all four -arguments. An invalid integer path or query value, or a missing or invalid body -field, receives HTTP 422 from FastAPI. From 737bf38f93fc5a3a4b8f6a07a9520f011091ffce Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:14:59 -0800 Subject: [PATCH 3/4] fix(apps): accept JSON object annotations consistently --- CHANGELOG.md | 1 + examples/apps/README.md | 28 ++++- runpod/apps/app.py | 6 +- runpod/apps/handles.py | 54 ++++++--- tests/test_apps/test_native_api.py | 169 ++++++++++++++++++++++++++++- 5 files changed, 240 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 382664f8a..2fb6a778f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/examples/apps/README.md b/examples/apps/README.md index c7ca9abb5..313fc07bc 100644 --- a/examples/apps/README.md +++ b/examples/apps/README.md @@ -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 @@ -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. - diff --git a/runpod/apps/app.py b/runpod/apps/app.py index b82d6d59f..f559d6d77 100644 --- a/runpod/apps/app.py +++ b/runpod/apps/app.py @@ -230,8 +230,10 @@ def web(): 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, or Any. defaults, - keyword-only arguments, variadics, and body schemas are unsupported. + 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. """ diff --git a/runpod/apps/handles.py b/runpod/apps/handles.py index 3c92be37e..356bdb2d8 100644 --- a/runpod/apps/handles.py +++ b/runpod/apps/handles.py @@ -21,6 +21,7 @@ def load_model(): ... # worker startup hook import asyncio import inspect import sys +from types import SimpleNamespace from typing import ( TYPE_CHECKING, Any, @@ -30,6 +31,9 @@ def load_model(): ... # worker startup hook List, Optional, Type, + get_args, + get_origin, + get_type_hints, ) from .._serialization import normalize_json @@ -320,17 +324,20 @@ def _validate_native_route(cls: Type, name: str, method: str, path: str) -> None 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, or Any. " + "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) - if sys.version_info >= (3, 14): + 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.FORWARDREF) + signature = inspect.signature(member, annotation_format=Format.STRING) else: signature = inspect.signature(member) parameters = list(signature.parameters.values()) @@ -351,18 +358,37 @@ def _validate_native_route(cls: Type, name: str, method: str, path: str) -> None raise InvalidResourceError(guidance) annotation = body.annotation if isinstance(annotation, str): - annotation = annotation.strip("'\"") - valid_annotation = annotation in { - "dict", - "Dict", - "typing.Dict", - "Any", - "typing.Any", - } - else: - valid_annotation = annotation in (inspect.Parameter.empty, dict, Dict, Any) + 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: + pass + 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): + pass + 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: - raise InvalidResourceError(guidance) + 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: diff --git a/tests/test_apps/test_native_api.py b/tests/test_apps/test_native_api.py index 890de246e..9a6218a97 100644 --- a/tests/test_apps/test_native_api.py +++ b/tests/test_apps/test_native_api.py @@ -2,6 +2,7 @@ import sys import typing +from functools import wraps import pytest @@ -50,11 +51,17 @@ def synchronous(self): "self, payload: dict", "self, payload: typing.Dict", "self, payload: typing.Any", + "self, payload: dict[str, typing.Any]", + "self, payload: typing.Dict[str, typing.Any]", + "self, payload: Dict[str, Any]", + "self, payload: dict[str, Any]", "self, payload: 'dict'", "self, payload: 'typing.Dict'", "self, payload: 'typing.Any'", "self, payload: 'Dict'", "self, payload: 'Any'", + "self, payload: 'dict[str, Any]'", + "self, payload: 'typing.Dict[str, typing.Any]'", "self, payload: dict, /", ], ) @@ -62,6 +69,7 @@ def synchronous(self): def test_single_json_object_argument(marker, signature, postponed): namespace = {"typing": typing} source = "from __future__ import annotations\n" if postponed else "" + source += "from typing import Any, Dict\n" source += f"async def route({signature}) -> 'Result':\n return payload\n" exec(source, namespace) cls = type("Service", (), {"route": marker("/echo")(namespace["route"])}) @@ -69,6 +77,104 @@ def test_single_json_object_argument(marker, signature, postponed): assert handle.spec.routes[0].handler_name == "route" +@pytest.mark.parametrize( + "annotation", + [ + "t.Dict", + "t.Any", + "t.Dict[str, t.Any]", + "dict[str, t.Any]", + "Object", + "Object[str, Value]", + "Value", + "JSONBody", + "'JSONBody'", + ], +) +@pytest.mark.parametrize("postponed", [False, True]) +@pytest.mark.parametrize("wrapped", [False, True]) +def test_imported_body_aliases(annotation, postponed, wrapped): + namespace = {} + source = "from __future__ import annotations\n" if postponed else "" + source += ( + "import typing as t\n" + "from typing import Dict as Object, Any as Value\n" + "JSONBody = Object[str, Value]\n" + f"def route(self, payload: {annotation}) -> 'UnresolvedReturn':\n" + " return payload\n" + ) + exec(source, namespace) + route = namespace["route"] + if wrapped: + original = route + + @wraps(original) + def route(*args, **kwargs): + return original(*args, **kwargs) + + cls = type("Service", (), {"route": post("/echo")(route)}) + assert App("test").api()(cls).spec.routes[0].handler_name == "route" + + +@pytest.mark.parametrize( + "annotation", + [ + "t.Dict[str, int]", + "dict[str, int]", + "t.Dict[int, t.Any]", + "dict[int, t.Any]", + "dict[t.Any, t.Any]", + "Object[str, int]", + "Schema", + "Model", + "Dict", + "Any", + "MissingBody", + "t.MissingBody", + "t.Annotated[dict[str, t.Any], 'body']", + ], +) +@pytest.mark.parametrize("postponed", [False, True]) +@pytest.mark.parametrize("wrapped", [False, True]) +def test_invalid_body_aliases_identify_argument_and_annotation( + annotation, postponed, wrapped +): + namespace = {} + source = "from __future__ import annotations\n" if postponed else "" + source += ( + "import typing as t\n" + "from typing import Dict as Object\n" + "from pydantic import BaseModel\n" + "class Schema(t.TypedDict):\n" + " count: int\n" + "class Model(BaseModel):\n" + " count: int\n" + "Dict = dict[str, int]\n" + "Any = int\n" + f"def route(self, payload: {annotation!r}) -> 'UnresolvedReturn':\n" + " return payload\n" + ) + exec(source, namespace) + route = namespace["route"] + if wrapped: + original = route + + @wraps(original) + def route(*args, **kwargs): + return original(*args, **kwargs) + + cls = type("Service", (), {"route": post("/echo")(route)}) + app = App("test") + with pytest.raises(InvalidResourceError) as exc: + app.api()(cls) + message = str(exc.value) + assert "body argument 'payload'" in message + assert annotation in message + assert "POST /echo (Service.route)" in message + assert "FastAPI factory" in message + assert app.resources == {} + + @pytest.mark.parametrize( "signature", ["self, q", "self, q: int = 10", "self, body: dict"] ) @@ -101,7 +207,8 @@ def test_get_arguments_rejected_before_registration(signature): "self, value: str", "self, value: list", "self, value: dict[str, int]", - "self, value: typing.Dict[str, typing.Any]", + "self, value: typing.Dict[int, typing.Any]", + "self, value: dict[int, typing.Any]", "self, value: typing.Optional[dict]", "self, value: typing.Annotated[dict, 'body']", "self, value: 'RequestModel'", @@ -181,6 +288,66 @@ def route(self, body): App("test").api()(cls) +@pytest.mark.skipif(sys.version_info < (3, 14), reason="requires deferred annotations") +def test_deferred_body_aliases_capture_enclosing_scope(): + namespace = {} + exec( + "def make_route():\n" + " from typing import Dict as Object, Any as Value\n" + " def route(self, payload: Object[str, Value]) -> UnresolvedReturn:\n" + " return payload\n" + " return route\n", + namespace, + ) + cls = type("Service", (), {"route": post("/echo")(namespace["make_route"]())}) + assert App("test").api()(cls).spec.routes[0].handler_name == "route" + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason="requires deferred annotations") +def test_deferred_body_aliases_with_unbound_return_closure(): + namespace = {"app": App("test"), "post": post} + exec( + "def declare():\n" + " from typing import Dict as Object, Any as Value\n" + " @app.api()\n" + " class Service:\n" + " @post('/echo')\n" + " def route(self, payload: Object[str, Value]) -> Service:\n" + " return payload\n" + " return Service\n", + namespace, + ) + assert namespace["declare"]().spec.routes[0].handler_name == "route" + + +@pytest.mark.parametrize("postponed", [False, True]) +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize("signature", ["self", "self, payload: t.Dict[str, t.Any]"]) +def test_return_annotations_are_not_evaluated(postponed, wrapped, signature): + if not postponed and sys.version_info < (3, 14): + pytest.skip("requires deferred annotations") + namespace = {} + source = "from __future__ import annotations\n" if postponed else "" + source += ( + "import typing as t\n" + "def invalid_return():\n" + " raise AssertionError('return annotation evaluated')\n" + f"def route({signature}) -> invalid_return():\n" + " pass\n" + ) + exec(source, namespace) + route = namespace["route"] + if wrapped: + original = route + + @wraps(original) + def route(*args, **kwargs): + return original(*args, **kwargs) + + cls = type("Service", (), {"route": post("/echo")(route)}) + assert App("test").api()(cls).spec.routes[0].handler_name == "route" + + @pytest.mark.skipif(sys.version_info < (3, 14), reason="requires deferred annotations") def test_deferred_return_annotations_do_not_require_class_name_binding(): app = App("test") From 4d8912d109c59d86ed786534ac19b3b43a6e51bc Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:27:19 -0800 Subject: [PATCH 4/4] fix(apps): handle unresolved body annotations explicitly --- runpod/apps/handles.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/runpod/apps/handles.py b/runpod/apps/handles.py index 356bdb2d8..bbc77496c 100644 --- a/runpod/apps/handles.py +++ b/runpod/apps/handles.py @@ -366,7 +366,7 @@ def _validate_native_route(cls: Type, name: str, method: str, path: str) -> None try: localns[variable] = cell.cell_contents except ValueError: - pass + continue try: annotation = get_type_hints( SimpleNamespace(__annotations__={body.name: annotation}), @@ -375,7 +375,7 @@ def _validate_native_route(cls: Type, name: str, method: str, path: str) -> None include_extras=True, )[body.name] except (NameError, TypeError, SyntaxError, AttributeError): - pass + annotation = None valid_annotation = annotation in (inspect.Parameter.empty, dict, Dict, Any) or ( get_origin(annotation) is dict and get_args(annotation) == (str, Any) )