Skip to content

Commit 4460535

Browse files
authored
fix: quart async test (#450)
* fix: quart async test Signed-off-by: Keming <kemingy94@gmail.com> * fix xml input Signed-off-by: Keming <kemingy94@gmail.com> --------- Signed-off-by: Keming <kemingy94@gmail.com>
1 parent c08ba67 commit 4460535

12 files changed

Lines changed: 210 additions & 179 deletions

File tree

examples/flask_demo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def with_code_header(headers: Header, cookies: Cookie):
7575
"""
7676
demo for JSON with status code and header
7777
"""
78-
return jsonify(language=headers.Lang), 203, {"X": 233}
78+
return jsonify(language=headers.Lang), 203, {"X": cookies.key}
7979

8080

8181
@app.route("/api/file_upload", methods=["POST"])

examples/quart_demo.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async def with_code_header(headers: Header, cookies: Cookie):
7777
"""
7878
demo for JSON with status code and header
7979
"""
80-
return jsonify(language=headers.get("Lang")), 203, {"X": 233}
80+
return jsonify(language=headers.Lang), 203, {"X": cookies.key}
8181

8282

8383
class UserAPI(MethodView):

pylock.legacy.toml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -773,12 +773,6 @@ wheels = [
773773
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", upload-time = 2024-11-27T22:38:35Z, size = 14257, hashes = { sha256 = "cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc" } },
774774
]
775775

776-
[[packages]]
777-
name = "types-aiofiles"
778-
version = "24.1.0.20250822"
779-
sdist = { url = "https://files.pythonhosted.org/packages/19/48/c64471adac9206cc844afb33ed311ac5a65d2f59df3d861e0f2d0cad7414/types_aiofiles-24.1.0.20250822.tar.gz", upload-time = 2025-08-22T03:02:23Z, size = 14484, hashes = { sha256 = "9ab90d8e0c307fe97a7cf09338301e3f01a163e39f3b529ace82466355c84a7b" } }
780-
wheels = [{ url = "https://files.pythonhosted.org/packages/bc/8e/5e6d2215e1d8f7c2a94c6e9d0059ae8109ce0f5681956d11bb0a228cef04/types_aiofiles-24.1.0.20250822-py3-none-any.whl", upload-time = 2025-08-22T03:02:21Z, size = 14322, hashes = { sha256 = "0ec8f8909e1a85a5a79aed0573af7901f53120dd2a29771dd0b3ef48e12328b0" } }]
781-
782776
[[packages]]
783777
name = "typing-extensions"
784778
version = "4.15.0"

pylock.toml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -834,12 +834,6 @@ wheels = [
834834
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", upload-time = 2024-11-27T22:38:35Z, size = 14257, hashes = { sha256 = "cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc" } },
835835
]
836836

837-
[[packages]]
838-
name = "types-aiofiles"
839-
version = "24.1.0.20250822"
840-
sdist = { url = "https://files.pythonhosted.org/packages/19/48/c64471adac9206cc844afb33ed311ac5a65d2f59df3d861e0f2d0cad7414/types_aiofiles-24.1.0.20250822.tar.gz", upload-time = 2025-08-22T03:02:23Z, size = 14484, hashes = { sha256 = "9ab90d8e0c307fe97a7cf09338301e3f01a163e39f3b529ace82466355c84a7b" } }
841-
wheels = [{ url = "https://files.pythonhosted.org/packages/bc/8e/5e6d2215e1d8f7c2a94c6e9d0059ae8109ce0f5681956d11bb0a228cef04/types_aiofiles-24.1.0.20250822-py3-none-any.whl", upload-time = 2025-08-22T03:02:21Z, size = 14322, hashes = { sha256 = "0ec8f8909e1a85a5a79aed0573af7901f53120dd2a29771dd0b3ef48e12328b0" } }]
842-
843837
[[packages]]
844838
name = "typing-extensions"
845839
version = "4.15.0"

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "spectree"
3-
version = "1.5.5"
3+
version = "1.5.6"
44
dynamic = []
55
description = "Generate OpenAPI document and validate request & response with Python annotations."
66
readme = "README.md"
@@ -67,12 +67,12 @@ warn_untyped_fields = true
6767

6868
[dependency-groups]
6969
dev = [
70+
"anyio>=4.10.0",
7071
"mypy>=1.16.0",
7172
"prek>=0.1.2",
7273
"pytest>=8.3.5",
7374
"ruff>=0.11.12",
7475
"syrupy>=4.9.1",
75-
"types-aiofiles>=24.1.0.20250822",
7676
"uvicorn>=0.35.0",
7777
]
7878
docs = [

spectree/plugins/flask_plugin.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55

66
from spectree._pydantic import (
77
InternalValidationError,
8+
SerializedPydanticResponse,
89
ValidationError,
10+
is_partial_base_model_instance,
11+
serialize_model_instance,
912
)
1013
from spectree._types import ModelType
11-
from spectree.plugins.base import Context
12-
from spectree.plugins.werkzeug_utils import WerkzeugPlugin
14+
from spectree.plugins.base import Context, validate_response
15+
from spectree.plugins.werkzeug_utils import WerkzeugPlugin, flask_response_unpack
1316
from spectree.response import Response
1417
from spectree.utils import cached_type_hints, get_multidict_items
1518

@@ -48,6 +51,64 @@ def request_validation(self, request, query, json, form, headers, cookies):
4851
cookies.parse_obj(req_cookies) if cookies else None,
4952
)
5053

54+
def validate_response(
55+
self,
56+
resp,
57+
resp_model: Optional[Response],
58+
skip_validation: bool,
59+
):
60+
resp_validation_error = None
61+
payload, status, additional_headers = flask_response_unpack(resp)
62+
63+
if self.is_app_response(payload):
64+
resp_status, resp_headers = payload.status_code, payload.headers
65+
payload = payload.get_data()
66+
# the inner flask.Response.status_code only takes effect when there is
67+
# no other status code
68+
if status == 200:
69+
status = resp_status
70+
# use the `Header` object to avoid deduplicated by `make_response`
71+
resp_headers.extend(additional_headers)
72+
additional_headers = resp_headers
73+
74+
if not skip_validation and resp_model:
75+
try:
76+
response_validation_result = validate_response(
77+
validation_model=resp_model.find_model(status),
78+
response_payload=payload,
79+
)
80+
except (InternalValidationError, ValidationError) as err:
81+
errors = (
82+
err.errors()
83+
if isinstance(err, InternalValidationError)
84+
else err.errors(include_context=False)
85+
)
86+
response = make_response(errors, 500)
87+
resp_validation_error = err
88+
else:
89+
response = make_response(
90+
self.get_current_app().response_class(
91+
response_validation_result.payload.data,
92+
mimetype="application/json",
93+
)
94+
if isinstance(
95+
response_validation_result.payload,
96+
SerializedPydanticResponse,
97+
)
98+
else response_validation_result.payload,
99+
status,
100+
additional_headers,
101+
)
102+
else:
103+
if is_partial_base_model_instance(payload):
104+
payload = self.get_current_app().response_class(
105+
serialize_model_instance(payload).data,
106+
mimetype="application/json",
107+
)
108+
response = make_response(payload, status, additional_headers)
109+
110+
return response, resp_validation_error
111+
51112
def validate(
52113
self,
53114
func: Callable,

spectree/plugins/quart_plugin.py

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44
import quart
55
from quart import Blueprint, abort, current_app, jsonify, make_response, request
66

7-
from spectree._pydantic import InternalValidationError, ValidationError
7+
from spectree._pydantic import (
8+
InternalValidationError,
9+
SerializedPydanticResponse,
10+
ValidationError,
11+
is_partial_base_model_instance,
12+
serialize_model_instance,
13+
)
814
from spectree._types import ModelType
9-
from spectree.plugins.base import Context
10-
from spectree.plugins.werkzeug_utils import WerkzeugPlugin
15+
from spectree.plugins.base import Context, validate_response
16+
from spectree.plugins.werkzeug_utils import WerkzeugPlugin, flask_response_unpack
1117
from spectree.response import Response
1218
from spectree.utils import cached_type_hints, get_multidict_items
1319

@@ -54,6 +60,64 @@ async def request_validation(self, request, query, json, form, headers, cookies)
5460
cookies.parse_obj(req_cookies) if cookies else None,
5561
)
5662

63+
async def validate_response(
64+
self,
65+
resp,
66+
resp_model: Optional[Response],
67+
skip_validation: bool,
68+
):
69+
resp_validation_error = None
70+
payload, status, additional_headers = flask_response_unpack(resp)
71+
72+
if self.is_app_response(payload):
73+
resp_status, resp_headers = payload.status_code, payload.headers
74+
payload = await payload.get_data()
75+
# the inner flask.Response.status_code only takes effect when there is
76+
# no other status code
77+
if status == 200:
78+
status = resp_status
79+
# use the `Header` object to avoid deduplicated by `make_response`
80+
resp_headers.extend(additional_headers)
81+
additional_headers = resp_headers
82+
83+
if not skip_validation and resp_model:
84+
try:
85+
response_validation_result = validate_response(
86+
validation_model=resp_model.find_model(status),
87+
response_payload=payload,
88+
)
89+
except (InternalValidationError, ValidationError) as err:
90+
errors = (
91+
err.errors()
92+
if isinstance(err, InternalValidationError)
93+
else err.errors(include_context=False)
94+
)
95+
response = await make_response(errors, 500)
96+
resp_validation_error = err
97+
else:
98+
response = await make_response(
99+
self.get_current_app().response_class(
100+
response_validation_result.payload.data,
101+
mimetype="application/json",
102+
)
103+
if isinstance(
104+
response_validation_result.payload,
105+
SerializedPydanticResponse,
106+
)
107+
else response_validation_result.payload,
108+
status,
109+
additional_headers,
110+
)
111+
else:
112+
if is_partial_base_model_instance(payload):
113+
payload = self.get_current_app().response_class(
114+
serialize_model_instance(payload).data,
115+
mimetype="application/json",
116+
)
117+
response = await make_response(payload, status, additional_headers)
118+
119+
return response, resp_validation_error
120+
57121
async def validate(
58122
self,
59123
func: Callable,
@@ -104,7 +168,7 @@ async def validate(
104168
else func(*args, **kwargs)
105169
)
106170

107-
response, resp_validation_error = self.validate_response(
171+
response, resp_validation_error = await self.validate_response(
108172
result, resp, skip_validation
109173
)
110174
after(request, response, resp_validation_error, None)

spectree/plugins/werkzeug_utils.py

Lines changed: 1 addition & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,7 @@
44
from werkzeug.datastructures import Headers
55
from werkzeug.routing import parse_converter_args
66

7-
from spectree._pydantic import (
8-
InternalValidationError,
9-
SerializedPydanticResponse,
10-
ValidationError,
11-
is_partial_base_model_instance,
12-
serialize_model_instance,
13-
)
14-
from spectree.plugins.base import BasePlugin, validate_response
15-
from spectree.response import Response
7+
from spectree.plugins.base import BasePlugin
168
from spectree.utils import get_multidict_items
179

1810
RE_FLASK_RULE = re.compile(
@@ -239,68 +231,6 @@ def fill_form(self, request) -> dict:
239231
req_data.update(get_multidict_items(request.files) if request.files else {})
240232
return req_data
241233

242-
def validate_response(
243-
self,
244-
resp,
245-
resp_model: Optional[Response],
246-
skip_validation: bool,
247-
):
248-
resp_validation_error = None
249-
payload, status, additional_headers = flask_response_unpack(resp)
250-
251-
if self.is_app_response(payload):
252-
resp_status, resp_headers = payload.status_code, payload.headers
253-
payload = payload.get_data()
254-
# the inner flask.Response.status_code only takes effect when there is
255-
# no other status code
256-
if status == 200:
257-
status = resp_status
258-
# use the `Header` object to avoid deduplicated by `make_response`
259-
resp_headers.extend(additional_headers)
260-
additional_headers = resp_headers
261-
262-
if not skip_validation and resp_model:
263-
try:
264-
response_validation_result = validate_response(
265-
validation_model=resp_model.find_model(status),
266-
response_payload=payload,
267-
)
268-
except (InternalValidationError, ValidationError) as err:
269-
errors = (
270-
err.errors()
271-
if isinstance(err, InternalValidationError)
272-
else err.errors(include_context=False)
273-
)
274-
response = self.make_response_with_addition(errors, 500)
275-
resp_validation_error = err
276-
else:
277-
response = self.make_response_with_addition(
278-
(
279-
self.get_current_app().response_class(
280-
response_validation_result.payload.data,
281-
mimetype="application/json",
282-
)
283-
if isinstance(
284-
response_validation_result.payload,
285-
SerializedPydanticResponse,
286-
)
287-
else response_validation_result.payload,
288-
status,
289-
additional_headers,
290-
)
291-
)
292-
else:
293-
if is_partial_base_model_instance(payload):
294-
payload = self.get_current_app().response_class(
295-
serialize_model_instance(payload).data,
296-
mimetype="application/json",
297-
)
298-
response = self.make_response_with_addition(
299-
payload, status, additional_headers
300-
)
301-
302-
return response, resp_validation_error
303-
304234
def register_route(self, app):
305235
app.add_url_rule(
306236
rule=self.config.spec_url,

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
from syrupy.filters import paths
44

55

6+
@pytest.fixture
7+
def anyio_backend():
8+
return "asyncio"
9+
10+
611
@pytest.fixture
712
def snapshot_json(snapshot):
813
return snapshot.use_extension(JSONSnapshotExtension)

0 commit comments

Comments
 (0)