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
13 changes: 9 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Rust build artifacts
target/
Cargo.lock

# Conductor workspace
.context/
Expand All @@ -14,6 +13,8 @@ __pycache__/
build/
dist/
.eggs/
*.egg
pip-wheel-metadata/

# Virtual environments
.env
Expand All @@ -30,24 +31,28 @@ turbo-freethreaded/
.idea/
*.swp
*.swo
*~

# macOS
.DS_Store
.AppleDouble
.LSOverride

# Benchmark and test outputs
benchmark_graphs/
*.json
archive/
*.bench.json
benchmark_results*.json

# Temporary files
*.tmp
*.temp
*.log
*.bak

# Documentation build
docs/_build/
site/

# PyO3/maturin build artifacts
*.whl
target/wheels/
benchmark_graphs/
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "turbonet"
version = "0.5.2"
version = "0.5.21"
edition = "2021"
authors = ["Rach Pradhan <rach@turboapi.dev>"]
description = "High-performance Python web framework core - Rust-powered HTTP server with Python 3.14 free-threading support, FastAPI-compatible security and middleware"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "turboapi"
version = "0.5.2"
version = "0.5.21"
description = "FastAPI-compatible web framework with Rust HTTP core - 2-3x faster with Python 3.13 free-threading"
readme = "README.md"
requires-python = ">=3.13"
Expand Down
29 changes: 23 additions & 6 deletions python/turboapi/request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,12 @@ def parse_json_body(body: bytes, handler_signature: inspect.Signature) -> dict[s
"""
if not body:
return {}

try:
json_data = json.loads(body.decode('utf-8'))
# CRITICAL: Make a defensive copy immediately using bytearray to force real copy
# Free-threaded Python with Metal/MLX can have concurrent memory access issues
body_copy = bytes(bytearray(body))
json_data = json.loads(body_copy.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"Invalid JSON body: {e}")

Expand Down Expand Up @@ -348,9 +351,16 @@ def normalize_response(result: Any) -> tuple[Any, int]:
try:
import json
body = json.loads(body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
# Keep as string for HTML/Text responses
body = body.decode('utf-8')
except json.JSONDecodeError:
# Not JSON, try as plain text
try:
body = body.decode('utf-8')
except UnicodeDecodeError:
# Binary data (audio, image, etc.) - keep as bytes
pass
except UnicodeDecodeError:
# Binary data (audio, image, etc.) - keep as bytes
pass
return body, result.status_code

# Handle tuple returns: (content, status_code)
Expand Down Expand Up @@ -394,6 +404,13 @@ def format_json_response(content: Any, status_code: int) -> dict[str, Any]:
def make_serializable(obj):
if isinstance(obj, Model):
return obj.model_dump()
elif isinstance(obj, bytes):
# Binary data - try to decode as UTF-8, otherwise base64 encode
try:
return obj.decode('utf-8')
except UnicodeDecodeError:
import base64
return base64.b64encode(obj).decode('ascii')
elif isinstance(obj, dict):
return {k: make_serializable(v) for k, v in obj.items()}
elif isinstance(obj, (list, tuple)):
Expand Down Expand Up @@ -491,7 +508,7 @@ async def enhanced_handler(**kwargs):

# Call original async handler and await it
result = await original_handler(**filtered_kwargs)

# Normalize response
content, status_code = ResponseHandler.normalize_response(result)

Expand Down
4 changes: 4 additions & 0 deletions python/turboapi/rust_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def classify_handler(handler, route) -> tuple[str, dict[str, str], dict]:
if BaseModel is not None and inspect.isclass(annotation) and issubclass(annotation, BaseModel):
# Found a model parameter - use fast model path (sync only for now)
model_info = {"param_name": param_name, "model_class": annotation}
# For async handlers, model parsing needs the enhanced path
# since Rust-side model parsing only supports sync handlers
if is_async:
needs_body = True
continue # Don't add to param_types
except TypeError:
pass
Expand Down
12 changes: 9 additions & 3 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -855,13 +855,19 @@ async fn call_python_handler_enhanced_async(
.map_err(|e| format!("Semaphore error: {}", e))?;

// Build kwargs and call async handler
let future = Python::with_gil(|py| {
// Make a defensive copy to own the data before the async boundary
let body_vec: Vec<u8> = body_bytes.to_vec();

// Use Python::attach (like sync handlers) instead of with_gil for better free-threading support
let future = Python::attach(|py| {
use pyo3::types::PyDict;
let kwargs = PyDict::new(py);

// Add body as bytes
// Add body as bytes - use PyBytes to copy into Python-managed memory
// (as_slice() would reference Rust memory that may be freed after closure ends)
let body_py = pyo3::types::PyBytes::new(py, body_vec.as_slice());
kwargs
.set_item("body", body_bytes.as_ref())
.set_item("body", body_py)
.map_err(|e| format!("Body set error: {}", e))?;

// Add headers dict
Expand Down
80 changes: 48 additions & 32 deletions src/simd_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,48 +88,64 @@ fn write_value(py: Python, obj: &Bound<'_, PyAny>, buf: &mut Vec<u8>) -> PyResul

// Fallback: try to convert to a serializable Python representation

// Check for Response objects (JSONResponse, HTMLResponse, etc.)
// These have a 'body' attribute that contains the serialized content
if let Ok(body_attr) = obj.getattr("body") {
if let Ok(status_attr) = obj.getattr("status_code") {
// This is a Response object - extract and serialize the body content
// Check if it has a model_dump() method (dhi/Pydantic models AND Response objects)
// Response classes in turboapi have model_dump() that returns the decoded content
if let Ok(dump_method) = obj.getattr("model_dump") {
if dump_method.is_callable() {
if let Ok(dumped) = dump_method.call0() {
return write_value(py, &dumped, buf);
}
}
}

// Check for Response objects (JSONResponse, HTMLResponse, FileResponse, etc.)
// These have 'body' and 'status_code' attributes
if obj.hasattr("body")? && obj.hasattr("status_code")? {
// Try to get body as bytes first
if let Ok(body_attr) = obj.getattr("body") {
// Try extracting as bytes (Vec<u8>)
if let Ok(body_bytes) = body_attr.extract::<Vec<u8>>() {
// Try to parse body as JSON first
if let Ok(json_str) = String::from_utf8(body_bytes.clone()) {
// If it's valid JSON, use it directly
if json_str.starts_with('{')
|| json_str.starts_with('[')
|| json_str.starts_with('"')
// If it looks like valid JSON, use it directly
let trimmed = json_str.trim();
if trimmed.starts_with('{')
|| trimmed.starts_with('[')
|| trimmed.starts_with('"')
|| trimmed == "null"
|| trimmed == "true"
|| trimmed == "false"
|| trimmed.parse::<f64>().is_ok()
{
buf.extend_from_slice(json_str.as_bytes());
return Ok(());
}
// Otherwise treat as string
buf.push(b'"');
for byte in json_str.bytes() {
match byte {
b'"' => buf.extend_from_slice(b"\\\""),
b'\\' => buf.extend_from_slice(b"\\\\"),
b'\n' => buf.extend_from_slice(b"\\n"),
b'\r' => buf.extend_from_slice(b"\\r"),
b'\t' => buf.extend_from_slice(b"\\t"),
b if b < 32 => {
buf.extend_from_slice(format!("\\u{:04x}", b).as_bytes());
}
_ => buf.push(byte),
}
}
buf.push(b'"');
// Otherwise treat as string content
write_str_escaped(&json_str, buf);
return Ok(());
}
// Binary content - encode as base64 or return error
// For now, just return the length as a placeholder
buf.extend_from_slice(b"\"<binary content>\"");
return Ok(());
}
// Try extracting as string
if let Ok(body_str) = body_attr.extract::<String>() {
let trimmed = body_str.trim();
if trimmed.starts_with('{')
|| trimmed.starts_with('[')
|| trimmed.starts_with('"')
|| trimmed == "null"
|| trimmed == "true"
|| trimmed == "false"
|| trimmed.parse::<f64>().is_ok()
{
buf.extend_from_slice(body_str.as_bytes());
return Ok(());
}
write_str_escaped(&body_str, buf);
return Ok(());
}
}
}

// Check if it has a model_dump() method (dhi/Pydantic models)
if let Ok(dump_method) = obj.getattr("model_dump") {
if let Ok(dumped) = dump_method.call0() {
return write_value(py, &dumped, buf);
}
}

Expand Down
39 changes: 27 additions & 12 deletions src/simd_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,31 +350,46 @@ fn set_simd_object_into_dict<'py>(

/// Parse JSON body using simd-json and return as a Python dict.
/// This is used for model validation where we need the full dict.
/// Falls back to Python's json.loads if simd-json fails.
#[inline]
pub fn parse_json_to_pydict<'py>(py: Python<'py>, body: &[u8]) -> PyResult<Bound<'py, PyDict>> {
if body.is_empty() {
return Ok(PyDict::new(py));
}

// Use simd-json for fast parsing
// Try simd-json first for fast parsing
let mut body_copy = body.to_vec();
let parsed = simd_json::to_borrowed_value(&mut body_copy)
.map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("JSON parse error: {}", e)))?;
let simd_result = simd_json::to_borrowed_value(&mut body_copy);

let dict = PyDict::new(py);
if let Ok(parsed) = simd_result {
let dict = PyDict::new(py);

// Only handle object (dict) bodies
if let simd_json::BorrowedValue::Object(map) = parsed {
for (key, value) in map.iter() {
set_simd_value_into_dict(py, key.as_ref(), value, &dict)?;
// Only handle object (dict) bodies
if let simd_json::BorrowedValue::Object(map) = parsed {
for (key, value) in map.iter() {
set_simd_value_into_dict(py, key.as_ref(), value, &dict)?;
}
return Ok(dict);
} else {
return Err(pyo3::exceptions::PyValueError::new_err(
"Expected JSON object",
));
}
}

// simd-json failed, fall back to Python's json.loads
let json_module = py.import("json")?;
let body_str = String::from_utf8_lossy(body);
let result = json_module.call_method1("loads", (body_str.as_ref(),))?;

// Check if result is a dict
if let Ok(dict) = result.downcast::<PyDict>() {
Ok(dict.clone())
} else {
return Err(pyo3::exceptions::PyValueError::new_err(
Err(pyo3::exceptions::PyValueError::new_err(
"Expected JSON object",
));
))
}

Ok(dict)
}

/// Set a single simd-json value into a PyDict at the given key.
Expand Down
Loading
Loading