From bfec1f9d59748e85aaf8fcf120d07bee0e80511e Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:38:21 +0000 Subject: [PATCH 1/2] feat: support framework='flask' (mk_app, mk_flask_app, run_app) _get_framework accepts bottle and flask; the default error handler and output mapper are chosen per framework instead of by a process-wide env var; mk_flask_app and mk_bottle_app pin their own framework; run_app can run a Flask app. Closes #39 Co-Authored-By: Claude Sonnet 5.5 --- py2http/default_configs.py | 37 ++++++++++++++++++++ py2http/service.py | 30 ++++++++++++---- py2http/tests/test_error_statuses.py | 51 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/py2http/default_configs.py b/py2http/default_configs.py index baab228..2f0635d 100644 --- a/py2http/default_configs.py +++ b/py2http/default_configs.py @@ -54,6 +54,18 @@ def flask_output_mapper(output, **inputs): return output +def flask_json_output_mapper(output, **inputs): + """The default output mapper of a Flask app: the output as a JSON response.""" + from flask import Response + + return Response( + json.dumps(output, cls=JsonRespEncoder), content_type=JSON_CONTENT_TYPE + ) + + +flask_json_output_mapper.content_type = JSON_CONTENT_TYPE + + def bottle_output_mapper(output, **inputs): response.content_type = JSON_CONTENT_TYPE return json.dumps(output, cls=JsonRespEncoder) @@ -171,6 +183,31 @@ def default_error_handler(error: Exception): return bottle_error_handler(error) +# What the default mappers become under each framework (a mapper a user gave is kept) +_DEFAULT_ERROR_HANDLERS = { + AIOHTTP: aiohttp_error_handler, + BOTTLE: bottle_error_handler, + FLASK: flask_error_handler, +} +_DEFAULT_OUTPUT_MAPPERS = {FLASK: flask_json_output_mapper} + + +def framework_defaults(framework, *, error_handler, output_mapper): + """The error handler and output mapper to use under ``framework``: the given ones, + except that the defaults (which only know of Bottle and AIOHTTP) are swapped for + the framework's own, so that no process-wide state decides how a response is made. + + >>> framework_defaults(FLASK, error_handler=default_error_handler, + ... output_mapper=default_output_mapper)[0] is flask_error_handler + True + """ + if error_handler is default_error_handler: + error_handler = _DEFAULT_ERROR_HANDLERS.get(framework, error_handler) + if output_mapper is default_output_mapper: + output_mapper = _DEFAULT_OUTPUT_MAPPERS.get(framework, output_mapper) + return error_handler, output_mapper + + default_configs = { "app_name": "HTTP Service", "framework": BOTTLE, diff --git a/py2http/service.py b/py2http/service.py index 56ecf7b..b63b7ff 100644 --- a/py2http/service.py +++ b/py2http/service.py @@ -38,6 +38,7 @@ default_configs, DFLT_CONTENT_TYPE, default_input_mapper, + framework_defaults, ) from py2http.openapi_utils import ( add_paths_to_spec, @@ -127,8 +128,11 @@ def get_input_args_and_kwargs(inputs): ) framework = _get_framework(configs, default_configs) input_mapper = config_for("input_mapper") - output_mapper = config_for("output_mapper") - error_handler = config_for("error_handler") + error_handler, output_mapper = framework_defaults( + framework, + error_handler=config_for("error_handler"), + output_mapper=config_for("output_mapper"), + ) header_inputs = config_for("header_inputs", type=dict) logger = config_for("logger") @@ -310,6 +314,7 @@ def mk_routes_and_openapi_specs(funcs, **configs): def mk_flask_app(funcs, **configs): from flask import Flask + configs = dict(configs, framework=FLASK) # these are Flask routes whatever the default routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs) app_name = mk_config("app_name", None, configs, default_configs) app = Flask(app_name) @@ -333,6 +338,7 @@ def mk_flask_app(funcs, **configs): def mk_bottle_app(funcs, **configs): from bottle import Bottle + configs = dict(configs, framework=BOTTLE) routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs) app = Bottle(catchall=False) enable_cors = mk_config("enable_cors", None, configs, default_configs) @@ -524,7 +530,9 @@ def get_web_framework_objects(): elif framework == AIOHTTP: app = web.Application() return app, app.add_subapp - return None + raise NotImplementedError( + f"A multi-api app is not supported for the framework {framework!r}" + ) parent_app, add_subapp_meth = get_web_framework_objects() for route, route_spec in app_spec.items(): @@ -559,6 +567,12 @@ def get_web_framework_objects(): return mk_single_api_app() +def run_flask(app, *, host, port, certfile=None, keyfile=None, **_): + """Run a Flask app with Flask's own server (``server`` does not apply to it).""" + ssl_context = (certfile, keyfile) if certfile and keyfile else None + app.run(host=host, port=port, ssl_context=ssl_context) + + @Sig.add_optional_keywords(default_configs) def run_app(app_obj: Union[AppSpec, Any], **configs): """ @@ -582,6 +596,8 @@ def get_run_func(): framework = _get_framework(configs, default_configs) if framework == BOTTLE: return run_bottle + elif framework == FLASK: + return run_flask elif framework == AIOHTTP: return web.run_app raise NotImplementedError("") @@ -611,11 +627,13 @@ def get_run_func(): ) +_SUPPORTED_FRAMEWORKS = (BOTTLE, FLASK) + + def _get_framework(configs, default_configs): framework = mk_config("framework", None, configs, default_configs) - # NOTE Only support Bottle until we redesign py2http using a reusable tool for routing - # if framework not in (FLASK, BOTTLE, AIOHTTP): - if framework != BOTTLE: + # NOTE Bottle and Flask only, until py2http is redesigned on a reusable routing tool + if framework not in _SUPPORTED_FRAMEWORKS: raise NotImplementedError( f'The Web Framework "{framework}" is not supported by py2http' ) diff --git a/py2http/tests/test_error_statuses.py b/py2http/tests/test_error_statuses.py index ea40d2a..aea8cbc 100644 --- a/py2http/tests/test_error_statuses.py +++ b/py2http/tests/test_error_statuses.py @@ -12,6 +12,7 @@ ) from py2http import mk_app +from py2http.service import mk_flask_app from py2http.default_configs import aiohttp_error_handler, error_status from py2http.tests.utils_for_testing import call_wsgi @@ -101,3 +102,53 @@ def test_flask_opaque_errors_hides_500_text(monkeypatch): "/f", "f", lambda: flask_error_handler(KeyError("internal detail")) ) assert app.test_client().get("/f").get_json() == {"error": "Internal server error"} + + +@pytest.mark.parametrize("error, status", CASES) +def test_flask_app_statuses(error, status, monkeypatch, capsys): + """``mk_app(framework='flask')``: statuses and challenges, end to end.""" + pytest.importorskip("flask") + monkeypatch.delenv("PY2HTTP_WWW_AUTHENTICATE", raising=False) + + def fail(): + raise error + + app = mk_app([fail], framework="flask") + response = app.test_client().post("/fail", json={}) + assert response.status_code == status + assert set(response.get_json()) == {"error"} + if status == 400: + assert response.status == f"400 {type(error).__name__}" + if status == 401: + assert response.headers["WWW-Authenticate"] == "Bearer" + else: + assert "WWW-Authenticate" not in response.headers + + +def test_flask_app_answers_json(): + pytest.importorskip("flask") + + def add(a, b: int = 0): + return {"sum": a + b} + + for app in ( + mk_app([add], framework="flask"), + mk_flask_app([add]), # called directly: still flask routes + ): + response = app.test_client().post("/add", json={"a": 1, "b": 2}) + assert response.status_code == 200 + assert response.content_type.startswith("application/json") + assert response.get_json() == {"sum": 3} + + +def test_flask_does_not_leak_into_a_bottle_app(capsys): + pytest.importorskip("flask") + bottle_app = _app_raising(NotFoundError("x")) + mk_app([_app_raising], framework="flask") # sets the process-wide framework + got, _ = call_wsgi(bottle_app, "/fail", {}) + assert got.startswith("404") # still bottle's way of answering + + +def test_unsupported_framework_is_refused(): + with pytest.raises(NotImplementedError): + mk_app([_app_raising], framework="django") From 73393587862987471d0af32e906118d85e56904b Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:40:54 +0000 Subject: [PATCH 2/2] fix: Flask refuses Bottle-only configs, publishes /openapi only when asked, wraps middleware Co-Authored-By: Claude Sonnet 5.5 --- py2http/service.py | 29 +++++++++++++++++------ py2http/tests/test_error_statuses.py | 35 ++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/py2http/service.py b/py2http/service.py index b63b7ff..77a2f7a 100644 --- a/py2http/service.py +++ b/py2http/service.py @@ -311,17 +311,29 @@ def mk_routes_and_openapi_specs(funcs, **configs): AppSpec = Union[Handlers, Dict[str, Union[Handlers, SubAppSpec]]] +#: Configs only the Bottle app honours. A Flask app refuses them rather than ignore +#: them: a silently dropped auth plugin would leave its routes open. +_BOTTLE_ONLY_CONFIGS = ("plugins", "enable_cors", "publish_swagger", "openapi_insecure") + + def mk_flask_app(funcs, **configs): + """A Flask app serving ``funcs``. JSON request bodies only (the raw, binary and + form input mappers read Bottle's request); ``middleware`` is a list of WSGI + wrappers; ``plugins``, ``enable_cors``, ``publish_swagger`` and + ``openapi_insecure`` are Bottle's, and raise ``NotImplementedError`` here.""" from flask import Flask - configs = dict(configs, framework=FLASK) # these are Flask routes whatever the default + unsupported = [k for k in _BOTTLE_ONLY_CONFIGS if configs.get(k)] + if unsupported: + raise NotImplementedError( + f"The config {unsupported} is not supported for the framework 'flask'" + ) + configs = dict(configs, framework=FLASK) # Flask routes whatever the default routes, openapi_spec = mk_routes_and_openapi_specs(funcs, **configs) app_name = mk_config("app_name", None, configs, default_configs) app = Flask(app_name) - middleware = mk_config("middleware", None, configs, default_configs) - # publish_openapi = mk_config('publish_openapi', None, configs, default_configs) - if middleware: - app = middleware(app) + for wrap in mk_config("middleware", None, configs, default_configs) or (): + app.wsgi_app = wrap(app.wsgi_app) for route in routes: app.add_url_rule( route.path, @@ -330,7 +342,8 @@ def mk_flask_app(funcs, **configs): methods=[route.http_method.upper()], ) app.add_url_rule("/ping", "ping", lambda: {"ping": "pong"}) - app.add_url_rule("/openapi", "openapi", lambda: openapi_spec) + if mk_config("publish_openapi", None, configs, default_configs): + app.add_url_rule("/openapi", "openapi", lambda: openapi_spec) app.openapi_spec = openapi_spec return app @@ -569,7 +582,9 @@ def get_web_framework_objects(): def run_flask(app, *, host, port, certfile=None, keyfile=None, **_): """Run a Flask app with Flask's own server (``server`` does not apply to it).""" - ssl_context = (certfile, keyfile) if certfile and keyfile else None + if bool(certfile) != bool(keyfile): + raise ValueError("Give both ssl_certfile and ssl_keyfile, or neither.") + ssl_context = (certfile, keyfile) if certfile else None app.run(host=host, port=port, ssl_context=ssl_context) diff --git a/py2http/tests/test_error_statuses.py b/py2http/tests/test_error_statuses.py index aea8cbc..621e268 100644 --- a/py2http/tests/test_error_statuses.py +++ b/py2http/tests/test_error_statuses.py @@ -152,3 +152,38 @@ def test_flask_does_not_leak_into_a_bottle_app(capsys): def test_unsupported_framework_is_refused(): with pytest.raises(NotImplementedError): mk_app([_app_raising], framework="django") + + +def test_flask_refuses_what_it_would_ignore(): + """An auth plugin dropped silently would leave the routes open.""" + pytest.importorskip("flask") + for config in ({"plugins": [object()]}, {"enable_cors": True}): + with pytest.raises(NotImplementedError): + mk_app([_app_raising], framework="flask", **config) + + +def test_flask_openapi_only_when_published(): + pytest.importorskip("flask") + + def f(): + return 1 + + assert mk_app([f], framework="flask").test_client().get("/openapi").status_code == 404 + published = mk_app([f], framework="flask", publish_openapi=True) + assert published.test_client().get("/openapi").status_code == 200 + + +def test_flask_middleware_wraps_the_wsgi_app(): + pytest.importorskip("flask") + seen = [] + + def wrap(wsgi_app): + def wrapped(environ, start_response): + seen.append(environ["PATH_INFO"]) + return wsgi_app(environ, start_response) + + return wrapped + + app = mk_app([_app_raising], framework="flask", middleware=[wrap]) + app.test_client().post("/_app_raising", json={}) + assert seen == ["/_app_raising"]