Skip to content
Open
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
68 changes: 60 additions & 8 deletions python/turboapi/request_handler.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Incomplete PEP 563 fix: UploadFile annotation checks in create_enhanced_handler still use raw param.annotation

The PR fixes PEP 563 from __future__ import annotations for bytes/bytearray/TurboRequest checks (request_handler.py:854) by using the _ann() resolver, but misses three UploadFile annotation checks in the same function that still use param.annotation directly. Under PEP 563, param.annotation is a string like 'UploadFile', so param.annotation is _UploadFile evaluates to False and isinstance(param.annotation, type) also fails. This causes bare file: UploadFile parameters (without explicit = File() default) to not be detected as form params.

Notably, classify_handler in zig_integration.py:232 WAS correctly updated to use the resolved ann, creating an inconsistency: Zig dispatch may classify the handler as "file_sync" but create_enhanced_handler won't populate the UploadFile parameter at runtime.

Affected locations (3 identical patterns)
  • Line 883: pre-check loop sets _has_form_params
  • Line 961: async handler runtime UploadFile extraction
  • Line 1144: sync handler runtime UploadFile extraction

All use param.annotation is _UploadFile instead of the resolved _ann(pname, param) is _UploadFile.

(Refers to lines 882-886)

Prompt for agents
In create_enhanced_handler (request_handler.py), the PR introduced the _ann() helper to resolve PEP 563 stringified annotations. It was applied to bytes/bytearray/TurboRequest checks (line 854) but missed three UploadFile annotation checks that still use param.annotation directly:

1. Line 882-885: The pre-check loop that sets _has_form_params
2. Line 960-966: The async enhanced handler's runtime UploadFile detection
3. Line 1143-1148: The sync enhanced handler's runtime UploadFile detection

All three should use _ann(pname, param) instead of param.annotation, matching what was done for classify_handler in zig_integration.py:232. Note that the runtime loops (points 2 and 3) iterate over sig.parameters using the variable name pname/param, so _ann(pname, param) can be called directly.

The fix pattern is:
  BEFORE: param.annotation is _UploadFile or (isinstance(param.annotation, type) and issubclass(param.annotation, _UploadFile))
  AFTER:  _ann(pname, param) is _UploadFile or (isinstance(_ann(pname, param), type) and issubclass(_ann(pname, param), _UploadFile))

Or better, assign _resolved = _ann(pname, param) once and use it in the check.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,8 @@ class PathParamParser:

@staticmethod
def extract_path_params(
route_pattern: str, actual_path: str, handler_signature: inspect.Signature | None = None
route_pattern: str, actual_path: str, handler_signature: inspect.Signature | None = None,
handler: Any | None = None,
) -> dict[str, Any]:
"""
Extract path parameters from actual path using route pattern.
Expand All @@ -248,6 +249,7 @@ def extract_path_params(
route_pattern: Route pattern with {param} placeholders (e.g., "/users/{user_id}")
actual_path: Actual request path (e.g., "/users/123")
handler_signature: Optional handler signature for type coercion
handler: Optional callable; used to resolve PEP 563 stringified annotations

Returns:
Dictionary of extracted path parameters (type-coerced if signature provided)
Expand All @@ -265,11 +267,32 @@ def extract_path_params(

params = match.groupdict()

# Resolve stringified annotations so ``from __future__ import annotations``
# does not silently break type coercion below.
resolved_hints: dict[str, Any] = {}
if handler is not None:
try:
import typing as _typing

resolved_hints = _typing.get_type_hints(handler)
except Exception:
resolved_hints = {}

# Coerce types based on handler signature annotations
if handler_signature:
for name, value in params.items():
if name in handler_signature.parameters:
annotation = handler_signature.parameters[name].annotation
annotation = resolved_hints.get(
name, handler_signature.parameters[name].annotation
)
# Resolve raw string annotations to built-in types where possible.
if isinstance(annotation, str):
annotation = {
"int": int,
"float": float,
"bool": bool,
"str": str,
}.get(annotation, annotation)
try:
if annotation is int:
params[name] = int(value)
Expand Down Expand Up @@ -785,6 +808,18 @@ def create_enhanced_handler(original_handler, route_definition):
sig = inspect.signature(original_handler)
is_async = inspect.iscoroutinefunction(original_handler)

# Resolve PEP 563 stringified annotations (``from __future__ import annotations``)
# so all downstream identity / isclass / issubclass checks see real types.
try:
import typing as _typing

_resolved_hints = _typing.get_type_hints(original_handler)
except Exception:
_resolved_hints = {}

def _ann(pname: str, param: inspect.Parameter):
return _resolved_hints.get(pname, param.annotation)

# Pre-compile path param regex and type converters at registration time
import re as _re

Expand All @@ -795,7 +830,7 @@ def create_enhanced_handler(original_handler, route_definition):
if "{" in rp:
_path_pattern = _re.compile("^" + _re.sub(r"\{(\w+)\}", r"(?P<\1>[^/]+)", rp) + "$")
for pname, param in sig.parameters.items():
ann = param.annotation
ann = _ann(pname, param)
if ann is int:
_path_param_types[pname] = int
elif ann is float:
Expand All @@ -816,10 +851,10 @@ def create_enhanced_handler(original_handler, route_definition):
_raw_body_param_names: set[str] = set()
_request_param_names: set[str] = set()
for _pname, _param in sig.parameters.items():
_ann = _param.annotation
if _ann is bytes or _ann is bytearray:
_a = _ann(_pname, _param)
if _a is bytes or _a is bytearray:
_raw_body_param_names.add(_pname)
elif isinstance(_ann, type) and issubclass(_ann, _TurboRequest):
elif isinstance(_a, type) and issubclass(_a, _TurboRequest):
_request_param_names.add(_pname)
_skip_json_body = bool(_raw_body_param_names or _request_param_names)

Expand Down Expand Up @@ -1304,10 +1339,19 @@ def create_fast_handler(original_handler, route_definition):
sig = inspect.signature(original_handler)
param_names = set(sig.parameters.keys())

# Resolve stringified annotations (``from __future__ import annotations``) so
# path-param type coercion below still fires on ``get(item_id: int)`` etc.
try:
import typing as _typing

_hints = _typing.get_type_hints(original_handler)
except Exception:
_hints = {}

# Pre-build type converters for path params
_converters: dict[str, type] = {}
for pname, param in sig.parameters.items():
ann = param.annotation
ann = _hints.get(pname, param.annotation)
if ann is int:
_converters[pname] = int
elif ann is float:
Expand Down Expand Up @@ -1427,9 +1471,17 @@ def create_fast_async_handler(original_handler, route_definition, eager: bool =
sig = inspect.signature(original_handler)
param_names = set(sig.parameters.keys())

# Resolve stringified annotations for ``from __future__ import annotations``.
try:
import typing as _typing

_hints = _typing.get_type_hints(original_handler)
except Exception:
_hints = {}

_converters: dict[str, type] = {}
for pname, param in sig.parameters.items():
ann = param.annotation
ann = _hints.get(pname, param.annotation)
if ann is int:
_converters[pname] = int
elif ann is float:
Expand Down
39 changes: 32 additions & 7 deletions python/turboapi/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,31 +133,56 @@ def decorator(
def wrapper(func: Callable) -> Callable:
# Analyze function signature
sig = inspect.signature(func)

# Resolve PEP 563 stringified annotations (``from __future__ import
# annotations``) so ``inspect.isclass(...)`` and identity checks on
# the annotation don't silently skip path/query binding.
try:
import typing as _typing

resolved_hints = _typing.get_type_hints(func)
except Exception:
resolved_hints = {}

_str_aliases = {
"int": int,
"float": float,
"bool": bool,
"str": str,
"bytes": bytes,
}

def _resolved_ann(p_name, p):
ann = resolved_hints.get(p_name, p.annotation)
if isinstance(ann, str):
ann = _str_aliases.get(ann, ann)
return ann

path_params = []
query_params = {}
request_model = None

for param_name, param in sig.parameters.items():
raw_ann = param.annotation
ann = _resolved_ann(param_name, param)
if f"{{{param_name}}}" in path:
# Path parameter
path_param = PathParameter(
name=param_name,
type=param.annotation
if param.annotation != inspect.Parameter.empty
else str,
type=ann if raw_ann != inspect.Parameter.empty else str,
default=param.default
if param.default != inspect.Parameter.empty
else None,
required=param.default == inspect.Parameter.empty,
)
path_params.append(path_param)
elif param.annotation != inspect.Parameter.empty:
elif raw_ann != inspect.Parameter.empty:
# Check if it's a request model (class type)
if inspect.isclass(param.annotation):
request_model = param.annotation
if inspect.isclass(ann):
request_model = ann
else:
# Query parameter
query_params[param_name] = param.annotation
query_params[param_name] = ann

# Create route definition
full_path = self.prefix + path
Expand Down
68 changes: 65 additions & 3 deletions python/turboapi/zig_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import inspect
import json
import os
import typing
from typing import Any, get_origin

try:
Expand All @@ -27,6 +28,57 @@
)
from .version_check import CHECK_MARK, CROSS_MARK, ROCKET

# ── PEP 563 helpers ──────────────────────────────────────────────────────────
# Resolve stringified annotations produced by ``from __future__ import
# annotations`` so identity checks like ``ann is int`` and ``inspect.isclass``
# below keep working. See issue #142 (Bug 3).


def _resolve_handler_hints(handler) -> dict[str, Any]:
"""Resolve stringified annotations (PEP 563 / ``from __future__ import annotations``).

Returns a mapping of parameter name -> resolved type. Silently falls back to
``inspect`` annotations (which may still be strings) if resolution fails —
callers that truly need the resolved type should guard identity checks with
:func:`_coerce_annotation`.
"""
try:
return typing.get_type_hints(handler, include_extras=True)
except Exception:
out: dict[str, Any] = {}
try:
sig = inspect.signature(handler)
except (TypeError, ValueError):
return out
for name, param in sig.parameters.items():
if param.annotation is not inspect.Parameter.empty:
out[name] = param.annotation
return out


_STR_TYPE_ALIASES: dict[str, Any] = {
"int": int,
"float": float,
"bool": bool,
"str": str,
"bytes": bytes,
"bytearray": bytearray,
"list": list,
"dict": dict,
"tuple": tuple,
"set": set,
}


def _coerce_annotation(annotation: Any) -> Any:
"""Best-effort resolution for a single stringified annotation.

Only handles built-in type names; anything else is returned unchanged.
"""
if isinstance(annotation, str):
return _STR_TYPE_ALIASES.get(annotation, annotation)
return annotation

_ASYNC_YIELD_OPS = {
"ASYNC_GEN_WRAP",
"BEFORE_ASYNC_WITH",
Expand Down Expand Up @@ -135,6 +187,15 @@ def classify_handler(handler, route) -> tuple[str, dict[str, str], dict]:
is_async = inspect.iscoroutinefunction(handler)

sig = inspect.signature(handler)
# Resolve PEP 563 stringified annotations so identity checks below (``is int``,
# ``issubclass``, ``isclass`` …) work even when the handler's module uses
# ``from __future__ import annotations``.
_resolved_hints = _resolve_handler_hints(handler)

def _ann_of(p_name: str, param: inspect.Parameter) -> Any:
ann = _resolved_hints.get(p_name, param.annotation)
return _coerce_annotation(ann)

param_types = {}
needs_body = False
has_depends = False
Expand All @@ -161,14 +222,15 @@ def classify_handler(handler, route) -> tuple[str, dict[str, str], dict]:
from .datastructures import File, Form, UploadFile

for pname, param in sig.parameters.items():
ann = _ann_of(pname, param)
if isinstance(param.default, Form):
has_form = True
param_types[pname] = "form"
elif isinstance(param.default, File):
has_file = True
param_types[pname] = "file"
elif param.annotation is UploadFile or (
isinstance(param.annotation, type) and issubclass(param.annotation, UploadFile)
elif ann is UploadFile or (
isinstance(ann, type) and issubclass(ann, UploadFile)
):
has_file = True
param_types[pname] = "file"
Expand All @@ -177,7 +239,7 @@ def classify_handler(handler, route) -> tuple[str, dict[str, str], dict]:

has_implicit_header_params = False
for param_name, param in sig.parameters.items():
annotation = param.annotation
annotation = _ann_of(param_name, param)

if param_types.get(param_name) in ("form", "file"):
needs_body = True
Expand Down
Loading
Loading