Skip to content

Commit 5fd28dd

Browse files
committed
fix: 'dict' object has no attribute 'name'
1 parent 49429f7 commit 5fd28dd

5 files changed

Lines changed: 160 additions & 99 deletions

File tree

astraapi/_compat/v2.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,20 @@ def validate(
138138
return None, _regenerate_error_with_loc(
139139
errors=exc.errors(include_url=False), loc_prefix=loc
140140
)
141+
except (TypeError, AttributeError) as exc:
142+
# Pydantic v2 validate_python with from_attributes=True can raise
143+
# TypeError/AttributeError when re-validating model instances that
144+
# contain nested dicts (e.g. model_construct bypass) or when
145+
# computed fields access missing attributes.
146+
return None, _regenerate_error_with_loc(
147+
errors=[{
148+
"type": "response_validation_error",
149+
"loc": loc,
150+
"msg": str(exc),
151+
"input": value,
152+
}],
153+
loc_prefix=loc,
154+
)
141155

142156
def serialize(
143157
self,

astraapi/applications.py

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,15 +1241,33 @@ async def _response_model_shim(**kwargs: Any) -> Any:
12411241
except Exception:
12421242
pass
12431243
raise exc
1244-
return response_field.serialize(
1245-
value,
1246-
include=response_model_include,
1247-
exclude=response_model_exclude,
1248-
by_alias=response_model_by_alias,
1249-
exclude_unset=response_model_exclude_unset,
1250-
exclude_defaults=response_model_exclude_defaults,
1251-
exclude_none=response_model_exclude_none,
1252-
)
1244+
try:
1245+
return response_field.serialize(
1246+
value,
1247+
include=response_model_include,
1248+
exclude=response_model_exclude,
1249+
by_alias=response_model_by_alias,
1250+
exclude_unset=response_model_exclude_unset,
1251+
exclude_defaults=response_model_exclude_defaults,
1252+
exclude_none=response_model_exclude_none,
1253+
)
1254+
except (TypeError, AttributeError) as _ser_exc:
1255+
from astraapi.exceptions import ResponseValidationError
1256+
_rve = ResponseValidationError(
1257+
errors=[{
1258+
"type": "response_serialization_error",
1259+
"loc": ("response",),
1260+
"msg": str(_ser_exc),
1261+
"input": raw,
1262+
}],
1263+
body=raw,
1264+
)
1265+
try:
1266+
from astraapi._cpp_server import _set_last_server_exception
1267+
_set_last_server_exception(_rve)
1268+
except Exception:
1269+
pass
1270+
raise _rve
12531271

12541272
_response_model_shim.__name__ = getattr(original_endpoint, "__name__", "_response_model_shim")
12551273
return _response_model_shim
@@ -1836,15 +1854,39 @@ async def _mw_call_next(_req, _r=_mw_resp):
18361854
except Exception:
18371855
pass
18381856
raise _rve
1839-
result = _response_field.serialize(
1840-
_val,
1841-
include=_rm_include,
1842-
exclude=_rm_exclude,
1843-
by_alias=_rm_by_alias,
1844-
exclude_unset=_rm_exclude_unset,
1845-
exclude_defaults=_rm_exclude_defaults,
1846-
exclude_none=_rm_exclude_none,
1847-
)
1857+
try:
1858+
result = _response_field.serialize(
1859+
_val,
1860+
include=_rm_include,
1861+
exclude=_rm_exclude,
1862+
by_alias=_rm_by_alias,
1863+
exclude_unset=_rm_exclude_unset,
1864+
exclude_defaults=_rm_exclude_defaults,
1865+
exclude_none=_rm_exclude_none,
1866+
)
1867+
except (TypeError, AttributeError) as _ser_exc:
1868+
from astraapi.exceptions import ResponseValidationError as _RVE
1869+
from astraapi.routing import _extract_endpoint_context
1870+
_rve_ctx = _extract_endpoint_context(original_endpoint)
1871+
_dep = getattr(route, 'dependant', None)
1872+
if _dep and getattr(_dep, 'path', None):
1873+
_rve_ctx['path'] = f"GET {_dep.path}"
1874+
_rve = _RVE(
1875+
errors=[{
1876+
"type": "response_serialization_error",
1877+
"loc": ("response",),
1878+
"msg": str(_ser_exc),
1879+
"input": result,
1880+
}],
1881+
body=result,
1882+
endpoint_ctx=_rve_ctx,
1883+
)
1884+
try:
1885+
from astraapi._cpp_server import _set_last_server_exception as _slse
1886+
_slse(_rve)
1887+
except Exception:
1888+
pass
1889+
raise _rve
18481890
# Not a Response — wrap using the route's response_class
18491891
if _is_redirect:
18501892
# Pass only the URL; let RedirectResponse use its own default status_code

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
44

55
[project]
66
name = "astraapi"
7-
version = "0.2.1"
7+
version = "0.2.11"
88
description = "AstraAPI framework, high performance, easy to learn, fast to code, ready for production"
99
readme = "README.md"
1010
license = {text = "MIT"}

release_notes.md

Lines changed: 8 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,22 @@
1-
# AstraAPI 0.2.1 Release Notes
1+
# AstraAPI 0.2.11 Release Notes
22

3-
**PyPI:** https://pypi.org/project/astraapi/0.2.1
3+
**PyPI:** https://pypi.org/project/astraapi/0.2.11
44
**Python:** 3.14+ | **C++:** 20
55

66
---
77

88
## Overview
99

10-
AstraAPI 0.2.1 is a stability and compatibility release for our FastAPI-compatible web framework powered by a compiled C++20 core. This release fixes editor IntelliSense, resolves Windows build failures, hardens CI/CD pipelines, and raises the minimum Python version to **3.14**.
11-
12-
```python
13-
from astraapi import AstraAPI
14-
15-
app = AstraAPI()
16-
17-
@app.get("/")
18-
def hello():
19-
return {"message": "Hello World"}
20-
21-
if __name__ == "__main__":
22-
app.run(port=8000, workers=4)
23-
```
24-
25-
---
26-
27-
## Breaking Changes
28-
29-
### Minimum Python Version Raised to 3.14
30-
- **Dropped support for Python 3.12 and 3.13.**
31-
- The codebase leverages `annotationlib` (PEP 649 deferred annotations) and other 3.14-specific runtime features that are not back-portable.
32-
- Wheels are now built **exclusively** for `cp314` (Linux x86_64/aarch64, macOS arm64, Windows x64).
33-
- Updated all docs, Docker examples, and CI matrices to reflect `python:3.14-slim`.
10+
Patch release that fixes a runtime compatibility issue where endpoints returning `list[PydanticModel]` could crash with `'dict' object has no attribute 'name'` during response validation/serialization.
3411

3512
---
3613

37-
## What's New
38-
39-
### Editor IntelliSense & Static Analysis
40-
- **Added `astraapi/py.typed`** — PEP 561 marker so Pylance, Pyright, and mypy recognize the package as fully typed.
41-
- **Added `astraapi/__init__.pyi`** — Stub file with explicit re-exports of all 20 public symbols (`AstraAPI`, `Depends`, `HTTPException`, `Request`, `Response`, `APIRouter`, `WebSocket`, etc.).
42-
- **Root cause:** `astraapi/__init__.py` used `__getattr__` lazy loading for fast cold-start (~1 s vs ~4.7 s). While great for runtime, it completely broke static resolution. The `.pyi` stub restores autocomplete, "Go to Definition", and type checking without changing runtime behavior.
43-
44-
### Windows Build Fixes
45-
- **`cpp_core/src/app.cpp`** — Added `#define NOMINMAX` before `Python.h` to prevent Windows `min`/`max` macros from clashing with `std::min`/`std::max` (MSVC `C2589` / `C2059`).
46-
- **`cpp_core/src/streaming_multipart.cpp`** — Replaced POSIX-only `mkstemp` / `write` / `close` with cross-platform `std::fopen` / `std::fwrite` / `std::fclose` + `std::filesystem::temp_directory_path()`.
47-
- **`cpp_core/include/streaming_multipart.hpp`** — Renamed enum value `ERROR``ERR` in both `MultipartState` and `FeedResult`. Windows defines `ERROR` as a macro (`0`), causing `FeedResult::ERROR` to expand to `FeedResult::0` (syntax error).
48-
- **`scripts/build_core.sh`** — Now passes `-DPython3_EXECUTABLE="$(python -c 'import sys; print(sys.executable)')"` to CMake to guarantee the C++ extension is built against the same Python that runs it.
49-
50-
### CI/CD Hardening
51-
- **`.github/workflows/tests.yml`** — Switched from `pip` to `uv` for faster installs. Installs `".[dev,standard]"` to ensure `httpx`, `jinja2`, `python-multipart`, and other standard extras are present during testing. Uses Python 3.14 explicitly (matching local dev and `uv`'s managed Python).
52-
- **`.github/workflows/publish.yml`**`CIBW_BUILD` narrowed to `cp314-*` only. Fixed CMake Python version mismatch that caused segfaults when the runtime Python differed from the build-time Python.
14+
## Bug Fixes
5315

54-
### Test Stability
55-
- **`astraapi/_testclient.py`** — TestClient shared server registry now uses `_app_instance_id` (monotonic counter) instead of `id(app)`. Python's allocator recycles object addresses, causing servers to be incorrectly reused across tests.
56-
- **`astraapi/applications.py`** — Custom route classes now pass `dep_inject_mask=0x07` so the C++ core injects `__raw_headers__`, `__method__`, `__path__` directly into kwargs, eliminating a ContextVar fallback race in `_asgi_shim`.
16+
### Response Model Validation/Serialization Robustness
5717

58-
### Documentation
59-
- **VitePress `base` path** — Changed from `/Astraapi/` to `/` to support custom root domains (e.g., `https://astraapi.lumoslab.tech/`). Previously CSS/assets 404'd when the site was served at a domain root instead of a GitHub Pages subdirectory.
60-
- **Dev dependencies** — Added missing test-only packages to `[project.optional-dependencies] dev`: `starlette`, `httpx`, `jinja2`, `python-multipart`, `email-validator`, `pydantic-settings`, `pydantic-extra-types`, `websockets`, `uvicorn`.
61-
62-
---
63-
64-
## Architecture
65-
66-
| Layer | Technology |
67-
|-------|-----------|
68-
| HTTP parser | llhttp (Node.js parser, compiled into C++ extension) |
69-
| JSON encoder | yyjson (zero-allocation, SIMD-accelerated) |
70-
| JSON serializer | Custom C++20 string builder with SSE2/NEON small-string optimization |
71-
| Compression | libdeflate (optional, ~3× faster than zlib) |
72-
| WebSocket framing | Custom C++ frame parser + ring buffer |
73-
| Response serialization | C++ `HTTPResponse` → Python `bytes` via memoryview (zero-copy when possible) |
74-
| Dependency injection | C++ topological sort (`compute_dependency_order`) at route registration time |
75-
| Parameter extraction | C++ scalar coercion + batch validation (avoids per-request Python loops) |
76-
77-
---
78-
79-
## Performance
80-
81-
| Metric | Value | Notes |
82-
|--------|-------|-------|
83-
| Cold import | ~1.0 s | `__getattr__` lazy loading defers pydantic/openapi until first use |
84-
| Hello World RPS | 450K+ | Single process, keep-alive, C++ core serving directly |
85-
| Latency p99 | 0.8 ms | `wrk -t4 -c400 -d30s` on AMD Ryzen 9 |
86-
| JSON serialization | ~2× faster | Custom C++ string builder vs `json.dumps` |
87-
| Multi-process | Linear scaling | `app.run(workers=N)` uses `SO_REUSEPORT` + pre-fork |
88-
89-
*Benchmarks: Python 3.14, C++20, AMD Ryzen 9, 16 workers, wrk2*
18+
- **`astraapi/_compat/v2.py`**`ModelField.validate()` now catches `TypeError` and `AttributeError` in addition to `ValidationError`. Pydantic v2's `validate_python(..., from_attributes=True)` can raise these exceptions when re-validating model instances that contain nested dicts (e.g., `model_construct` bypass) or when computed fields access missing attributes.
19+
- **`astraapi/applications.py`** — Both `_make_response_model_shim` and `_make_response_class_shim` now wrap `response_field.serialize()` in try-except to catch `TypeError` and `AttributeError` and convert them to `ResponseValidationError` instead of letting raw exceptions propagate and crash the request.
9020

9121
---
9222

@@ -96,6 +26,4 @@ if __name__ == "__main__":
9626
pip install astraapi
9727
```
9828

99-
Requires **Python 3.14+** and a C++20-capable compiler (GCC 10+, Clang 12+, MSVC 2019+). Pre-built wheels are provided for Linux x86_64/aarch64, macOS arm64, and Windows x64. The C++ extension builds automatically from source if a wheel is not available for your platform.
100-
101-
---
29+
Requires **Python 3.14+** and a C++20-capable compiler (GCC 10+, Clang 12+, MSVC 2019+). Pre-built wheels are provided for Linux x86_64/aarch64, macOS arm64, and Windows x64.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Regression tests for response model serialization edge cases.
2+
3+
Covers: AttributeError/TypeError during validate_python with from_attributes=True
4+
when re-validating model instances that contain nested dicts (e.g. model_construct
5+
bypass) or when computed fields access missing attributes.
6+
"""
7+
import pytest
8+
from pydantic import BaseModel, computed_field
9+
from typing import Optional
10+
11+
from astraapi import AstraAPI
12+
from astraapi.testclient import TestClient
13+
from astraapi.exceptions import ResponseValidationError
14+
15+
16+
class Inner(BaseModel):
17+
name: str
18+
19+
20+
class Outer(BaseModel):
21+
inner: Optional[Inner] = None
22+
23+
@computed_field
24+
@property
25+
def display_name(self) -> str:
26+
# This will fail if inner is a dict instead of Inner
27+
return self.inner.name
28+
29+
30+
class PlainOuter(BaseModel):
31+
inner: Optional[Inner] = None
32+
33+
34+
def test_valid_model_instance_serializes_normally():
35+
"""Normal model instances should still serialize correctly."""
36+
app = AstraAPI()
37+
38+
@app.get("/items", response_model=list[PlainOuter])
39+
def get_items():
40+
return [PlainOuter(inner=Inner(name="test"))]
41+
42+
client = TestClient(app)
43+
response = client.get("/items")
44+
assert response.status_code == 200
45+
assert response.json() == [{"inner": {"name": "test"}}]
46+
47+
48+
def test_computed_field_attribute_error_serialization():
49+
"""A computed field that accesses an attribute on a dict should raise
50+
ResponseValidationError, not raw AttributeError."""
51+
app = AstraAPI()
52+
53+
@app.get("/items", response_model=list[Outer])
54+
def get_items():
55+
return [Outer.model_construct(inner={"name": "test"})]
56+
57+
client = TestClient(app)
58+
with pytest.raises(ResponseValidationError):
59+
client.get("/items")
60+
61+
62+
def test_computed_field_with_custom_exception_handler():
63+
"""Even with a catch-all Exception handler, ResponseValidationError
64+
should be raised (TestClient re-raises it as a programming error)."""
65+
app = AstraAPI()
66+
67+
@app.exception_handler(Exception)
68+
async def generic_handler(request, exc):
69+
return {"error": str(exc)}
70+
71+
@app.get("/items", response_model=list[Outer])
72+
def get_items():
73+
return [Outer.model_construct(inner={"name": "test"})]
74+
75+
client = TestClient(app)
76+
with pytest.raises(ResponseValidationError):
77+
client.get("/items")

0 commit comments

Comments
 (0)