Repository: pallets/flask

Issue:
# `provide_automatic_options` is weird

While dealing with trying to detect duplicate routes in https://github.com/pallets/werkzeug/issues/3105, I ran into an issue with Flask, which adds `OPTIONS` to every single rule by default.

I started looking into Flask's `provide_automatic_options` feature, and it's just weird.

- Why is there a `PROVIDE_AUTOMATIC_OPTIONS` config? What benefit is there to turning `OPTIONS` off at all, let alone configuring it for a given deployment.
- Flask adds `OPTIONS` to every route, then has a check that runs on dispatching _every_ request to see if the method was `OPTIONS` and `provide_automatic_options` was enabled for the rule. Performing this check for every request seems wasteful, as opposed to adding an actual rule and endpoint so that the routing that's already happening handles it.
    - But adding a rule is also sort of bad, as it results in a bunch of duplicate rules since Flask has no way to know what other rules have already been added. This can be caught with Werkzeug 3.2, and doesn't cause problems in earlier versions, but it's still annoying.
- `provide_automatic_options` can be set as an attribute on the view function. There are some other attributes that are barely documented as well, such as `required_methods`. This is presumably mostly for `View` classes, but works on functions too, making type annotations unhelpful.
- If `PROVIDE_AUTOMATIC_OPTIONS` is disabled (not the default), the logic for the `provide_automatic_options` argument and attribute doesn't work correctly to _enable_ it, it only works for _disabling_.
- The tests for this were incomplete and had some issues. Some other tests were revealed to be adding duplicate rules as well. 

At a minimum I've fixed the enabling issue. I've also got a change that registers a route and view for options instead of using special dispatch logic. We should also consider deprecating the config.

Implementation patch:
diff --git a/src/flask/sansio/app.py b/src/flask/sansio/app.py
index e069b9087a..a734793e40 100644
--- a/src/flask/sansio/app.py
+++ b/src/flask/sansio/app.py
@@ -627,19 +627,19 @@ def add_url_rule(
         # Methods that should always be added
         required_methods: set[str] = set(getattr(view_func, "required_methods", ()))
 
-        # starting with Flask 0.8 the view_func object can disable and
-        # force-enable the automatic options handling.
         if provide_automatic_options is None:
             provide_automatic_options = getattr(
                 view_func, "provide_automatic_options", None
             )
 
-        if provide_automatic_options is None:
-            if "OPTIONS" not in methods and self.config["PROVIDE_AUTOMATIC_OPTIONS"]:
-                provide_automatic_options = True
-                required_methods.add("OPTIONS")
-            else:
-                provide_automatic_options = False
+            if provide_automatic_options is None:
+                provide_automatic_options = (
+                    "OPTIONS" not in methods
+                    and self.config["PROVIDE_AUTOMATIC_OPTIONS"]
+                )
+
+        if provide_automatic_options:
+            required_methods.add("OPTIONS")
 
         # Add the required methods now.
         methods |= required_methods


Test patch:
diff --git a/tests/test_basic.py b/tests/test_basic.py
index f8a1152911..48365a6b6e 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -20,6 +20,7 @@
 from werkzeug.routing import RequestRedirect
 
 import flask
+from flask.testing import FlaskClient
 
 require_cpython_gc = pytest.mark.skipif(
     python_implementation() != "CPython",
@@ -67,63 +68,61 @@ def test_method_route_no_methods(app):
         app.get("/", methods=["GET", "POST"])
 
 
-def test_provide_automatic_options_attr():
-    app = flask.Flask(__name__)
+def test_provide_automatic_options_attr_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options can be disabled by the view func attribute."""
 
     def index():
         return "Hello World!"
 
     index.provide_automatic_options = False
-    app.route("/")(index)
-    rv = app.test_client().open("/", method="OPTIONS")
+    app.add_url_rule("/", view_func=index)
+    rv = client.options()
     assert rv.status_code == 405
 
-    app = flask.Flask(__name__)
 
-    def index2():
+def test_provide_automatic_options_attr_enable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """When default automatic options is disabled in config, it can still be
+    enabled by the view function attribute.
+    """
+    app.config["PROVIDE_AUTOMATIC_OPTIONS"] = False
+
+    def index():
         return "Hello World!"
 
-    index2.provide_automatic_options = True
-    app.route("/", methods=["OPTIONS"])(index2)
-    rv = app.test_client().open("/", method="OPTIONS")
-    assert sorted(rv.allow) == ["OPTIONS"]
+    index.provide_automatic_options = True
+    app.add_url_rule("/", view_func=index)
+    rv = client.options()
+    assert rv.allow == {"GET", "HEAD", "OPTIONS"}
 
 
-def test_provide_automatic_options_kwarg(app, client):
-    def index():
-        return flask.request.method
-
-    def more():
-        return flask.request.method
+def test_provide_automatic_options_arg_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options can be disabled by the route argument."""
 
-    app.add_url_rule("/", view_func=index, provide_automatic_options=False)
-    app.add_url_rule(
-        "/more",
-        view_func=more,
-        methods=["GET", "POST"],
-        provide_automatic_options=False,
-    )
-    assert client.get("/").data == b"GET"
+    @app.get("/", provide_automatic_options=False)
+    def index():
+        return "Hello World!"
 
-    rv = client.post("/")
+    rv = client.options()
     assert rv.status_code == 405
-    assert sorted(rv.allow) == ["GET", "HEAD"]
 
-    rv = client.open("/", method="OPTIONS")
-    assert rv.status_code == 405
 
-    rv = client.head("/")
-    assert rv.status_code == 200
-    assert not rv.data  # head truncates
-    assert client.post("/more").data == b"POST"
-    assert client.get("/more").data == b"GET"
+def test_provide_automatic_options_method_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options is ignored if the route handles options."""
 
-    rv = client.delete("/more")
-    assert rv.status_code == 405
-    assert sorted(rv.allow) == ["GET", "HEAD", "POST"]
+    @app.route("/", methods=["OPTIONS"])
+    def index():
+        return "", {"X-Test": "test"}
 
-    rv = client.open("/more", method="OPTIONS")
-    assert rv.status_code == 405
+    rv = client.options()
+    assert rv.headers["X-Test"] == "test"
 
 
 def test_request_dispatching(app, client):
diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py
index ed1683c470..001e1981c7 100644
--- a/tests/test_blueprints.py
+++ b/tests/test_blueprints.py
@@ -220,28 +220,19 @@ def test_templates_and_static(test_apps):
         assert flask.render_template("nested/nested.txt") == "I'm nested"
 
 
-def test_default_static_max_age(app):
+def test_default_static_max_age(app: flask.Flask) -> None:
     class MyBlueprint(flask.Blueprint):
         def get_send_file_max_age(self, filename):
             return 100
 
-    blueprint = MyBlueprint("blueprint", __name__, static_folder="static")
+    blueprint = MyBlueprint(
+        "blueprint", __name__, url_prefix="/bp", static_folder="static"
+    )
     app.register_blueprint(blueprint)
 
-    # try/finally, in case other tests use this app for Blueprint tests.
-    max_age_default = app.config["SEND_FILE_MAX_AGE_DEFAULT"]
-    try:
-        with app.test_request_context():
-            unexpected_max_age = 3600
-            if app.config["SEND_FILE_MAX_AGE_DEFAULT"] == unexpected_max_age:
-                unexpected_max_age = 7200
-            app.config["SEND_FILE_MAX_AGE_DEFAULT"] = unexpected_max_age
-            rv = blueprint.send_static_file("index.html")
-            cc = parse_cache_control_header(rv.headers["Cache-Control"])
-            assert cc.max_age == 100
-            rv.close()
-    finally:
-        app.config["SEND_FILE_MAX_AGE_DEFAULT"] = max_age_default
+    with app.test_request_context(), blueprint.send_static_file("index.html") as rv:
+        cc = parse_cache_control_header(rv.headers["Cache-Control"])
+        assert cc.max_age == 100
 
 
 def test_templates_list(test_apps):
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a5aab5013d..8f8f25cf01 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -483,12 +483,18 @@ def test_sort(self, app, invoke):
             ["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
 
diff --git a/tests/test_views.py b/tests/test_views.py
index eab5eda286..d002b50439 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -2,6 +2,7 @@
 from werkzeug.http import parse_set_header
 
 import flask.views
+from flask.testing import FlaskClient
 
 
 def common_test(app):
@@ -98,44 +99,55 @@ def dispatch_request(self):
     assert rv.data == b"Awesome"
 
 
-def test_view_provide_automatic_options_attr():
-    app = flask.Flask(__name__)
+def test_view_provide_automatic_options_attr_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options can be disabled by the view class attribute."""
 
-    class Index1(flask.views.View):
+    class Index(flask.views.View):
         provide_automatic_options = False
 
         def dispatch_request(self):
             return "Hello World!"
 
-    app.add_url_rule("/", view_func=Index1.as_view("index"))
-    c = app.test_client()
-    rv = c.open("/", method="OPTIONS")
+    app.add_url_rule("/", view_func=Index.as_view("index"))
+    rv = client.options()
     assert rv.status_code == 405
 
-    app = flask.Flask(__name__)
 
-    class Index2(flask.views.View):
-        methods = ["OPTIONS"]
+def test_view_provide_automatic_options_attr_enable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """When default automatic options is disabled in config, it can still be
+    enabled by the view class attribute.
+    """
+    app.config["PROVIDE_AUTOMATIC_OPTIONS"] = False
+
+    class Index(flask.views.View):
         provide_automatic_options = True
 
         def dispatch_request(self):
             return "Hello World!"
 
-    app.add_url_rule("/", view_func=Index2.as_view("index"))
-    c = app.test_client()
-    rv = c.open("/", method="OPTIONS")
-    assert sorted(rv.allow) == ["OPTIONS"]
+    app.add_url_rule("/", view_func=Index.as_view("index"))
+    rv = client.options("/")
+    assert rv.allow == {"GET", "HEAD", "OPTIONS"}
 
-    app = flask.Flask(__name__)
 
-    class Index3(flask.views.View):
+def test_provide_automatic_options_method_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options is ignored if the route handles options."""
+
+    class Index(flask.views.View):
+        methods = ["OPTIONS"]
+
         def dispatch_request(self):
-            return "Hello World!"
+            return "", {"X-Test": "test"}
 
-    app.add_url_rule("/", view_func=Index3.as_view("index"))
-    c = app.test_client()
-    rv = c.open("/", method="OPTIONS")
-    assert "OPTIONS" in rv.allow
+    app.add_url_rule("/", view_func=Index.as_view("index"))
+    rv = client.options()
+    assert rv.headers["X-Test"] == "test"
 
 
 def test_implicit_head(app, client):
@@ -180,7 +192,7 @@ def dispatch_request(self):
     app.add_url_rule("/", view_func=Index.as_view("index"))
 
     with pytest.raises(AssertionError):
-        app.add_url_rule("/", view_func=Index.as_view("index"))
+        app.add_url_rule("/other", view_func=Index.as_view("index"))
 
     # But these tests should still pass. We just log a warning.
     common_test(app)