diff --git a/CHANGES.rst b/CHANGES.rst index f4e0b5d0..5717e48d 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,11 +13,14 @@ Version 2.0.2 Unreleased -- Fix type annotation for ``teardown_request``. :issue:`4093` +- Fix type annotation for ``teardown_*`` methods. :issue:`4093` - Fix type annotation for ``before_request`` and ``before_app_request`` decorators. :issue:`4104` - Fixed the issue where typing requires template global decorators to accept functions with no arguments. :issue:`4098` +- Support View and MethodView instances with async handlers. :issue:`4112` +- Enhance typing of ``app.errorhandler`` decorator. :issue:`4095` +- Fix registering a blueprint twice with differing names. :issue:`4124` Version 2.0.1 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 3a9177a4..a3c8b851 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -12,14 +12,16 @@ to address bugs and feature requests in Flask itself. Use one of the following resources for questions about using Flask or issues with your own code: -- The ``#get-help`` channel on our Discord chat: +- The ``#questions`` channel on our Discord chat: https://discord.gg/pallets - The mailing list flask@python.org for long term discussion or larger issues. - Ask on `Stack Overflow`_. Search with Google first using: ``site:stackoverflow.com flask {search term, exception message, etc.}`` +- Ask on our `GitHub Discussions`_. .. _Stack Overflow: https://stackoverflow.com/questions/tagged/flask?tab=Frequent +.. _GitHub Discussions: https://github.com/pallets/flask/discussions Reporting issues @@ -92,7 +94,7 @@ First time setup .. code-block:: text - git remote add fork https://github.com/{username}/flask + $ git remote add fork https://github.com/{username}/flask - Create a virtualenv. diff --git a/docs/async-await.rst b/docs/async-await.rst index 34751d47..71e5f452 100644 --- a/docs/async-await.rst +++ b/docs/async-await.rst @@ -7,7 +7,8 @@ Using ``async`` and ``await`` Routes, error handlers, before request, after request, and teardown functions can all be coroutine functions if Flask is installed with the -``async`` extra (``pip install flask[async]``). This allows views to be +``async`` extra (``pip install flask[async]``). It requires Python 3.7+ +where ``contextvars.ContextVar`` is available. This allows views to be defined with ``async def`` and use ``await``. .. code-block:: python @@ -17,6 +18,12 @@ defined with ``async def`` and use ``await``. data = await async_db_query(...) return jsonify(data) +Pluggable class-based views also support handlers that are implemented as +coroutines. This applies to the :meth:`~flask.views.View.dispatch_request` +method in views that inherit from the :class:`flask.views.View` class, as +well as all the HTTP method handlers in views that inherit from the +:class:`flask.views.MethodView` class. + .. admonition:: Using ``async`` on Windows on Python 3.8 Python 3.8 has a bug related to asyncio on Windows. If you encounter diff --git a/docs/installation.rst b/docs/installation.rst index aef7df0c..a5d105f7 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -8,6 +8,8 @@ Python Version We recommend using the latest version of Python. Flask supports Python 3.6 and newer. +``async`` support in Flask requires Python 3.7+ for ``contextvars.ContextVar``. + Dependencies ------------ diff --git a/docs/patterns/celery.rst b/docs/patterns/celery.rst index e1f6847e..38a9a025 100644 --- a/docs/patterns/celery.rst +++ b/docs/patterns/celery.rst @@ -64,7 +64,7 @@ An example task Let's write a task that adds two numbers together and returns the result. We configure Celery's broker and backend to use Redis, create a ``celery`` -application using the factor from above, and then use it to define the task. :: +application using the factory from above, and then use it to define the task. :: from flask import Flask diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 5cd59f41..b5071ab0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -444,9 +444,9 @@ Here is an example template:

Hello, World!

{% endif %} -Inside templates you also have access to the :class:`~flask.request`, -:class:`~flask.session` and :class:`~flask.g` [#]_ objects -as well as the :func:`~flask.get_flashed_messages` function. +Inside templates you also have access to the :data:`~flask.Flask.config`, +:class:`~flask.request`, :class:`~flask.session` and :class:`~flask.g` [#]_ objects +as well as the :func:`~flask.url_for` and :func:`~flask.get_flashed_messages` functions. Templates are especially useful if inheritance is used. If you want to know how that works, see :doc:`patterns/templateinheritance`. Basically diff --git a/docs/templating.rst b/docs/templating.rst index b0964df8..dcc757c3 100644 --- a/docs/templating.rst +++ b/docs/templating.rst @@ -37,7 +37,7 @@ by default: .. data:: config :noindex: - The current configuration object (:data:`flask.config`) + The current configuration object (:data:`flask.Flask.config`) .. versionadded:: 0.6 diff --git a/docs/testing.rst b/docs/testing.rst index 2fedc600..061d46d2 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -48,20 +48,21 @@ the application for testing and initializes a new database:: import pytest from flaskr import create_app + from flaskr.db import init_db @pytest.fixture def client(): - db_fd, flaskr.app.config['DATABASE'] = tempfile.mkstemp() - flaskr.app.config['TESTING'] = True + db_fd, db_path = tempfile.mkstemp() + app = create_app({'TESTING': True, 'DATABASE': db_path}) - with flaskr.app.test_client() as client: - with flaskr.app.app_context(): - flaskr.init_db() + with app.test_client() as client: + with app.app_context(): + init_db() yield client os.close(db_fd) - os.unlink(flaskr.app.config['DATABASE']) + os.unlink(db_path) This client fixture will be called by each individual test. It gives us a simple interface to the application, where we can trigger test requests to the @@ -224,13 +225,13 @@ temporarily. With this you can access the :class:`~flask.request`, :class:`~flask.g` and :class:`~flask.session` objects like in view functions. Here is a full example that demonstrates this approach:: - import flask + from flask import Flask, request - app = flask.Flask(__name__) + app = Flask(__name__) with app.test_request_context('/?name=Peter'): - assert flask.request.path == '/' - assert flask.request.args['name'] == 'Peter' + assert request.path == '/' + assert request.args['name'] == 'Peter' All the other objects that are context bound can be used in the same way. @@ -247,7 +248,7 @@ the test request context leaves the ``with`` block. If you do want the :meth:`~flask.Flask.before_request` functions to be called as well, you need to call :meth:`~flask.Flask.preprocess_request` yourself:: - app = flask.Flask(__name__) + app = Flask(__name__) with app.test_request_context('/?name=Peter'): app.preprocess_request() @@ -260,7 +261,7 @@ If you want to call the :meth:`~flask.Flask.after_request` functions you need to call into :meth:`~flask.Flask.process_response` which however requires that you pass it a response object:: - app = flask.Flask(__name__) + app = Flask(__name__) with app.test_request_context('/?name=Peter'): resp = Response('...') @@ -329,7 +330,7 @@ context around for a little longer so that additional introspection can happen. With Flask 0.4 this is possible by using the :meth:`~flask.Flask.test_client` with a ``with`` block:: - app = flask.Flask(__name__) + app = Flask(__name__) with app.test_client() as c: rv = c.get('/?tequila=42') @@ -353,7 +354,7 @@ keep the context around and access :data:`flask.session`:: with app.test_client() as c: rv = c.get('/') - assert flask.session['foo'] == 42 + assert session['foo'] == 42 This however does not make it possible to also modify the session or to access the session before a request was fired. Starting with Flask 0.8 we diff --git a/src/flask/app.py b/src/flask/app.py index cacb40a5..22cc9abc 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -61,7 +61,6 @@ from .templating import Environment from .typing import AfterRequestCallable from .typing import BeforeFirstRequestCallable from .typing import BeforeRequestCallable -from .typing import ErrorHandlerCallable from .typing import ResponseReturnValue from .typing import TeardownCallable from .typing import TemplateContextProcessorCallable @@ -78,6 +77,7 @@ if t.TYPE_CHECKING: from .blueprints import Blueprint from .testing import FlaskClient from .testing import FlaskCliRunner + from .typing import ErrorHandlerCallable if sys.version_info >= (3, 8): iscoroutinefunction = inspect.iscoroutinefunction @@ -1268,7 +1268,9 @@ class Flask(Scaffold): self.shell_context_processors.append(f) return f - def _find_error_handler(self, e: Exception) -> t.Optional[ErrorHandlerCallable]: + def _find_error_handler( + self, e: Exception + ) -> t.Optional["ErrorHandlerCallable[Exception]"]: """Return a registered error handler for an exception in this order: blueprint handler for a specific code, app handler for a specific code, blueprint handler for an exception class, app handler for an exception @@ -1276,7 +1278,7 @@ class Flask(Scaffold): """ exc_class, code = self._get_exc_class_and_code(type(e)) - for c in [code, None]: + for c in [code, None] if code is not None else [None]: for name in chain(request.blueprints, [None]): handler_map = self.error_handler_spec[name][c] diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 883fc2ff..c8ce67a4 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -8,7 +8,6 @@ from .scaffold import Scaffold from .typing import AfterRequestCallable from .typing import BeforeFirstRequestCallable from .typing import BeforeRequestCallable -from .typing import ErrorHandlerCallable from .typing import TeardownCallable from .typing import TemplateContextProcessorCallable from .typing import TemplateFilterCallable @@ -19,6 +18,7 @@ from .typing import URLValuePreprocessorCallable if t.TYPE_CHECKING: from .app import Flask + from .typing import ErrorHandlerCallable DeferredSetupFunction = t.Callable[["BlueprintSetupState"], t.Callable] @@ -293,7 +293,6 @@ class Blueprint(Scaffold): Registering the same blueprint with the same name multiple times is deprecated and will become an error in Flask 2.1. """ - first_registration = not any(bp is self for bp in app.blueprints.values()) name_prefix = options.get("name_prefix", "") self_name = options.get("name", self.name) name = f"{name_prefix}.{self_name}".lstrip(".") @@ -318,9 +317,12 @@ class Blueprint(Scaffold): stacklevel=4, ) + first_bp_registration = not any(bp is self for bp in app.blueprints.values()) + first_name_registration = name not in app.blueprints + app.blueprints[name] = self self._got_registered_once = True - state = self.make_setup_state(app, options, first_registration) + state = self.make_setup_state(app, options, first_bp_registration) if self.has_static_folder: state.add_url_rule( @@ -330,7 +332,7 @@ class Blueprint(Scaffold): ) # Merge blueprint data into parent. - if first_registration: + if first_bp_registration or first_name_registration: def extend(bp_dict, parent_dict): for key, values in bp_dict.items(): @@ -581,7 +583,9 @@ class Blueprint(Scaffold): handler is used for all requests, even if outside of the blueprint. """ - def decorator(f: ErrorHandlerCallable) -> ErrorHandlerCallable: + def decorator( + f: "ErrorHandlerCallable[Exception]", + ) -> "ErrorHandlerCallable[Exception]": self.record_once(lambda s: s.app.errorhandler(code)(f)) return f diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index 239bc46a..e80e1915 100644 --- a/src/flask/scaffold.py +++ b/src/flask/scaffold.py @@ -21,7 +21,7 @@ from .templating import _default_template_ctx_processor from .typing import AfterRequestCallable from .typing import AppOrBlueprintKey from .typing import BeforeRequestCallable -from .typing import ErrorHandlerCallable +from .typing import GenericException from .typing import TeardownCallable from .typing import TemplateContextProcessorCallable from .typing import URLDefaultCallable @@ -29,6 +29,7 @@ from .typing import URLValuePreprocessorCallable if t.TYPE_CHECKING: from .wrappers import Response + from .typing import ErrorHandlerCallable # a singleton sentinel value for parameter defaults _sentinel = object() @@ -144,7 +145,10 @@ class Scaffold: #: directly and its format may change at any time. self.error_handler_spec: t.Dict[ AppOrBlueprintKey, - t.Dict[t.Optional[int], t.Dict[t.Type[Exception], ErrorHandlerCallable]], + t.Dict[ + t.Optional[int], + t.Dict[t.Type[Exception], "ErrorHandlerCallable[Exception]"], + ], ] = defaultdict(lambda: defaultdict(dict)) #: A data structure of functions to call at the beginning of @@ -643,8 +647,11 @@ class Scaffold: @setupmethod def errorhandler( - self, code_or_exception: t.Union[t.Type[Exception], int] - ) -> t.Callable[[ErrorHandlerCallable], ErrorHandlerCallable]: + self, code_or_exception: t.Union[t.Type[GenericException], int] + ) -> t.Callable[ + ["ErrorHandlerCallable[GenericException]"], + "ErrorHandlerCallable[GenericException]", + ]: """Register a function to handle errors by code or exception class. A decorator that is used to register a function given an @@ -674,7 +681,9 @@ class Scaffold: an arbitrary exception """ - def decorator(f: ErrorHandlerCallable) -> ErrorHandlerCallable: + def decorator( + f: "ErrorHandlerCallable[GenericException]", + ) -> "ErrorHandlerCallable[GenericException]": self.register_error_handler(code_or_exception, f) return f @@ -683,8 +692,8 @@ class Scaffold: @setupmethod def register_error_handler( self, - code_or_exception: t.Union[t.Type[Exception], int], - f: ErrorHandlerCallable, + code_or_exception: t.Union[t.Type[GenericException], int], + f: "ErrorHandlerCallable[GenericException]", ) -> None: """Alternative error attach function to the :meth:`errorhandler` decorator that is more straightforward to use for non decorator @@ -708,7 +717,9 @@ class Scaffold: " instead." ) - self.error_handler_spec[None][code][exc_class] = f + self.error_handler_spec[None][code][exc_class] = t.cast( + "ErrorHandlerCallable[Exception]", f + ) @staticmethod def _get_exc_class_and_code( diff --git a/src/flask/typing.py b/src/flask/typing.py index b1a6cbdc..f1c84670 100644 --- a/src/flask/typing.py +++ b/src/flask/typing.py @@ -33,11 +33,12 @@ ResponseReturnValue = t.Union[ "WSGIApplication", ] +GenericException = t.TypeVar("GenericException", bound=Exception, contravariant=True) + AppOrBlueprintKey = t.Optional[str] # The App key is None, whereas blueprints are named AfterRequestCallable = t.Callable[["Response"], "Response"] BeforeFirstRequestCallable = t.Callable[[], None] BeforeRequestCallable = t.Callable[[], t.Optional[ResponseReturnValue]] -ErrorHandlerCallable = t.Callable[[Exception], ResponseReturnValue] TeardownCallable = t.Callable[[t.Optional[BaseException]], None] TemplateContextProcessorCallable = t.Callable[[], t.Dict[str, t.Any]] TemplateFilterCallable = t.Callable[..., t.Any] @@ -45,3 +46,11 @@ TemplateGlobalCallable = t.Callable[..., t.Any] TemplateTestCallable = t.Callable[..., bool] URLDefaultCallable = t.Callable[[str, dict], None] URLValuePreprocessorCallable = t.Callable[[t.Optional[str], t.Optional[dict]], None] + + +if t.TYPE_CHECKING: + import typing_extensions as te + + class ErrorHandlerCallable(te.Protocol[GenericException]): + def __call__(self, error: GenericException) -> ResponseReturnValue: + ... diff --git a/src/flask/views.py b/src/flask/views.py index 339ffa18..1bd5c68b 100644 --- a/src/flask/views.py +++ b/src/flask/views.py @@ -1,5 +1,6 @@ import typing as t +from .globals import current_app from .globals import request from .typing import ResponseReturnValue @@ -80,7 +81,7 @@ class View: def view(*args: t.Any, **kwargs: t.Any) -> ResponseReturnValue: self = view.view_class(*class_args, **class_kwargs) # type: ignore - return self.dispatch_request(*args, **kwargs) + return current_app.ensure_sync(self.dispatch_request)(*args, **kwargs) if cls.decorators: view.__name__ = name @@ -154,4 +155,4 @@ class MethodView(View, metaclass=MethodViewType): meth = getattr(self, "get", None) assert meth is not None, f"Unimplemented method {request.method!r}" - return meth(*args, **kwargs) + return current_app.ensure_sync(meth)(*args, **kwargs) diff --git a/tests/test_async.py b/tests/test_async.py index 26a91118..8276c4a8 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -6,6 +6,8 @@ import pytest from flask import Blueprint from flask import Flask from flask import request +from flask.views import MethodView +from flask.views import View pytest.importorskip("asgiref") @@ -18,6 +20,24 @@ class BlueprintError(Exception): pass +class AsyncView(View): + methods = ["GET", "POST"] + + async def dispatch_request(self): + await asyncio.sleep(0) + return request.method + + +class AsyncMethodView(MethodView): + async def get(self): + await asyncio.sleep(0) + return "GET" + + async def post(self): + await asyncio.sleep(0) + return "POST" + + @pytest.fixture(name="async_app") def _async_app(): app = Flask(__name__) @@ -53,11 +73,14 @@ def _async_app(): app.register_blueprint(blueprint, url_prefix="/bp") + app.add_url_rule("/view", view_func=AsyncView.as_view("view")) + app.add_url_rule("/methodview", view_func=AsyncMethodView.as_view("methodview")) + return app @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires Python >= 3.7") -@pytest.mark.parametrize("path", ["/", "/home", "/bp/"]) +@pytest.mark.parametrize("path", ["/", "/home", "/bp/", "/view", "/methodview"]) def test_async_route(path, async_app): test_client = async_app.test_client() response = test_client.get(path) diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index 088ad779..a124c612 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -899,6 +899,14 @@ def test_blueprint_renaming(app, client) -> None: def index(): return flask.request.endpoint + @bp.get("/error") + def error(): + flask.abort(403) + + @bp.errorhandler(403) + def forbidden(_: Exception): + return "Error", 403 + @bp2.get("/") def index2(): return flask.request.endpoint @@ -911,3 +919,5 @@ def test_blueprint_renaming(app, client) -> None: assert client.get("/b/").data == b"alt.index" assert client.get("/a/a/").data == b"bp.sub.index2" assert client.get("/b/a/").data == b"alt.sub.index2" + assert client.get("/a/error").data == b"Error" + assert client.get("/b/error").data == b"Error"