Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
David Lord
80669a0ea8
automatic options as separate route 2026-05-01 21:06:39 -07:00
5 changed files with 56 additions and 30 deletions

View file

@ -5,7 +5,6 @@ import inspect
import os
import sys
import typing as t
import weakref
from datetime import timedelta
from functools import update_wrapper
from inspect import iscoroutinefunction
@ -352,16 +351,15 @@ class Flask(App):
assert bool(static_host) == host_matching, (
"Invalid static_host/host_matching combination"
)
# Use a weakref to avoid creating a reference cycle between the app
# and the view function (see #3761).
self_ref = weakref.ref(self)
self.add_url_rule(
f"{self.static_url_path}/<path:filename>",
endpoint="static",
host=static_host,
view_func=lambda **kw: self_ref().send_static_file(**kw), # type: ignore
view_func=_static_view,
)
self.view_functions["_automatic_options"] = _options_view
def get_send_file_max_age(self, filename: str | None) -> int | None:
"""Used by :func:`send_file` to determine the ``max_age`` cache
value for a given file path if it wasn't passed.
@ -978,14 +976,6 @@ class Flask(App):
if req.routing_exception is not None:
self.raise_routing_exception(req)
rule: Rule = req.url_rule # type: ignore[assignment]
# if we provide automatic options for this URL and the
# request came with the OPTIONS method, reply automatically
if (
getattr(rule, "provide_automatic_options", False)
and req.method == "OPTIONS"
):
return self.make_default_options_response(ctx)
# otherwise dispatch to the handler for that endpoint
view_args: dict[str, t.Any] = req.view_args # type: ignore[assignment]
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
@ -1623,3 +1613,12 @@ class Flask(App):
wrapped to apply middleware.
"""
return self.wsgi_app(environ, start_response)
def _static_view(filename: str) -> Response:
return app_ctx.app.send_static_file(filename)
def _options_view(**_: t.Any) -> Response:
ctx = app_ctx._get_current_object()
return ctx.app.make_default_options_response(ctx)

View file

@ -1072,10 +1072,15 @@ def routes_command(sort: str, all_methods: bool) -> None:
rows = []
for rule in rules:
row = [
rule.endpoint,
", ".join(sorted((rule.methods or set()) - ignored_methods)),
]
if rule.endpoint == "_automatic_options":
continue
methods = rule.methods or set()
if getattr(rule, "provide_automatic_options", False):
methods.add("OPTIONS")
row = [rule.endpoint, ", ".join(sorted(methods - ignored_methods))]
if has_domain:
row.append((rule.host if host_matching else rule.subdomain) or "")

View file

@ -612,7 +612,7 @@ class App(Scaffold):
) -> None:
if endpoint is None:
endpoint = _endpoint_from_view_func(view_func) # type: ignore
options["endpoint"] = endpoint
methods = options.pop("methods", None)
# if the methods are not given and the view_func object knows its
@ -620,15 +620,16 @@ class App(Scaffold):
# a tuple of only ``GET`` as default.
if methods is None:
methods = getattr(view_func, "methods", None) or ("GET",)
if isinstance(methods, str):
raise TypeError(
"Allowed methods must be a list of strings, for"
' example: @app.route(..., methods=["POST"])'
)
methods = {item.upper() for item in methods}
methods = {item.upper() for item in methods}
# Methods that should always be added
required_methods: set[str] = set(getattr(view_func, "required_methods", ()))
methods |= set(getattr(view_func, "required_methods", ()))
if provide_automatic_options is None:
provide_automatic_options = getattr(
@ -641,16 +642,12 @@ class App(Scaffold):
and self.config["PROVIDE_AUTOMATIC_OPTIONS"]
)
if provide_automatic_options:
required_methods.add("OPTIONS")
# Add the required methods now.
methods |= required_methods
rule_obj = self.url_rule_class(rule, methods=methods, **options)
rule_obj = self.url_rule_class(
rule, methods=methods, endpoint=endpoint, **options
)
rule_obj.provide_automatic_options = provide_automatic_options # type: ignore[attr-defined]
self.url_map.add(rule_obj)
if view_func is not None:
old_func = self.view_functions.get(endpoint)
if old_func is not None and old_func != view_func:
@ -660,6 +657,19 @@ class App(Scaffold):
)
self.view_functions[endpoint] = view_func
if provide_automatic_options:
try:
self.url_map.add(
self.url_rule_class(
rule,
methods={"OPTIONS"},
endpoint="_automatic_options",
**options,
)
)
except Exception:
pass
@t.overload
def template_filter(self, name: T_template_filter) -> T_template_filter: ...
@t.overload

View file

@ -52,6 +52,13 @@ def test_options_on_multiple_rules(app, client):
assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS", "POST", "PUT"]
def test_options_view_args(app: flask.Flask, client: FlaskClient) -> None:
"""The automatic options view accepts any view args."""
app.add_url_rule("/<a>/<b>", endpoint="add")
rv = client.options("/1/2")
assert rv.allow == {"GET", "HEAD", "OPTIONS"}
@pytest.mark.parametrize("method", ["get", "post", "put", "delete", "patch"])
def test_method_route(app, client, method):
method_route = getattr(app, method)

View file

@ -483,12 +483,17 @@ class TestRoutes:
["yyy_get_post", "static", "aaa_post"],
invoke(["routes", "-s", "rule"]).output,
)
match_order = [r.endpoint for r in app.url_map.iter_rules()]
match_order = [
r.endpoint
for r in app.url_map.iter_rules()
if r.endpoint != "_automatic_options"
]
self.expect_order(match_order, invoke(["routes", "-s", "match"]).output)
def test_all_methods(self, invoke):
output = invoke(["routes"]).output
assert "GET, HEAD, OPTIONS, POST" not in output
assert "HEAD" not in output
assert "OPTIONS" not in output
output = invoke(["routes", "--all-methods"]).output
assert "GET, HEAD, OPTIONS, POST" in output