From 67d3997afe0e6f78bead16794fda9bdf9c72f88f Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:23:36 +0000 Subject: [PATCH 1/2] flask: async/tuple output mappers keep content_type, several files per form field, max_request_size -> MAX_CONTENT_LENGTH (#44) Co-Authored-By: Claude Sonnet 5.5 --- py2http/decorators.py | 14 ++++-- py2http/default_configs.py | 56 +++++++++++++++++++----- py2http/service.py | 3 ++ py2http/tests/test_flask_requests.py | 65 ++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 14 deletions(-) diff --git a/py2http/decorators.py b/py2http/decorators.py index 698c67c..d758ce0 100644 --- a/py2http/decorators.py +++ b/py2http/decorators.py @@ -1225,10 +1225,13 @@ def _request_body(request) -> bytes: def _request_files(request) -> dict: - """``{field name: bytes}`` of the uploaded files of a Bottle or Flask request.""" + """``{field name: bytes}`` of the uploaded files of a Bottle or Flask request. A field + with several files gives the ``list`` of their bytes, in upload order.""" if hasattr(request, "get_data"): # Flask: werkzeug FileStorage - return {k: v.read() for k, v in request.files.items()} - return {k: v.file.read() for k, v in request.files.items()} + groups = {k: [f.read() for f in request.files.getlist(k)] for k in request.files} + else: + groups = {k: [f.file.read() for f in request.files.getall(k)] for k in request.files} + return {k: v[0] if len(v) == 1 else v for k, v in groups.items()} def _loads_mapping(data: bytes, what: str) -> dict: @@ -1275,7 +1278,10 @@ def _get_inputs_from_request(request, content_type, *, binary_loads=None): binaries = _request_files(request) if "__fields" not in binaries: raise InputError("The form request has no '__fields' part.") - fields = _loads_mapping(binaries.pop("__fields"), "'__fields' part") + fields_part = binaries.pop("__fields") + if isinstance(fields_part, list): + raise InputError("The form request has several '__fields' parts; expected one.") + fields = _loads_mapping(fields_part, "'__fields' part") inputs = dict(fields, **binaries) return dict(defaults, **inputs) else: diff --git a/py2http/default_configs.py b/py2http/default_configs.py index 52369ee..1c45132 100644 --- a/py2http/default_configs.py +++ b/py2http/default_configs.py @@ -14,6 +14,7 @@ from aiohttp import web from bottle import response +import inspect import json import logging import os @@ -67,13 +68,34 @@ def flask_json_output_mapper(output, **inputs): flask_json_output_mapper.content_type = JSON_CONTENT_TYPE +def _flask_response(result, content_type): + """``result`` as a Flask response of ``content_type`` if it is a ``str``/``bytes`` body, or + a ``(body, status[, headers])`` tuple of one; anything else (a ``Response``, a dict) is kept.""" + from flask import Response + from werkzeug.datastructures import Headers + + if isinstance(result, (str, bytes)): + return Response(result, content_type=content_type) + if isinstance(result, tuple) and result and isinstance(result[0], (str, bytes)): + body, *rest = result + status = rest[0] if rest and isinstance(rest[0], (int, str)) else None + headers = next((r for r in rest if isinstance(r, (dict, list))), None) + response = Response(body, status=status, headers=headers) + if not any(k.lower() == "content-type" for k, _ in Headers(headers or {}).items()): + response.content_type = content_type + return response + return result + + def flask_response_mapper(output_mapper): """Make a Flask view's response of what an output mapper returns. The ``send_*_resp`` decorators set Bottle's ``response.content_type`` and return the body (``str`` or ``bytes``); Flask ignores that and would answer ``text/html``. - A mapper that declares a ``content_type`` and returns a ``str``/``bytes`` body gets - that content type; anything else (a ``Response``, a dict, a tuple) is kept as is. + A mapper that declares a ``content_type`` and returns a ``str``/``bytes`` body, or a + ``(body, status[, headers])`` tuple of one, gets that content type (unless the tuple's + headers set their own); an ``async`` mapper is awaited first. Anything else (a + ``Response``, a dict) is kept as is. >>> from py2http.decorators import send_raw_resp # (needs flask) >>> flask_response_mapper(send_raw_resp(lambda o: o))(b"x").content_type # doctest: +SKIP @@ -83,14 +105,24 @@ def flask_response_mapper(output_mapper): if content_type is None: return output_mapper - @wraps(output_mapper) - def mapper(output, **inputs): - result = output_mapper(output, **inputs) - if isinstance(result, (str, bytes)): - from flask import Response + if inspect.iscoroutinefunction(output_mapper): + + @wraps(output_mapper) + async def mapper(output, **inputs): + return _flask_response(await output_mapper(output, **inputs), content_type) + + else: + + @wraps(output_mapper) + def mapper(output, **inputs): + result = output_mapper(output, **inputs) + if inspect.isawaitable(result): # a sync wrapper around an async mapper + + async def settle(): + return _flask_response(await result, content_type) - return Response(result, content_type=content_type) - return result + return settle() + return _flask_response(result, content_type) return mapper @@ -195,6 +227,9 @@ def flask_error_handler(error: Exception): the bottle handler (``OPAQUE_ERRORS`` hides the text of a 500).""" message = str(error) status = error_status(error) + code = getattr(error, "code", None) # werkzeug's own 4xx, e.g. 413 past MAX_CONTENT_LENGTH + if status == 500 and isinstance(code, int) and 400 <= code < 500 and hasattr(error, "get_response"): + status, message = code, getattr(error, "description", None) or message if status == 500 and os.getenv("OPAQUE_ERRORS", None): message = "Internal server error" status_line = f"400 {type(error).__name__}" if status == 400 else status @@ -255,7 +290,8 @@ def framework_defaults(framework, *, error_handler, output_mapper): "logger": None, "plugins": [], "enable_cors": False, - # Bottle only: max bytes of an in-memory request body (None keeps bottle's 100 KiB). + # Max bytes of a request body. Bottle: the in-memory limit (None keeps bottle's 100 KiB). + # Flask: sets MAX_CONTENT_LENGTH (None keeps Flask's default: no limit). # A bigger request is answered "413 Request entity too large". "max_request_size": None, "cors_allowed_origins": "*", diff --git a/py2http/service.py b/py2http/service.py index 46ed94f..efa60d6 100644 --- a/py2http/service.py +++ b/py2http/service.py @@ -332,6 +332,9 @@ def mk_flask_app(funcs, **configs): plugins = check_flask_plugins(get_config("plugins")) routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs) app = Flask(get_config("app_name")) + max_request_size = get_config("max_request_size") + if max_request_size is not None: + app.config["MAX_CONTENT_LENGTH"] = int(max_request_size) for wrap in get_config("middleware") or (): app.wsgi_app = wrap(app.wsgi_app) enable_cors = get_config("enable_cors") diff --git a/py2http/tests/test_flask_requests.py b/py2http/tests/test_flask_requests.py index 4da6f0d..a0c7ad1 100644 --- a/py2http/tests/test_flask_requests.py +++ b/py2http/tests/test_flask_requests.py @@ -134,3 +134,68 @@ def test_bottle_bad_body_is_400(): def test_json_encodings_and_content_type_case(body, content_type): r = _client(add).post("/add", data=body, content_type=content_type) assert (r.status_code, r.get_json()) == (200, 3) + + +# --- i2mint/py2http#44 ----------------------------------------------------------------- + + +def _mapper_with_content_type(fn, content_type=RAW_CONTENT_TYPE, *, is_async=False): + if is_async: + + async def mapper(output, **inputs): + return fn(output) + + else: + + def mapper(output, **inputs): + return fn(output) + + mapper.content_type = content_type + return mapper + + +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize( + "returned, status, expected", + [("hi", 200, b"hi"), (("hi", 201), 201, b"hi"), ((b"hi", 202, {"X-A": "1"}), 202, b"hi")], +) +def test_output_mapper_content_type_survives_async_and_status_tuples( + is_async, returned, status, expected +): + mapper = _mapper_with_content_type(lambda out: returned, is_async=is_async) + r = _client(add, output_mapper=mapper).post("/add", json={"x": 1}) + assert (r.status_code, r.data) == (status, expected) + assert r.content_type.split(";")[0] == RAW_CONTENT_TYPE + + +def test_a_tuple_with_its_own_content_type_header_keeps_it(): + mapper = _mapper_with_content_type(lambda out: ("hi", 200, {"Content-Type": "text/csv"})) + r = _client(add, output_mapper=mapper).post("/add", json={"x": 1}) + assert r.content_type.split(";")[0] == "text/csv" + + +def sizes(data: bytes): + return [len(d) for d in data] if isinstance(data, list) else len(data) + + +def _post_files(*files): + import io + + mapper = handle_form_req(lambda data: dict(data=data)) + data = {"__fields": (io.BytesIO(b"{}"), "__fields")} + data["data"] = [(io.BytesIO(b), "data") for b in files] if len(files) > 1 else (io.BytesIO(files[0]), "data") + return _client(sizes, input_mapper=mapper).post("/sizes", data=data, content_type="multipart/form-data") + + +def test_several_files_under_one_form_field_are_all_kept(): + assert _post_files(b"a", b"bb", b"ccc").get_json() == [1, 2, 3] + assert _post_files(b"abcd").get_json() == 4 # one file stays a bare bytes + + +def test_max_request_size_sets_flask_max_content_length(): + app = mk_app([add], framework="flask", max_request_size=1000) + assert app.config["MAX_CONTENT_LENGTH"] == 1000 + r = app.test_client().post("/add", data=b"x" * 2000, content_type=JSON_CONTENT_TYPE) + assert r.status_code == 413 + assert app.test_client().post("/add", json={"x": 1}).status_code == 200 + assert mk_app([add], framework="flask").config["MAX_CONTENT_LENGTH"] is None From 383859fee23614903da7e0f597a73c7ece4a80af Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:24:52 +0000 Subject: [PATCH 2/2] flask: keep Headers objects in tuples and werkzeug 4xx headers (review of #47) Co-Authored-By: Claude Sonnet 5.5 --- py2http/default_configs.py | 11 +++++++++-- py2http/tests/test_flask_requests.py | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/py2http/default_configs.py b/py2http/default_configs.py index 1c45132..f80fecb 100644 --- a/py2http/default_configs.py +++ b/py2http/default_configs.py @@ -79,7 +79,7 @@ def _flask_response(result, content_type): if isinstance(result, tuple) and result and isinstance(result[0], (str, bytes)): body, *rest = result status = rest[0] if rest and isinstance(rest[0], (int, str)) else None - headers = next((r for r in rest if isinstance(r, (dict, list))), None) + headers = next((r for r in rest if r is not None and not isinstance(r, (int, str))), None) response = Response(body, status=status, headers=headers) if not any(k.lower() == "content-type" for k, _ in Headers(headers or {}).items()): response.content_type = content_type @@ -233,7 +233,14 @@ def flask_error_handler(error: Exception): if status == 500 and os.getenv("OPAQUE_ERRORS", None): message = "Internal server error" status_line = f"400 {type(error).__name__}" if status == 400 else status - return {"error": message}, status_line, _status_headers(status) + headers = _status_headers(status) + if hasattr(error, "get_response") and status == code: # e.g. the ``Allow`` of a 405 + headers.update( + (k, v) + for k, v in error.get_response().headers + if k.lower() not in ("content-type", "content-length") + ) + return {"error": message}, status_line, headers def default_error_handler(error: Exception): diff --git a/py2http/tests/test_flask_requests.py b/py2http/tests/test_flask_requests.py index a0c7ad1..448e770 100644 --- a/py2http/tests/test_flask_requests.py +++ b/py2http/tests/test_flask_requests.py @@ -199,3 +199,19 @@ def test_max_request_size_sets_flask_max_content_length(): assert r.status_code == 413 assert app.test_client().post("/add", json={"x": 1}).status_code == 200 assert mk_app([add], framework="flask").config["MAX_CONTENT_LENGTH"] is None + + +def test_a_tuple_with_werkzeug_headers_keeps_them(): + from werkzeug.datastructures import Headers + + mapper = _mapper_with_content_type(lambda out: ("hi", 201, Headers([("X-A", "1")]))) + r = _client(add, output_mapper=mapper).post("/add", json={"x": 1}) + assert (r.status_code, r.headers["X-A"]) == (201, "1") + + +def test_a_werkzeug_4xx_keeps_the_headers_it_carries(): + from werkzeug.exceptions import MethodNotAllowed + from py2http.default_configs import flask_error_handler + + _, status, headers = flask_error_handler(MethodNotAllowed(["POST"])) + assert status == 405 and headers["Allow"] == "POST"