diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..86e010df --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + time: "08:00" + open-pull-requests-limit: 99 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e656dcf8..06338f6b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -2,7 +2,7 @@ name: Tests on: push: branches: - - master + - main - '*.x' paths-ignore: - 'docs/**' @@ -10,7 +10,7 @@ on: - '*.rst' pull_request: branches: - - master + - main - '*.x' paths-ignore: - 'docs/**' diff --git a/.gitignore b/.gitignore index 71dafa39..e50a290e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ *.pyc *.pyo env/ +venv/ +.venv/ env* dist/ build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index af21506b..6ee654a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,29 +1,31 @@ +ci: + autoupdate_schedule: monthly repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.13.0 + rev: v2.23.1 hooks: - id: pyupgrade args: ["--py36-plus"] - repo: https://github.com/asottile/reorder_python_imports - rev: v2.5.0 + rev: v2.6.0 hooks: - id: reorder-python-imports name: Reorder Python imports (src, tests) files: "^(?!examples/)" args: ["--application-directories", "src"] - repo: https://github.com/psf/black - rev: 21.4b0 + rev: 21.7b0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 - rev: 3.9.1 + rev: 3.9.2 hooks: - id: flake8 additional_dependencies: - flake8-bugbear - flake8-implicit-str-concat - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.4.0 + rev: v4.0.1 hooks: - id: fix-byte-order-marker - id: trailing-whitespace diff --git a/CHANGES.rst b/CHANGES.rst index 6b24db27..2abd012a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,9 +1,70 @@ .. currentmodule:: flask +Version 2.1.0 +------------- + +Unreleased + +- Update Click dependency to >= 8.0. + + +Version 2.0.2 +------------- + +Unreleased + +- 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 +------------- + +Released 2021-05-21 + +- Re-add the ``filename`` parameter in ``send_from_directory``. The + ``filename`` parameter has been renamed to ``path``, the old name + is deprecated. :pr:`4019` +- Mark top-level names as exported so type checking understands + imports in user projects. :issue:`4024` +- Fix type annotation for ``g`` and inform mypy that it is a namespace + object that has arbitrary attributes. :issue:`4020` +- Fix some types that weren't available in Python 3.6.0. :issue:`4040` +- Improve typing for ``send_file``, ``send_from_directory``, and + ``get_send_file_max_age``. :issue:`4044`, :pr:`4026` +- Show an error when a blueprint name contains a dot. The ``.`` has + special meaning, it is used to separate (nested) blueprint names and + the endpoint name. :issue:`4041` +- Combine URL prefixes when nesting blueprints that were created with + a ``url_prefix`` value. :issue:`4037` +- Roll back a change to the order that URL matching was done. The + URL is again matched after the session is loaded, so the session is + available in custom URL converters. :issue:`4053` +- Re-add deprecated ``Config.from_json``, which was accidentally + removed early. :issue:`4078` +- Improve typing for some functions using ``Callable`` in their type + signatures, focusing on decorator factories. :issue:`4060` +- Nested blueprints are registered with their dotted name. This allows + different blueprints with the same name to be nested at different + locations. :issue:`4069` +- ``register_blueprint`` takes a ``name`` option to change the + (pre-dotted) name the blueprint is registered with. This allows the + same blueprint to be registered multiple times with unique names for + ``url_for``. Registering the same blueprint with the same name + multiple times is deprecated. :issue:`1091` +- Improve typing for ``stream_with_context``. :issue:`4052` + + Version 2.0.0 ------------- -Unreleased +Released 2021-05-11 - Drop support for Python 2 and 3.5. - Bump minimum versions of other Pallets projects: Werkzeug >= 2, @@ -76,6 +137,8 @@ Unreleased - Support async views, error handlers, before and after request, and teardown functions. :pr:`3412` - Support nesting blueprints. :issue:`593, 1548`, :pr:`3923` +- Set the default encoding to "UTF-8" when loading ``.env`` and + ``.flaskenv`` files to allow to use non-ASCII characters. :issue:`3931` - ``flask shell`` sets up tab and history completion like the default ``python`` shell if ``readline`` is installed. :issue:`3941` - ``helpers.total_seconds()`` is deprecated. Use @@ -84,6 +147,26 @@ Unreleased - Support using the ``route`` decorator on view classes (i.e. ``View`` and ``MethodView`` subclasses). :issue:`3404` +Version 1.1.4 +------------- + +Released 2021-05-13 + +- Update ``static_folder`` to use ``_compat.fspath`` instead of + ``os.fspath`` to continue supporting Python < 3.6 :issue:`4050` + + +Version 1.1.3 +------------- + +Released 2021-05-13 + +- Set maximum versions of Werkzeug, Jinja, Click, and ItsDangerous. + :issue:`4043` +- Re-add support for passing a ``pathlib.Path`` for ``static_folder``. + :pr:`3579` + + Version 1.1.2 ------------- diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 01a01177..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. @@ -112,6 +114,12 @@ First time setup > py -3 -m venv env > env\Scripts\activate +- Upgrade pip and setuptools. + + .. code-block:: text + + $ python -m pip install --upgrade pip setuptools + - Install the development dependencies, then install Flask in editable mode. @@ -129,7 +137,7 @@ First time setup .. _username: https://docs.github.com/en/github/using-git/setting-your-username-in-git .. _email: https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address .. _GitHub account: https://github.com/join -.. _Fork: https://github.com/pallets/jinja/fork +.. _Fork: https://github.com/pallets/flask/fork .. _Clone: https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#step-2-create-a-local-clone-of-your-fork @@ -143,15 +151,15 @@ Start coding .. code-block:: text $ git fetch origin - $ git checkout -b your-branch-name origin/1.1.x + $ git checkout -b your-branch-name origin/2.0.x If you're submitting a feature addition or change, branch off of the - "master" branch. + "main" branch. .. code-block:: text $ git fetch origin - $ git checkout -b your-branch-name origin/master + $ git checkout -b your-branch-name origin/main - Using your favorite editor, make your changes, `committing as you go`_. diff --git a/README.rst b/README.rst index 95f9bbae..3b1f2fbd 100644 --- a/README.rst +++ b/README.rst @@ -55,7 +55,7 @@ Contributing For guidance on setting up a development environment and how to make a contribution to Flask, see the `contributing guidelines`_. -.. _contributing guidelines: https://github.com/pallets/flask/blob/master/CONTRIBUTING.rst +.. _contributing guidelines: https://github.com/pallets/flask/blob/main/CONTRIBUTING.rst Donate diff --git a/docs/async-await.rst b/docs/async-await.rst index 3fc24a06..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,18 @@ 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 + something like ``ValueError: set_wakeup_fd only works in main thread``, + please upgrade to Python 3.9. + Performance ----------- @@ -51,7 +64,7 @@ example via ``asyncio.create_task``. If you wish to use background tasks it is best to use a task queue to trigger background work, rather than spawn tasks in a view function. With that in mind you can spawn asyncio tasks by serving -Flask with a ASGI server and utilising the asgiref WsgiToAsgi adapter +Flask with an ASGI server and utilising the asgiref WsgiToAsgi adapter as described in :ref:`asgi`. This works as the adapter creates an event loop that runs continually. @@ -64,7 +77,7 @@ to the way it is implemented. If you have a mainly async codebase it would make sense to consider `Quart`_. Quart is a reimplementation of Flask based on the `ASGI`_ standard instead of WSGI. This allows it to handle many concurrent requests, long running requests, and websockets -without requiring individual worker processes or threads. +without requiring multiple worker processes or threads. It has also already been possible to run Flask with Gevent or Eventlet to get many of the benefits of async request handling. These libraries @@ -80,12 +93,27 @@ to understanding the specific needs of your project. Extensions ---------- -Existing Flask extensions only expect views to be synchronous. If they -provide decorators to add functionality to views, those will probably +Flask extensions predating Flask's async support do not expect async views. +If they provide decorators to add functionality to views, those will probably not work with async views because they will not await the function or be awaitable. Other functions they provide will not be awaitable either and will probably be blocking if called within an async view. +Extension authors can support async functions by utilising the +:meth:`flask.Flask.ensure_sync` method. For example, if the extension +provides a view function decorator add ``ensure_sync`` before calling +the decorated function, + +.. code-block:: python + + def extension(func): + @wraps(func) + def wrapper(*args, **kwargs): + ... # Extension logic + return current_app.ensure_sync(func)(*args, **kwargs) + + return wrapper + Check the changelog of the extension you want to use to see if they've implemented async support, or make a feature request or PR to them. diff --git a/docs/blueprints.rst b/docs/blueprints.rst index 6e8217ab..af368bac 100644 --- a/docs/blueprints.rst +++ b/docs/blueprints.rst @@ -127,8 +127,8 @@ It is possible to register a blueprint on another blueprint. .. code-block:: python - parent = Blueprint("parent", __name__, url_prefix="/parent") - child = Blueprint("child", __name__, url_prefix="/child) + parent = Blueprint('parent', __name__, url_prefix='/parent') + child = Blueprint('child', __name__, url_prefix='/child') parent.register_blueprint(child) app.register_blueprint(parent) diff --git a/docs/cli.rst b/docs/cli.rst index b390c96e..6036cb2d 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -45,13 +45,13 @@ While ``FLASK_APP`` supports a variety of options for specifying your application, most use cases should be simple. Here are the typical values: (nothing) - The file :file:`wsgi.py` is imported, automatically detecting an app - (``app``). This provides an easy way to create an app from a factory with - extra arguments. + The name "app" or "wsgi" is imported (as a ".py" file, or package), + automatically detecting an app (``app`` or ``application``) or + factory (``create_app`` or ``make_app``). ``FLASK_APP=hello`` - The name is imported, automatically detecting an app (``app``) or factory - (``create_app``). + The given name is imported, automatically detecting an app (``app`` + or ``application``) or factory (``create_app`` or ``make_app``). ---- diff --git a/docs/conf.py b/docs/conf.py index 4b7ea13e..ae2922d7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,6 +20,7 @@ extensions = [ "sphinx_issues", "sphinx_tabs.tabs", ] +autodoc_typehints = "description" intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), "werkzeug": ("https://werkzeug.palletsprojects.com/", None), @@ -48,10 +49,10 @@ html_context = { ] } html_sidebars = { - "index": ["project.html", "localtoc.html", "searchbox.html"], - "**": ["localtoc.html", "relations.html", "searchbox.html"], + "index": ["project.html", "localtoc.html", "searchbox.html", "ethicalads.html"], + "**": ["localtoc.html", "relations.html", "searchbox.html", "ethicalads.html"], } -singlehtml_sidebars = {"index": ["project.html", "localtoc.html"]} +singlehtml_sidebars = {"index": ["project.html", "localtoc.html", "ethicalads.html"]} html_static_path = ["_static"] html_favicon = "_static/flask-icon.png" html_logo = "_static/flask-icon.png" @@ -77,7 +78,7 @@ def github_link(name, rawtext, text, lineno, inliner, options=None, content=None words = None if packaging.version.parse(release).is_devrelease: - url = f"{base_url}master/{text}" + url = f"{base_url}main/{text}" else: url = f"{base_url}{release}/{text}" diff --git a/docs/deploying/index.rst b/docs/deploying/index.rst index 4511bf45..e1ed9269 100644 --- a/docs/deploying/index.rst +++ b/docs/deploying/index.rst @@ -16,6 +16,7 @@ Hosted options - `Deploying Flask on Heroku `_ - `Deploying Flask on Google App Engine `_ +- `Deploying Flask on Google Cloud Run `_ - `Deploying Flask on AWS Elastic Beanstalk `_ - `Deploying on Azure (IIS) `_ - `Deploying on PythonAnywhere `_ 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/patterns/viewdecorators.rst b/docs/patterns/viewdecorators.rst index b0960b2d..0b0479ef 100644 --- a/docs/patterns/viewdecorators.rst +++ b/docs/patterns/viewdecorators.rst @@ -142,7 +142,7 @@ Here is the code for that decorator:: def decorated_function(*args, **kwargs): template_name = template if template_name is None: - template_name = f"'{request.endpoint.replace('.', '/')}.html'" + template_name = f"{request.endpoint.replace('.', '/')}.html" ctx = f(*args, **kwargs) if ctx is None: ctx = {} diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 0aca7ffd..b5071ab0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -50,7 +50,7 @@ to tell your terminal the application to work with by exporting the .. code-block:: text - $ export FLASK_APP=hello.py + $ export FLASK_APP=hello $ flask run * Running on http://127.0.0.1:5000/ @@ -58,7 +58,7 @@ to tell your terminal the application to work with by exporting the .. code-block:: text - > set FLASK_APP=hello.py + > set FLASK_APP=hello > flask run * Running on http://127.0.0.1:5000/ @@ -66,10 +66,16 @@ to tell your terminal the application to work with by exporting the .. code-block:: text - > $env:FLASK_APP = "hello.py" + > $env:FLASK_APP = "hello" > flask run * Running on http://127.0.0.1:5000/ +.. admonition:: Application Discovery Behavior + + As a shortcut, if the file is named ``app.py`` or ``wsgi.py``, you + don't have to set the ``FLASK_APP`` environment variable. See + :doc:`/cli` for more details. + This launches a very simple builtin server, which is good enough for testing but probably not what you want to use in production. For deployment options see :doc:`deploying/index`. @@ -240,7 +246,7 @@ of the argument like ````. :: @app.route('/user/') def show_user_profile(username): # show the user profile for that user - return f'User {username}' + return f'User {escape(username)}' @app.route('/post/') def show_post(post_id): @@ -250,7 +256,7 @@ of the argument like ````. :: @app.route('/path/') def show_subpath(subpath): # show the subpath after /path/ - return f'Subpath {subpath}' + return f'Subpath {escape(subpath)}' Converter types: @@ -438,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/docs/tutorial/index.rst b/docs/tutorial/index.rst index 103c27a8..d5dc5b3c 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -55,7 +55,7 @@ this structure and take full advantage of Flask's flexibility. .. image:: flaskr_edit.png :align: center :class: screenshot - :alt: screenshot of login page + :alt: screenshot of edit page :gh:`The tutorial project is available as an example in the Flask repository `, if you want to compare your project diff --git a/docs/views.rst b/docs/views.rst index f3d1cdcf..86d81dfc 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -113,8 +113,8 @@ Method Based Dispatching For RESTful APIs it's especially helpful to execute a different function for each HTTP method. With the :class:`flask.views.MethodView` you can -easily do that. Each HTTP method maps to a function with the same name -(just in lowercase):: +easily do that. Each HTTP method maps to a method of the class with the +same name (just in lowercase):: from flask.views import MethodView diff --git a/examples/tutorial/README.rst b/examples/tutorial/README.rst index 7c7255f9..41f3f6ba 100644 --- a/examples/tutorial/README.rst +++ b/examples/tutorial/README.rst @@ -11,7 +11,7 @@ Install **Be sure to use the same version of the code as the version of the docs you're reading.** You probably want the latest tagged version, but the -default Git version is the master branch. :: +default Git version is the main branch. :: # clone the repository $ git clone https://github.com/pallets/flask @@ -35,7 +35,7 @@ Install Flaskr:: $ pip install -e . -Or if you are using the master branch, install Flask from source before +Or if you are using the main branch, install Flask from source before installing Flaskr:: $ pip install -e ../.. diff --git a/requirements/dev.in b/requirements/dev.in index c854000e..2588467c 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -1,5 +1,6 @@ -r docs.in -r tests.in +-r typing.in pip-tools pre-commit tox diff --git a/requirements/dev.txt b/requirements/dev.txt index 934e7f31..8347270c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -8,33 +8,35 @@ alabaster==0.7.12 # via sphinx appdirs==1.4.4 # via virtualenv -asgiref==3.3.4 +asgiref==3.4.1 # via -r requirements/tests.in -attrs==20.3.0 +attrs==21.2.0 # via pytest -babel==2.9.0 +babel==2.9.1 # via sphinx blinker==1.4 # via -r requirements/tests.in certifi==2020.12.5 # via requests -cfgv==3.2.0 +cfgv==3.3.0 # via pre-commit chardet==4.0.0 # via requests -click==7.1.2 +click==8.0.1 # via pip-tools distlib==0.3.1 # via virtualenv docutils==0.16 - # via sphinx + # via + # sphinx + # sphinx-tabs filelock==3.0.12 # via # tox # virtualenv -greenlet==1.0.0 +greenlet==1.1.0 # via -r requirements/tests.in -identify==2.2.3 +identify==2.2.4 # via pre-commit idna==2.10 # via requests @@ -42,10 +44,14 @@ imagesize==1.2.0 # via sphinx iniconfig==1.1.1 # via pytest -jinja2==2.11.3 +jinja2==3.0.1 # via sphinx -markupsafe==1.1.1 +markupsafe==2.0.1 # via jinja2 +mypy==0.910 + # via -r requirements/typing.in +mypy-extensions==0.4.3 + # via mypy nodeenv==1.6.0 # via pre-commit packaging==20.9 @@ -54,31 +60,31 @@ packaging==20.9 # pytest # sphinx # tox -pallets-sphinx-themes==2.0.0rc1 +pallets-sphinx-themes==2.0.1 # via -r requirements/docs.in pep517==0.10.0 # via pip-tools -pip-tools==6.1.0 +pip-tools==6.2.0 # via -r requirements/dev.in pluggy==0.13.1 # via # pytest # tox -pre-commit==2.12.0 +pre-commit==2.13.0 # via -r requirements/dev.in py==1.10.0 # via # pytest # tox -pygments==2.8.1 +pygments==2.9.0 # via # sphinx # sphinx-tabs pyparsing==2.4.7 # via packaging -pytest==6.2.3 +pytest==6.2.4 # via -r requirements/tests.in -python-dotenv==0.17.0 +python-dotenv==0.19.0 # via -r requirements/tests.in pytz==2021.1 # via babel @@ -86,28 +92,28 @@ pyyaml==5.4.1 # via pre-commit requests==2.25.1 # via sphinx -six==1.15.0 +six==1.16.0 # via # tox # virtualenv snowballstemmer==2.1.0 # via sphinx -sphinx-issues==1.2.0 - # via -r requirements/docs.in -sphinx-tabs==2.1.0 - # via -r requirements/docs.in -sphinx==3.5.4 +sphinx==4.1.2 # via # -r requirements/docs.in # pallets-sphinx-themes # sphinx-issues # sphinx-tabs # sphinxcontrib-log-cabinet +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx-tabs==3.1.0 + # via -r requirements/docs.in sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==1.0.3 +sphinxcontrib-htmlhelp==2.0.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx @@ -115,22 +121,27 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.4 +sphinxcontrib-serializinghtml==1.1.5 # via sphinx toml==0.10.2 # via + # mypy # pep517 # pre-commit # pytest # tox -tox==3.23.0 +tox==3.24.1 # via -r requirements/dev.in -urllib3==1.26.4 +typing-extensions==3.10.0.0 + # via mypy +urllib3==1.26.5 # via requests -virtualenv==20.4.3 +virtualenv==20.4.6 # via # pre-commit # tox +wheel==0.36.2 + # via pip-tools # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements/docs.in b/requirements/docs.in index c1898bc7..3ee050af 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,4 +1,4 @@ -Pallets-Sphinx-Themes >= 2.0.0rc1 +Pallets-Sphinx-Themes Sphinx sphinx-issues sphinxcontrib-log-cabinet diff --git a/requirements/docs.txt b/requirements/docs.txt index 55682252..17730411 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -6,29 +6,31 @@ # alabaster==0.7.12 # via sphinx -babel==2.9.0 +babel==2.9.1 # via sphinx certifi==2020.12.5 # via requests chardet==4.0.0 # via requests docutils==0.16 - # via sphinx + # via + # sphinx + # sphinx-tabs idna==2.10 # via requests imagesize==1.2.0 # via sphinx -jinja2==2.11.3 +jinja2==3.0.1 # via sphinx -markupsafe==1.1.1 +markupsafe==2.0.1 # via jinja2 packaging==20.9 # via # pallets-sphinx-themes # sphinx -pallets-sphinx-themes==2.0.0rc1 +pallets-sphinx-themes==2.0.1 # via -r requirements/docs.in -pygments==2.8.1 +pygments==2.9.0 # via # sphinx # sphinx-tabs @@ -40,22 +42,22 @@ requests==2.25.1 # via sphinx snowballstemmer==2.1.0 # via sphinx -sphinx-issues==1.2.0 - # via -r requirements/docs.in -sphinx-tabs==2.1.0 - # via -r requirements/docs.in -sphinx==3.5.4 +sphinx==4.1.2 # via # -r requirements/docs.in # pallets-sphinx-themes # sphinx-issues # sphinx-tabs # sphinxcontrib-log-cabinet +sphinx-issues==1.2.0 + # via -r requirements/docs.in +sphinx-tabs==3.1.0 + # via -r requirements/docs.in sphinxcontrib-applehelp==1.0.2 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==1.0.3 +sphinxcontrib-htmlhelp==2.0.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx @@ -63,9 +65,9 @@ sphinxcontrib-log-cabinet==1.0.1 # via -r requirements/docs.in sphinxcontrib-qthelp==1.0.3 # via sphinx -sphinxcontrib-serializinghtml==1.1.4 +sphinxcontrib-serializinghtml==1.1.5 # via sphinx -urllib3==1.26.4 +urllib3==1.26.5 # via requests # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/tests.txt b/requirements/tests.txt index 04ee36ba..1f5f2a67 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -4,13 +4,13 @@ # # pip-compile requirements/tests.in # -asgiref==3.3.4 +asgiref==3.4.1 # via -r requirements/tests.in -attrs==20.3.0 +attrs==21.2.0 # via pytest blinker==1.4 # via -r requirements/tests.in -greenlet==1.0.0 +greenlet==1.1.0 # via -r requirements/tests.in iniconfig==1.1.1 # via pytest @@ -22,9 +22,9 @@ py==1.10.0 # via pytest pyparsing==2.4.7 # via packaging -pytest==6.2.3 +pytest==6.2.4 # via -r requirements/tests.in -python-dotenv==0.17.0 +python-dotenv==0.19.0 # via -r requirements/tests.in toml==0.10.2 # via pytest diff --git a/requirements/typing.txt b/requirements/typing.txt index 29e12e5e..fa04c8ad 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -4,11 +4,11 @@ # # pip-compile requirements/typing.in # +mypy==0.910 + # via -r requirements/typing.in mypy-extensions==0.4.3 # via mypy -mypy==0.812 - # via -r requirements/typing.in -typed-ast==1.4.3 +toml==0.10.2 # via mypy -typing-extensions==3.7.4.3 +typing-extensions==3.10.0.0 # via mypy diff --git a/setup.cfg b/setup.cfg index 77616866..e25c1e27 100644 --- a/setup.cfg +++ b/setup.cfg @@ -112,3 +112,6 @@ ignore_missing_imports = True [mypy-dotenv.*] ignore_missing_imports = True + +[mypy-cryptography.*] +ignore_missing_imports = True diff --git a/setup.py b/setup.py index bfd58e94..cf4b5f01 100644 --- a/setup.py +++ b/setup.py @@ -4,13 +4,13 @@ from setuptools import setup setup( name="Flask", install_requires=[ - "Werkzeug>=2.0.0rc4", - "Jinja2>=3.0.0rc1", - "itsdangerous>=2.0.0rc2", - "click>=7.1.2", + "Werkzeug >= 2.0", + "Jinja2 >= 3.0", + "itsdangerous >= 2.0", + "click >= 8.0", ], extras_require={ - "async": ["asgiref>=3.2"], + "async": ["asgiref >= 3.2"], "dotenv": ["python-dotenv"], }, ) diff --git a/src/flask/__init__.py b/src/flask/__init__.py index c15821ba..3e7198a6 100644 --- a/src/flask/__init__.py +++ b/src/flask/__init__.py @@ -1,46 +1,46 @@ from markupsafe import escape from markupsafe import Markup -from werkzeug.exceptions import abort -from werkzeug.utils import redirect +from werkzeug.exceptions import abort as abort +from werkzeug.utils import redirect as redirect -from . import json -from .app import Flask -from .app import Request -from .app import Response -from .blueprints import Blueprint -from .config import Config -from .ctx import after_this_request -from .ctx import copy_current_request_context -from .ctx import has_app_context -from .ctx import has_request_context -from .globals import _app_ctx_stack -from .globals import _request_ctx_stack -from .globals import current_app -from .globals import g -from .globals import request -from .globals import session -from .helpers import flash -from .helpers import get_flashed_messages -from .helpers import get_template_attribute -from .helpers import make_response -from .helpers import safe_join -from .helpers import send_file -from .helpers import send_from_directory -from .helpers import stream_with_context -from .helpers import url_for -from .json import jsonify -from .signals import appcontext_popped -from .signals import appcontext_pushed -from .signals import appcontext_tearing_down -from .signals import before_render_template -from .signals import got_request_exception -from .signals import message_flashed -from .signals import request_finished -from .signals import request_started -from .signals import request_tearing_down -from .signals import signals_available -from .signals import template_rendered -from .templating import render_template -from .templating import render_template_string +from . import json as json +from .app import Flask as Flask +from .app import Request as Request +from .app import Response as Response +from .blueprints import Blueprint as Blueprint +from .config import Config as Config +from .ctx import after_this_request as after_this_request +from .ctx import copy_current_request_context as copy_current_request_context +from .ctx import has_app_context as has_app_context +from .ctx import has_request_context as has_request_context +from .globals import _app_ctx_stack as _app_ctx_stack +from .globals import _request_ctx_stack as _request_ctx_stack +from .globals import current_app as current_app +from .globals import g as g +from .globals import request as request +from .globals import session as session +from .helpers import flash as flash +from .helpers import get_flashed_messages as get_flashed_messages +from .helpers import get_template_attribute as get_template_attribute +from .helpers import make_response as make_response +from .helpers import safe_join as safe_join +from .helpers import send_file as send_file +from .helpers import send_from_directory as send_from_directory +from .helpers import stream_with_context as stream_with_context +from .helpers import url_for as url_for +from .json import jsonify as jsonify +from .signals import appcontext_popped as appcontext_popped +from .signals import appcontext_pushed as appcontext_pushed +from .signals import appcontext_tearing_down as appcontext_tearing_down +from .signals import before_render_template as before_render_template +from .signals import got_request_exception as got_request_exception +from .signals import message_flashed as message_flashed +from .signals import request_finished as request_finished +from .signals import request_started as request_started +from .signals import request_tearing_down as request_tearing_down +from .signals import signals_available as signals_available +from .signals import template_rendered as template_rendered +from .templating import render_template as render_template +from .templating import render_template_string as render_template_string -__version__ = "2.0.0rc1" +__version__ = "2.1.0.dev0" diff --git a/src/flask/app.py b/src/flask/app.py index dea12591..35e0b179 100644 --- a/src/flask/app.py +++ b/src/flask/app.py @@ -16,6 +16,7 @@ from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequestKeyError from werkzeug.exceptions import HTTPException from werkzeug.exceptions import InternalServerError +from werkzeug.local import ContextVar from werkzeug.routing import BuildError from werkzeug.routing import Map from werkzeug.routing import MapAdapter @@ -35,12 +36,12 @@ from .globals import _request_ctx_stack from .globals import g from .globals import request from .globals import session +from .helpers import _split_blueprint_path from .helpers import get_debug_flag from .helpers import get_env from .helpers import get_flashed_messages from .helpers import get_load_dotenv from .helpers import locked_cached_property -from .helpers import run_async from .helpers import url_for from .json import jsonify from .logging import create_logger @@ -58,8 +59,8 @@ from .signals import request_tearing_down from .templating import DispatchingJinjaLoader 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 @@ -74,9 +75,11 @@ from .wrappers import Request from .wrappers import Response if t.TYPE_CHECKING: + import typing_extensions as te 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 @@ -439,7 +442,7 @@ class Flask(Scaffold): #: :meth:`before_first_request` decorator. #: #: .. versionadded:: 0.8 - self.before_first_request_funcs: t.List[BeforeRequestCallable] = [] + self.before_first_request_funcs: t.List[BeforeFirstRequestCallable] = [] #: A list of functions that are called when the application context #: is destroyed. Since the application context is also torn down @@ -706,7 +709,7 @@ class Flask(Scaffold): session=session, g=g, ) - rv.policies["json.dumps_function"] = json.dumps # type: ignore + rv.policies["json.dumps_function"] = json.dumps return rv def create_global_jinja_loader(self) -> DispatchingJinjaLoader: @@ -748,7 +751,7 @@ class Flask(Scaffold): ] = self.template_context_processors[None] reqctx = _request_ctx_stack.top if reqctx is not None: - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.template_context_processors: funcs = chain(funcs, self.template_context_processors[bp]) orig_ctx = context.copy() @@ -1019,6 +1022,12 @@ class Flask(Scaffold): :class:`~flask.blueprints.BlueprintSetupState`. They can be accessed in :meth:`~flask.Blueprint.record` callbacks. + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + .. versionadded:: 0.7 """ blueprint.register(self, options) @@ -1090,17 +1099,17 @@ class Flask(Scaffold): view_func = view_func.as_view(endpoint) if view_func is not None: old_func = self.view_functions.get(endpoint) - if getattr(old_func, "_flask_sync_wrapper", False): - old_func = old_func.__wrapped__ # type: ignore if old_func is not None and old_func != view_func: raise AssertionError( "View function mapping is overwriting an existing" f" endpoint function: {endpoint}" ) - self.view_functions[endpoint] = self.ensure_sync(view_func) + self.view_functions[endpoint] = view_func @setupmethod - def template_filter(self, name: t.Optional[str] = None) -> t.Callable: + def template_filter( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateFilterCallable], TemplateFilterCallable]: """A decorator that is used to register custom template filter. You can specify a name for the filter, otherwise the function name will be used. Example:: @@ -1132,7 +1141,9 @@ class Flask(Scaffold): self.jinja_env.filters[name or f.__name__] = f @setupmethod - def template_test(self, name: t.Optional[str] = None) -> t.Callable: + def template_test( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateTestCallable], TemplateTestCallable]: """A decorator that is used to register custom template test. You can specify a name for the test, otherwise the function name will be used. Example:: @@ -1173,7 +1184,9 @@ class Flask(Scaffold): self.jinja_env.tests[name or f.__name__] = f @setupmethod - def template_global(self, name: t.Optional[str] = None) -> t.Callable: + def template_global( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateGlobalCallable], TemplateGlobalCallable]: """A decorator that is used to register a custom template global function. You can specify a name for the global function, otherwise the function name will be used. Example:: @@ -1209,7 +1222,9 @@ class Flask(Scaffold): self.jinja_env.globals[name or f.__name__] = f @setupmethod - def before_first_request(self, f: BeforeRequestCallable) -> BeforeRequestCallable: + def before_first_request( + self, f: BeforeFirstRequestCallable + ) -> BeforeFirstRequestCallable: """Registers a function to be run before the first request to this instance of the application. @@ -1218,7 +1233,7 @@ class Flask(Scaffold): .. versionadded:: 0.8 """ - self.before_first_request_funcs.append(self.ensure_sync(f)) + self.before_first_request_funcs.append(f) return f @setupmethod @@ -1251,7 +1266,7 @@ class Flask(Scaffold): .. versionadded:: 0.9 """ - self.teardown_appcontext_funcs.append(self.ensure_sync(f)) + self.teardown_appcontext_funcs.append(f) return f @setupmethod @@ -1263,7 +1278,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 @@ -1271,8 +1288,8 @@ class Flask(Scaffold): """ exc_class, code = self._get_exc_class_and_code(type(e)) - for c in [code, None]: - for name in chain(self._request_blueprints(), [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] if not handler_map: @@ -1299,7 +1316,7 @@ class Flask(Scaffold): .. versionchanged:: 1.0 Exceptions are looked up by code *and* by MRO, so - ``HTTPExcpetion`` subclasses can be handled with a catch-all + ``HTTPException`` subclasses can be handled with a catch-all handler for the base ``HTTPException``. .. versionadded:: 0.3 @@ -1318,7 +1335,7 @@ class Flask(Scaffold): handler = self._find_error_handler(e) if handler is None: return e - return handler(e) + return self.ensure_sync(handler)(e) def trap_http_exception(self, e: Exception) -> bool: """Checks if an HTTP exception should be trapped or not. By default @@ -1385,7 +1402,7 @@ class Flask(Scaffold): if handler is None: raise - return handler(e) + return self.ensure_sync(handler)(e) def handle_exception(self, e: Exception) -> Response: """Handle an exception that did not have an error handler @@ -1432,7 +1449,7 @@ class Flask(Scaffold): handler = self._find_error_handler(server_error) if handler is not None: - server_error = handler(server_error) + server_error = self.ensure_sync(handler)(server_error) return self.finalize_request(server_error, from_error_handler=True) @@ -1453,7 +1470,7 @@ class Flask(Scaffold): f"Exception on {request.path} [{request.method}]", exc_info=exc_info ) - def raise_routing_exception(self, request: Request) -> t.NoReturn: + def raise_routing_exception(self, request: Request) -> "te.NoReturn": """Exceptions that are recording during routing are reraised with this method. During debug we are not reraising redirect requests for non ``GET``, ``HEAD``, or ``OPTIONS`` requests and we're raising @@ -1494,7 +1511,7 @@ class Flask(Scaffold): ): return self.make_default_options_response() # otherwise dispatch to the handler for that endpoint - return self.view_functions[rule.endpoint](**req.view_args) + return self.ensure_sync(self.view_functions[rule.endpoint])(**req.view_args) def full_dispatch_request(self) -> Response: """Dispatches the request and on top of that performs request @@ -1555,7 +1572,7 @@ class Flask(Scaffold): if self._got_first_request: return for func in self.before_first_request_funcs: - func() + self.ensure_sync(func)() self._got_first_request = True def make_default_options_response(self) -> Response: @@ -1591,10 +1608,40 @@ class Flask(Scaffold): .. versionadded:: 2.0 """ if iscoroutinefunction(func): - return run_async(func) + return self.async_to_sync(func) return func + def async_to_sync( + self, func: t.Callable[..., t.Coroutine] + ) -> t.Callable[..., t.Any]: + """Return a sync function that will run the coroutine function. + + .. code-block:: python + + result = app.async_to_sync(func)(*args, **kwargs) + + Override this method to change how the app converts async code + to be synchronously callable. + + .. versionadded:: 2.0 + """ + try: + from asgiref.sync import async_to_sync as asgiref_async_to_sync + except ImportError: + raise RuntimeError( + "Install Flask with the 'async' extra in order to use async views." + ) + + # Check that Werkzeug isn't using its fallback ContextVar class. + if ContextVar.__module__ == "werkzeug.local": + raise RuntimeError( + "Async cannot be used with this combination of Python " + "and Greenlet versions." + ) + + return asgiref_async_to_sync(func) + def make_response(self, rv: ResponseReturnValue) -> Response: """Convert the return value from a view function to an instance of :attr:`response_class`. @@ -1763,9 +1810,14 @@ class Flask(Scaffold): .. versionadded:: 0.7 """ funcs: t.Iterable[URLDefaultCallable] = self.url_default_functions[None] + if "." in endpoint: - bp = endpoint.rsplit(".", 1)[0] - funcs = chain(funcs, self.url_default_functions[bp]) + # This is called by url_for, which can be called outside a + # request, can't use request.blueprints. + bps = _split_blueprint_path(endpoint.rpartition(".")[0]) + bp_funcs = chain.from_iterable(self.url_default_functions[bp] for bp in bps) + funcs = chain(funcs, bp_funcs) + for func in funcs: func(endpoint, values) @@ -1806,18 +1858,18 @@ class Flask(Scaffold): funcs: t.Iterable[URLValuePreprocessorCallable] = self.url_value_preprocessors[ None ] - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.url_value_preprocessors: funcs = chain(funcs, self.url_value_preprocessors[bp]) for func in funcs: func(request.endpoint, request.view_args) funcs: t.Iterable[BeforeRequestCallable] = self.before_request_funcs[None] - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.before_request_funcs: funcs = chain(funcs, self.before_request_funcs[bp]) for func in funcs: - rv = func() + rv = self.ensure_sync(func)() if rv is not None: return rv @@ -1838,13 +1890,13 @@ class Flask(Scaffold): """ ctx = _request_ctx_stack.top funcs: t.Iterable[AfterRequestCallable] = ctx._after_request_functions - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.after_request_funcs: funcs = chain(funcs, reversed(self.after_request_funcs[bp])) if None in self.after_request_funcs: funcs = chain(funcs, reversed(self.after_request_funcs[None])) for handler in funcs: - response = handler(response) + response = self.ensure_sync(handler)(response) if not self.session_interface.is_null_session(ctx.session): self.session_interface.save_session(self, ctx.session, response) return response @@ -1877,11 +1929,11 @@ class Flask(Scaffold): funcs: t.Iterable[TeardownCallable] = reversed( self.teardown_request_funcs[None] ) - for bp in self._request_blueprints(): + for bp in request.blueprints: if bp in self.teardown_request_funcs: funcs = chain(funcs, reversed(self.teardown_request_funcs[bp])) for func in funcs: - func(exc) + self.ensure_sync(func)(exc) request_tearing_down.send(self, exc=exc) def do_teardown_appcontext( @@ -1904,7 +1956,7 @@ class Flask(Scaffold): if exc is _sentinel: exc = sys.exc_info()[1] for func in reversed(self.teardown_appcontext_funcs): - func(exc) + self.ensure_sync(func)(exc) appcontext_tearing_down.send(self, exc=exc) def app_context(self) -> AppContext: @@ -2049,9 +2101,3 @@ class Flask(Scaffold): wrapped to apply middleware. """ return self.wsgi_app(environ, start_response) - - def _request_blueprints(self) -> t.Iterable[str]: - if _request_ctx_stack.top.request.blueprint is None: - return [] - else: - return reversed(_request_ctx_stack.top.request.blueprint.split(".")) diff --git a/src/flask/blueprints.py b/src/flask/blueprints.py index 0001db01..9a72b045 100644 --- a/src/flask/blueprints.py +++ b/src/flask/blueprints.py @@ -6,8 +6,8 @@ from .scaffold import _endpoint_from_view_func from .scaffold import _sentinel 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 @@ -20,6 +20,7 @@ from .views import View if t.TYPE_CHECKING: from .app import Flask + from .typing import ErrorHandlerCallable DeferredSetupFunction = t.Callable[["BlueprintSetupState"], t.Callable] @@ -69,6 +70,7 @@ class BlueprintSetupState: #: blueprint. self.url_prefix = url_prefix + self.name = self.options.get("name", blueprint.name) self.name_prefix = self.options.get("name_prefix", "") #: A dictionary with URL defaults that is added to each and every @@ -103,9 +105,10 @@ class BlueprintSetupState: defaults = dict(defaults, **options.pop("defaults")) if isinstance(view_func, type) and issubclass(view_func, View): view_func = view_func.as_view(endpoint) + self.app.add_url_rule( rule, - f"{self.name_prefix}{self.blueprint.name}.{endpoint}", + f"{self.name_prefix}.{self.name}.{endpoint}".lstrip("."), view_func, defaults=defaults, **options, @@ -195,6 +198,10 @@ class Blueprint(Scaffold): template_folder=template_folder, root_path=root_path, ) + + if "." in name: + raise ValueError("'name' may not contain a dot '.' character.") + self.name = name self.url_prefix = url_prefix self.subdomain = subdomain @@ -255,39 +262,74 @@ class Blueprint(Scaffold): arguments passed to this method will override the defaults set on the blueprint. + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + .. versionadded:: 2.0 """ + if blueprint is self: + raise ValueError("Cannot register a blueprint on itself") self._blueprints.append((blueprint, options)) def register(self, app: "Flask", options: dict) -> None: """Called by :meth:`Flask.register_blueprint` to register all views and callbacks registered on the blueprint with the application. Creates a :class:`.BlueprintSetupState` and calls - each :meth:`record` callbackwith it. + each :meth:`record` callback with it. :param app: The application this blueprint is being registered with. :param options: Keyword arguments forwarded from :meth:`~Flask.register_blueprint`. - :param first_registration: Whether this is the first time this - blueprint has been registered on the application. + + .. versionchanged:: 2.0.1 + Nested blueprints are registered with their dotted name. + This allows different blueprints with the same name to be + nested at different locations. + + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + + .. versionchanged:: 2.0.1 + Registering the same blueprint with the same name multiple + times is deprecated and will become an error in Flask 2.1. """ - first_registration = False + name_prefix = options.get("name_prefix", "") + self_name = options.get("name", self.name) + name = f"{name_prefix}.{self_name}".lstrip(".") - if self.name in app.blueprints: - assert app.blueprints[self.name] is self, ( - "A name collision occurred between blueprints" - f" {self!r} and {app.blueprints[self.name]!r}." - f" Both share the same name {self.name!r}." - f" Blueprints that are created on the fly need unique" - f" names." - ) - else: - app.blueprints[self.name] = self - first_registration = True + if name in app.blueprints: + existing_at = f" '{name}'" if self_name != name else "" + if app.blueprints[name] is not self: + raise ValueError( + f"The name '{self_name}' is already registered for" + f" a different blueprint{existing_at}. Use 'name='" + " to provide a unique name." + ) + else: + import warnings + + warnings.warn( + f"The name '{self_name}' is already registered for" + f" this blueprint{existing_at}. Use 'name=' to" + " provide a unique name. This will become an error" + " in Flask 2.1.", + 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( @@ -297,25 +339,20 @@ 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, ensure_sync=False): + def extend(bp_dict, parent_dict): for key, values in bp_dict.items(): - key = self.name if key is None else f"{self.name}.{key}" - - if ensure_sync: - values = [app.ensure_sync(func) for func in values] - + key = name if key is None else f"{name}.{key}" parent_dict[key].extend(values) for key, value in self.error_handler_spec.items(): - key = self.name if key is None else f"{self.name}.{key}" + key = name if key is None else f"{name}.{key}" value = defaultdict( dict, { code: { - exc_class: app.ensure_sync(func) - for exc_class, func in code_values.items() + exc_class: func for exc_class, func in code_values.items() } for code, code_values in value.items() }, @@ -323,16 +360,13 @@ class Blueprint(Scaffold): app.error_handler_spec[key] = value for endpoint, func in self.view_functions.items(): - app.view_functions[endpoint] = app.ensure_sync(func) + app.view_functions[endpoint] = func - extend( - self.before_request_funcs, app.before_request_funcs, ensure_sync=True - ) - extend(self.after_request_funcs, app.after_request_funcs, ensure_sync=True) + extend(self.before_request_funcs, app.before_request_funcs) + extend(self.after_request_funcs, app.after_request_funcs) extend( self.teardown_request_funcs, app.teardown_request_funcs, - ensure_sync=True, ) extend(self.url_default_functions, app.url_default_functions) extend(self.url_value_preprocessors, app.url_value_preprocessors) @@ -347,21 +381,29 @@ class Blueprint(Scaffold): if cli_resolved_group is None: app.cli.commands.update(self.cli.commands) elif cli_resolved_group is _sentinel: - self.cli.name = self.name + self.cli.name = name app.cli.add_command(self.cli) else: self.cli.name = cli_resolved_group app.cli.add_command(self.cli) for blueprint, bp_options in self._blueprints: - url_prefix = options.get("url_prefix", "") - if "url_prefix" in bp_options: - url_prefix = ( - url_prefix.rstrip("/") + "/" + bp_options["url_prefix"].lstrip("/") - ) + bp_options = bp_options.copy() + bp_url_prefix = bp_options.get("url_prefix") - bp_options["url_prefix"] = url_prefix - bp_options["name_prefix"] = options.get("name_prefix", "") + self.name + "." + if bp_url_prefix is None: + bp_url_prefix = blueprint.url_prefix + + if state.url_prefix is not None and bp_url_prefix is not None: + bp_options["url_prefix"] = ( + state.url_prefix.rstrip("/") + "/" + bp_url_prefix.lstrip("/") + ) + elif bp_url_prefix is not None: + bp_options["url_prefix"] = bp_url_prefix + elif state.url_prefix is not None: + bp_options["url_prefix"] = state.url_prefix + + bp_options["name_prefix"] = name blueprint.register(app, bp_options) def add_url_rule( @@ -369,20 +411,31 @@ class Blueprint(Scaffold): rule: str, endpoint: t.Optional[str] = None, view_func: t.Optional[t.Callable] = None, + provide_automatic_options: t.Optional[bool] = None, **options: t.Any, ) -> None: """Like :meth:`Flask.add_url_rule` but for a blueprint. The endpoint for the :func:`url_for` function is prefixed with the name of the blueprint. """ - if endpoint: - assert "." not in endpoint, "Blueprint endpoints should not contain dots" - if view_func and hasattr(view_func, "__name__"): - assert ( - "." not in view_func.__name__ - ), "Blueprint view function name should not contain dots" - self.record(lambda s: s.add_url_rule(rule, endpoint, view_func, **options)) + if endpoint and "." in endpoint: + raise ValueError("'endpoint' may not contain a dot '.' character.") - def app_template_filter(self, name: t.Optional[str] = None) -> t.Callable: + if view_func and hasattr(view_func, "__name__") and "." in view_func.__name__: + raise ValueError("'view_func' name may not contain a dot '.' character.") + + self.record( + lambda s: s.add_url_rule( + rule, + endpoint, + view_func, + provide_automatic_options=provide_automatic_options, + **options, + ) + ) + + def app_template_filter( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateFilterCallable], TemplateFilterCallable]: """Register a custom template filter, available application wide. Like :meth:`Flask.template_filter` but for a blueprint. @@ -412,7 +465,9 @@ class Blueprint(Scaffold): self.record_once(register_template) - def app_template_test(self, name: t.Optional[str] = None) -> t.Callable: + def app_template_test( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateTestCallable], TemplateTestCallable]: """Register a custom template test, available application wide. Like :meth:`Flask.template_test` but for a blueprint. @@ -446,7 +501,9 @@ class Blueprint(Scaffold): self.record_once(register_template) - def app_template_global(self, name: t.Optional[str] = None) -> t.Callable: + def app_template_global( + self, name: t.Optional[str] = None + ) -> t.Callable[[TemplateGlobalCallable], TemplateGlobalCallable]: """Register a custom template global, available application wide. Like :meth:`Flask.template_global` but for a blueprint. @@ -485,21 +542,17 @@ class Blueprint(Scaffold): before each request, even if outside of a blueprint. """ self.record_once( - lambda s: s.app.before_request_funcs.setdefault(None, []).append( - s.app.ensure_sync(f) - ) + lambda s: s.app.before_request_funcs.setdefault(None, []).append(f) ) return f def before_app_first_request( - self, f: BeforeRequestCallable - ) -> BeforeRequestCallable: + self, f: BeforeFirstRequestCallable + ) -> BeforeFirstRequestCallable: """Like :meth:`Flask.before_first_request`. Such a function is executed before the first request to the application. """ - self.record_once( - lambda s: s.app.before_first_request_funcs.append(s.app.ensure_sync(f)) - ) + self.record_once(lambda s: s.app.before_first_request_funcs.append(f)) return f def after_app_request(self, f: AfterRequestCallable) -> AfterRequestCallable: @@ -507,9 +560,7 @@ class Blueprint(Scaffold): is executed after each request, even if outside of the blueprint. """ self.record_once( - lambda s: s.app.after_request_funcs.setdefault(None, []).append( - s.app.ensure_sync(f) - ) + lambda s: s.app.after_request_funcs.setdefault(None, []).append(f) ) return f @@ -539,7 +590,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 @@ -560,14 +613,3 @@ class Blueprint(Scaffold): lambda s: s.app.url_default_functions.setdefault(None, []).append(f) ) return f - - def ensure_sync(self, f: t.Callable) -> t.Callable: - """Ensure the function is synchronous. - - Override if you would like custom async to sync behaviour in - this blueprint. Otherwise the app's - :meth:`~flask.Flask.ensure_sync` is used. - - .. versionadded:: 2.0 - """ - return f diff --git a/src/flask/config.py b/src/flask/config.py index 86f21dc8..c79a558e 100644 --- a/src/flask/config.py +++ b/src/flask/config.py @@ -202,6 +202,31 @@ class Config(dict): return self.from_mapping(obj) + def from_json(self, filename: str, silent: bool = False) -> bool: + """Update the values in the config from a JSON file. The loaded + data is passed to the :meth:`from_mapping` method. + + :param filename: The path to the JSON file. This can be an + absolute path or relative to the config root path. + :param silent: Ignore the file if it doesn't exist. + + .. deprecated:: 2.0.0 + Will be removed in Flask 2.1. Use :meth:`from_file` instead. + This was removed early in 2.0.0, was added back in 2.0.1. + + .. versionadded:: 0.11 + """ + import warnings + from . import json + + warnings.warn( + "'from_json' is deprecated and will be removed in Flask" + " 2.1. Use 'from_file(path, json.load)' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self.from_file(filename, json.load, silent=silent) + def from_mapping( self, mapping: t.Optional[t.Mapping[str, t.Any]] = None, **kwargs: t.Any ) -> bool: diff --git a/src/flask/ctx.py b/src/flask/ctx.py index 70de8cad..5c064635 100644 --- a/src/flask/ctx.py +++ b/src/flask/ctx.py @@ -41,6 +41,24 @@ class _AppCtxGlobals: .. versionadded:: 0.10 """ + # Define attr methods to let mypy know this is a namespace object + # that has arbitrary attributes. + + def __getattr__(self, name: str) -> t.Any: + try: + return self.__dict__[name] + except KeyError: + raise AttributeError(name) from None + + def __setattr__(self, name: str, value: t.Any) -> None: + self.__dict__[name] = value + + def __delattr__(self, name: str) -> None: + try: + del self.__dict__[name] + except KeyError: + raise AttributeError(name) from None + def get(self, name: str, default: t.Optional[t.Any] = None) -> t.Any: """Get an attribute by name, or a default value. Like :meth:`dict.get`. @@ -78,10 +96,10 @@ class _AppCtxGlobals: """ return self.__dict__.setdefault(name, default) - def __contains__(self, item: t.Any) -> bool: + def __contains__(self, item: str) -> bool: return item in self.__dict__ - def __iter__(self) -> t.Iterator: + def __iter__(self) -> t.Iterator[str]: return iter(self.__dict__) def __repr__(self) -> str: @@ -377,9 +395,6 @@ class RequestContext: _request_ctx_stack.push(self) - if self.url_adapter is not None: - self.match_request() - # Open the session at the moment that the request context is available. # This allows a custom open_session method to use the request context. # Only open a new session if this is the first time the request was @@ -391,6 +406,11 @@ class RequestContext: if self.session is None: self.session = session_interface.make_null_session(self.app) + # Match the request URL after loading the session, so that the + # session is available in custom URL converters. + if self.url_adapter is not None: + self.match_request() + def pop(self, exc: t.Optional[BaseException] = _sentinel) -> None: # type: ignore """Pops the request context and unbinds it by doing that. This will also trigger the execution of functions registered by the diff --git a/src/flask/globals.py b/src/flask/globals.py index 5e6e8c75..6d91c75e 100644 --- a/src/flask/globals.py +++ b/src/flask/globals.py @@ -6,7 +6,7 @@ from werkzeug.local import LocalStack if t.TYPE_CHECKING: from .app import Flask - from .ctx import AppContext + from .ctx import _AppCtxGlobals from .sessions import SessionMixin from .wrappers import Request @@ -53,5 +53,7 @@ _request_ctx_stack = LocalStack() _app_ctx_stack = LocalStack() current_app: "Flask" = LocalProxy(_find_app) # type: ignore request: "Request" = LocalProxy(partial(_lookup_req_object, "request")) # type: ignore -session: "SessionMixin" = LocalProxy(partial(_lookup_req_object, "session")) # type: ignore # noqa: B950 -g: "AppContext" = LocalProxy(partial(_lookup_app_object, "g")) # type: ignore +session: "SessionMixin" = LocalProxy( # type: ignore + partial(_lookup_req_object, "session") +) +g: "_AppCtxGlobals" = LocalProxy(partial(_lookup_app_object, "g")) # type: ignore diff --git a/src/flask/helpers.py b/src/flask/helpers.py index f7d37ed7..7b8b0870 100644 --- a/src/flask/helpers.py +++ b/src/flask/helpers.py @@ -4,14 +4,14 @@ import socket import sys import typing as t import warnings +from datetime import datetime from datetime import timedelta +from functools import lru_cache from functools import update_wrapper -from functools import wraps from threading import RLock import werkzeug.utils from werkzeug.exceptions import NotFound -from werkzeug.local import ContextVar from werkzeug.routing import BuildError from werkzeug.urls import url_quote @@ -64,8 +64,10 @@ def get_load_dotenv(default: bool = True) -> bool: def stream_with_context( - generator_or_function: t.Union[t.Generator, t.Callable] -) -> t.Generator: + generator_or_function: t.Union[ + t.Iterator[t.AnyStr], t.Callable[..., t.Iterator[t.AnyStr]] + ] +) -> t.Iterator[t.AnyStr]: """Request contexts disappear when the response is started on the server. This is done for efficiency reasons and to make it less likely to encounter memory leaks with badly written WSGI middlewares. The downside is that if @@ -438,14 +440,16 @@ def get_flashed_messages( def _prepare_send_file_kwargs( - download_name=None, - attachment_filename=None, - etag=None, - add_etags=None, - max_age=None, - cache_timeout=None, - **kwargs, -): + download_name: t.Optional[str] = None, + attachment_filename: t.Optional[str] = None, + etag: t.Optional[t.Union[bool, str]] = None, + add_etags: t.Optional[t.Union[bool]] = None, + max_age: t.Optional[ + t.Union[int, t.Callable[[t.Optional[str]], t.Optional[int]]] + ] = None, + cache_timeout: t.Optional[int] = None, + **kwargs: t.Any, +) -> t.Dict[str, t.Any]: if attachment_filename is not None: warnings.warn( "The 'attachment_filename' parameter has been renamed to" @@ -484,23 +488,25 @@ def _prepare_send_file_kwargs( max_age=max_age, use_x_sendfile=current_app.use_x_sendfile, response_class=current_app.response_class, - _root_path=current_app.root_path, + _root_path=current_app.root_path, # type: ignore ) return kwargs def send_file( - path_or_file, - mimetype=None, - as_attachment=False, - download_name=None, - attachment_filename=None, - conditional=True, - etag=True, - add_etags=None, - last_modified=None, - max_age=None, - cache_timeout=None, + path_or_file: t.Union[os.PathLike, str, t.BinaryIO], + mimetype: t.Optional[str] = None, + as_attachment: bool = False, + download_name: t.Optional[str] = None, + attachment_filename: t.Optional[str] = None, + conditional: bool = True, + etag: t.Union[bool, str] = True, + add_etags: t.Optional[bool] = None, + last_modified: t.Optional[t.Union[datetime, int, float]] = None, + max_age: t.Optional[ + t.Union[int, t.Callable[[t.Optional[str]], t.Optional[int]]] + ] = None, + cache_timeout: t.Optional[int] = None, ): """Send the contents of a file to the client. @@ -644,7 +650,12 @@ def safe_join(directory: str, *pathnames: str) -> str: return path -def send_from_directory(directory: str, path: str, **kwargs: t.Any) -> "Response": +def send_from_directory( + directory: t.Union[os.PathLike, str], + path: t.Union[os.PathLike, str], + filename: t.Optional[str] = None, + **kwargs: t.Any, +) -> "Response": """Send a file from within a directory using :func:`send_file`. .. code-block:: python @@ -668,12 +679,24 @@ def send_from_directory(directory: str, path: str, **kwargs: t.Any) -> "Response ``directory``. :param kwargs: Arguments to pass to :func:`send_file`. + .. versionchanged:: 2.0 + ``path`` replaces the ``filename`` parameter. + .. versionadded:: 2.0 Moved the implementation to Werkzeug. This is now a wrapper to pass some Flask-specific arguments. .. versionadded:: 0.5 """ + if filename is not None: + warnings.warn( + "The 'filename' parameter has been renamed to 'path'. The" + " old name will be removed in Flask 2.1.", + DeprecationWarning, + stacklevel=2, + ) + path = filename + return werkzeug.utils.send_from_directory( # type: ignore directory, path, **_prepare_send_file_kwargs(**kwargs) ) @@ -803,49 +826,11 @@ def is_ip(value: str) -> bool: return False -def run_async(func: t.Callable[..., t.Coroutine]) -> t.Callable[..., t.Any]: - """Return a sync function that will run the coroutine function *func*.""" - try: - from asgiref.sync import async_to_sync - except ImportError: - raise RuntimeError( - "Install Flask with the 'async' extra in order to use async views." - ) +@lru_cache(maxsize=None) +def _split_blueprint_path(name: str) -> t.List[str]: + out: t.List[str] = [name] - # Check that Werkzeug isn't using its fallback ContextVar class. - if ContextVar.__module__ == "werkzeug.local": - raise RuntimeError( - "Async cannot be used with this combination of Python & Greenlet versions." - ) + if "." in name: + out.extend(_split_blueprint_path(name.rpartition(".")[0])) - @wraps(func) - def outer(*args: t.Any, **kwargs: t.Any) -> t.Any: - """This function grabs the current context for the inner function. - - This is similar to the copy_current_xxx_context functions in the - ctx module, except it has an async inner. - """ - ctx = None - - if _request_ctx_stack.top is not None: - ctx = _request_ctx_stack.top.copy() - - @wraps(func) - async def inner(*a: t.Any, **k: t.Any) -> t.Any: - """This restores the context before awaiting the func. - - This is required as the function must be awaited within the - context. Only calling ``func`` (as per the - ``copy_current_xxx_context`` functions) doesn't work as the - with block will close before the coroutine is awaited. - """ - if ctx is not None: - with ctx: - return await func(*a, **k) - else: - return await func(*a, **k) - - return async_to_sync(inner)(*args, **kwargs) - - outer._flask_sync_wrapper = True # type: ignore - return outer + return out diff --git a/src/flask/json/__init__.py b/src/flask/json/__init__.py index 5a6e4942..5780e204 100644 --- a/src/flask/json/__init__.py +++ b/src/flask/json/__init__.py @@ -5,7 +5,7 @@ import uuid import warnings from datetime import date -from jinja2.utils import htmlsafe_json_dumps as _jinja_htmlsafe_dumps # type: ignore +from jinja2.utils import htmlsafe_json_dumps as _jinja_htmlsafe_dumps from werkzeug.http import http_date from ..globals import current_app diff --git a/src/flask/scaffold.py b/src/flask/scaffold.py index d5b72162..454b76dc 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,12 +29,15 @@ 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() +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) -def setupmethod(f: t.Callable) -> t.Callable: + +def setupmethod(f: F) -> F: """Wraps a method so that it performs a check in debug mode if the first request was already handled. """ @@ -53,7 +56,7 @@ def setupmethod(f: t.Callable) -> t.Callable: ) return f(self, *args, **kwargs) - return update_wrapper(wrapper_func, f) + return t.cast(F, update_wrapper(wrapper_func, f)) class Scaffold: @@ -142,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 @@ -288,7 +294,7 @@ class Scaffold: self._static_url_path = value - def get_send_file_max_age(self, filename: str) -> t.Optional[int]: + def get_send_file_max_age(self, filename: t.Optional[str]) -> t.Optional[int]: """Used by :func:`send_file` to determine the ``max_age`` cache value for a given file path if it wasn't passed. @@ -446,7 +452,7 @@ class Scaffold: view_func: t.Optional[t.Callable] = None, provide_automatic_options: t.Optional[bool] = None, **options: t.Any, - ) -> t.Callable: + ) -> None: """Register a rule for routing incoming requests and building URLs. The :meth:`route` decorator is a shortcut to call this with the ``view_func`` argument. These are equivalent: @@ -524,7 +530,7 @@ class Scaffold: """ def decorator(f): - self.view_functions[endpoint] = self.ensure_sync(f) + self.view_functions[endpoint] = f return f return decorator @@ -548,7 +554,7 @@ class Scaffold: return value from the view, and further request handling is stopped. """ - self.before_request_funcs.setdefault(None, []).append(self.ensure_sync(f)) + self.before_request_funcs.setdefault(None, []).append(f) return f @setupmethod @@ -564,7 +570,7 @@ class Scaffold: should not be used for actions that must execute, such as to close resources. Use :meth:`teardown_request` for that. """ - self.after_request_funcs.setdefault(None, []).append(self.ensure_sync(f)) + self.after_request_funcs.setdefault(None, []).append(f) return f @setupmethod @@ -603,7 +609,7 @@ class Scaffold: debugger can still access it. This behavior can be controlled by the ``PRESERVE_CONTEXT_ON_EXCEPTION`` configuration variable. """ - self.teardown_request_funcs.setdefault(None, []).append(self.ensure_sync(f)) + self.teardown_request_funcs.setdefault(None, []).append(f) return f @setupmethod @@ -644,8 +650,11 @@ class Scaffold: @setupmethod def errorhandler( - self, code_or_exception: t.Union[t.Type[Exception], int] - ) -> t.Callable: + 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 @@ -675,7 +684,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 @@ -684,8 +695,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 @@ -709,7 +720,9 @@ class Scaffold: " instead." ) - self.error_handler_spec[None][code][exc_class] = self.ensure_sync(f) + self.error_handler_spec[None][code][exc_class] = t.cast( + "ErrorHandlerCallable[Exception]", f + ) @staticmethod def _get_exc_class_and_code( @@ -737,9 +750,6 @@ class Scaffold: else: return exc_class, None - def ensure_sync(self, func: t.Callable) -> t.Callable: - raise NotImplementedError() - def _endpoint_from_view_func(view_func: t.Callable) -> str: """Internal helper that returns the default endpoint for a given diff --git a/src/flask/sessions.py b/src/flask/sessions.py index 0e68e884..34b1d0ce 100644 --- a/src/flask/sessions.py +++ b/src/flask/sessions.py @@ -12,6 +12,7 @@ from .helpers import is_ip from .json.tag import TaggedJSONSerializer if t.TYPE_CHECKING: + import typing_extensions as te from .app import Flask from .wrappers import Request, Response @@ -92,7 +93,7 @@ class NullSession(SecureCookieSession): but fail on setting. """ - def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: + def _fail(self, *args: t.Any, **kwargs: t.Any) -> "te.NoReturn": raise RuntimeError( "The session is unavailable because no secret " "key was set. Set the secret_key on the " diff --git a/src/flask/templating.py b/src/flask/templating.py index 1987d9e9..bb3e7fd5 100644 --- a/src/flask/templating.py +++ b/src/flask/templating.py @@ -51,18 +51,21 @@ class DispatchingJinjaLoader(BaseLoader): def __init__(self, app: "Flask") -> None: self.app = app - def get_source( + def get_source( # type: ignore self, environment: Environment, template: str - ) -> t.Tuple[str, t.Optional[str], t.Callable]: + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable]]: if self.app.config["EXPLAIN_TEMPLATE_LOADING"]: return self._get_source_explained(environment, template) return self._get_source_fast(environment, template) def _get_source_explained( self, environment: Environment, template: str - ) -> t.Tuple[str, t.Optional[str], t.Callable]: + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable]]: attempts = [] - trv = None + rv: t.Optional[t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]]] + trv: t.Optional[ + t.Tuple[str, t.Optional[str], t.Optional[t.Callable[[], bool]]] + ] = None for srcobj, loader in self._iter_loaders(template): try: @@ -83,7 +86,7 @@ class DispatchingJinjaLoader(BaseLoader): def _get_source_fast( self, environment: Environment, template: str - ) -> t.Tuple[str, t.Optional[str], t.Callable]: + ) -> t.Tuple[str, t.Optional[str], t.Optional[t.Callable]]: for _srcobj, loader in self._iter_loaders(template): try: return loader.get_source(environment, template) diff --git a/src/flask/typing.py b/src/flask/typing.py index e583217f..8bca2228 100644 --- a/src/flask/typing.py +++ b/src/flask/typing.py @@ -2,8 +2,8 @@ import typing as t if t.TYPE_CHECKING: + from _typeshed.wsgi import WSGIApplication # noqa: F401 from werkzeug.datastructures import Headers # noqa: F401 - from wsgiref.types import WSGIApplication # noqa: F401 from .wrappers import Response # noqa: F401 from .views import View # noqa: F401 @@ -34,15 +34,25 @@ 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"] -BeforeRequestCallable = t.Callable[[], None] -ErrorHandlerCallable = t.Callable[[Exception], ResponseReturnValue] -TeardownCallable = t.Callable[[t.Optional[BaseException]], "Response"] +BeforeFirstRequestCallable = t.Callable[[], None] +BeforeRequestCallable = t.Callable[[], t.Optional[ResponseReturnValue]] +TeardownCallable = t.Callable[[t.Optional[BaseException]], None] TemplateContextProcessorCallable = t.Callable[[], t.Dict[str, t.Any]] -TemplateFilterCallable = t.Callable[[t.Any], str] -TemplateGlobalCallable = t.Callable[[], t.Any] -TemplateTestCallable = t.Callable[[t.Any], bool] +TemplateFilterCallable = t.Callable[..., t.Any] +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] ViewFuncArgument = t.Optional[t.Union[t.Callable, t.Type["View"]]] + + +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/src/flask/wrappers.py b/src/flask/wrappers.py index 48fcc34b..47dbe5c8 100644 --- a/src/flask/wrappers.py +++ b/src/flask/wrappers.py @@ -6,8 +6,10 @@ from werkzeug.wrappers import Response as ResponseBase from . import json from .globals import current_app +from .helpers import _split_blueprint_path if t.TYPE_CHECKING: + import typing_extensions as te from werkzeug.routing import Rule @@ -58,23 +60,54 @@ class Request(RequestBase): @property def endpoint(self) -> t.Optional[str]: - """The endpoint that matched the request. This in combination with - :attr:`view_args` can be used to reconstruct the same or a - modified URL. If an exception happened when matching, this will - be ``None``. + """The endpoint that matched the request URL. + + This will be ``None`` if matching failed or has not been + performed yet. + + This in combination with :attr:`view_args` can be used to + reconstruct the same URL or a modified URL. """ if self.url_rule is not None: return self.url_rule.endpoint - else: - return None + + return None @property def blueprint(self) -> t.Optional[str]: - """The name of the current blueprint""" - if self.url_rule and "." in self.url_rule.endpoint: - return self.url_rule.endpoint.rsplit(".", 1)[0] - else: - return None + """The registered name of the current blueprint. + + This will be ``None`` if the endpoint is not part of a + blueprint, or if URL matching failed or has not been performed + yet. + + This does not necessarily match the name the blueprint was + created with. It may have been nested, or registered with a + different name. + """ + endpoint = self.endpoint + + if endpoint is not None and "." in endpoint: + return endpoint.rpartition(".")[0] + + return None + + @property + def blueprints(self) -> t.List[str]: + """The registered names of the current blueprint upwards through + parent blueprints. + + This will be an empty list if there is no current blueprint, or + if URL matching failed. + + .. versionadded:: 2.0.1 + """ + name = self.blueprint + + if name is None: + return [] + + return _split_blueprint_path(name) def _load_form_data(self) -> None: RequestBase._load_form_data(self) @@ -91,7 +124,7 @@ class Request(RequestBase): attach_enctype_error_multidict(self) - def on_json_loading_failed(self, e: Exception) -> t.NoReturn: + def on_json_loading_failed(self, e: Exception) -> "te.NoReturn": if current_app and current_app.debug: raise BadRequest(f"Failed to decode JSON object: {e}") diff --git a/tests/test_async.py b/tests/test_async.py index 8c096f69..8276c4a8 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -6,7 +6,8 @@ import pytest from flask import Blueprint from flask import Flask from flask import request -from flask.helpers import run_async +from flask.views import MethodView +from flask.views import View pytest.importorskip("asgiref") @@ -19,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__) @@ -54,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) @@ -136,5 +158,6 @@ def test_async_before_after_request(): @pytest.mark.skipif(sys.version_info >= (3, 7), reason="should only raise Python < 3.7") def test_async_runtime_error(): + app = Flask(__name__) with pytest.raises(RuntimeError): - run_async(None) + app.async_to_sync(None) diff --git a/tests/test_basic.py b/tests/test_basic.py index d6ec3fe4..2cb96794 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1448,7 +1448,6 @@ def test_static_url_empty_path_default(app): rv.close() -@pytest.mark.skipif(sys.version_info < (3, 6), reason="requires Python >= 3.6") def test_static_folder_with_pathlib_path(app): from pathlib import Path @@ -1631,7 +1630,7 @@ def test_url_processors(app, client): def test_inject_blueprint_url_defaults(app): - bp = flask.Blueprint("foo.bar.baz", __name__, template_folder="template") + bp = flask.Blueprint("foo", __name__, template_folder="template") @bp.url_defaults def bp_defaults(endpoint, values): @@ -1644,12 +1643,12 @@ def test_inject_blueprint_url_defaults(app): app.register_blueprint(bp) values = dict() - app.inject_url_defaults("foo.bar.baz.view", values) + app.inject_url_defaults("foo.view", values) expected = dict(page="login") assert values == expected with app.test_request_context("/somepage"): - url = flask.url_for("foo.bar.baz.view") + url = flask.url_for("foo.view") expected = "/login" assert url == expected diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py index b986ca02..a124c612 100644 --- a/tests/test_blueprints.py +++ b/tests/test_blueprints.py @@ -1,5 +1,3 @@ -import functools - import pytest from jinja2 import TemplateNotFound from werkzeug.http import parse_cache_control_header @@ -142,7 +140,7 @@ def test_blueprint_url_defaults(app, client): return str(bar) app.register_blueprint(bp, url_prefix="/1", url_defaults={"bar": 23}) - app.register_blueprint(bp, url_prefix="/2", url_defaults={"bar": 19}) + app.register_blueprint(bp, name="test2", url_prefix="/2", url_defaults={"bar": 19}) assert client.get("/1/foo").data == b"23/42" assert client.get("/2/foo").data == b"19/42" @@ -253,28 +251,9 @@ def test_templates_list(test_apps): assert templates == ["admin/index.html", "frontend/index.html"] -def test_dotted_names(app, client): - frontend = flask.Blueprint("myapp.frontend", __name__) - backend = flask.Blueprint("myapp.backend", __name__) - - @frontend.route("/fe") - def frontend_index(): - return flask.url_for("myapp.backend.backend_index") - - @frontend.route("/fe2") - def frontend_page2(): - return flask.url_for(".frontend_index") - - @backend.route("/be") - def backend_index(): - return flask.url_for("myapp.frontend.frontend_index") - - app.register_blueprint(frontend) - app.register_blueprint(backend) - - assert client.get("/fe").data.strip() == b"/be" - assert client.get("/fe2").data.strip() == b"/fe" - assert client.get("/be").data.strip() == b"/fe" +def test_dotted_name_not_allowed(app, client): + with pytest.raises(ValueError): + flask.Blueprint("app.ui", __name__) def test_dotted_names_from_app(app, client): @@ -343,62 +322,19 @@ def test_route_decorator_custom_endpoint(app, client): def test_route_decorator_custom_endpoint_with_dots(app, client): bp = flask.Blueprint("bp", __name__) - @bp.route("/foo") - def foo(): - return flask.request.endpoint + with pytest.raises(ValueError): + bp.route("/", endpoint="a.b")(lambda: "") - try: + with pytest.raises(ValueError): + bp.add_url_rule("/", endpoint="a.b") - @bp.route("/bar", endpoint="bar.bar") - def foo_bar(): - return flask.request.endpoint + def view(): + return "" - except AssertionError: - pass - else: - raise AssertionError("expected AssertionError not raised") + view.__name__ = "a.b" - try: - - @bp.route("/bar/123", endpoint="bar.123") - def foo_bar_foo(): - return flask.request.endpoint - - except AssertionError: - pass - else: - raise AssertionError("expected AssertionError not raised") - - def foo_foo_foo(): - pass - - pytest.raises( - AssertionError, - lambda: bp.add_url_rule("/bar/123", endpoint="bar.123", view_func=foo_foo_foo), - ) - - pytest.raises( - AssertionError, bp.route("/bar/123", endpoint="bar.123"), lambda: None - ) - - foo_foo_foo.__name__ = "bar.123" - - pytest.raises( - AssertionError, lambda: bp.add_url_rule("/bar/123", view_func=foo_foo_foo) - ) - - bp.add_url_rule( - "/bar/456", endpoint="foofoofoo", view_func=functools.partial(foo_foo_foo) - ) - - app.register_blueprint(bp, url_prefix="/py") - - assert client.get("/py/foo").data == b"bp.foo" - # The rule's didn't actually made it through - rv = client.get("/py/bar") - assert rv.status_code == 404 - rv = client.get("/py/bar/123") - assert rv.status_code == 404 + with pytest.raises(ValueError): + bp.add_url_rule("/", view_func=view) def test_endpoint_decorator(app, client): @@ -899,3 +835,89 @@ def test_nested_blueprint(app, client): assert client.get("/parent/no").data == b"Parent no" assert client.get("/parent/child/no").data == b"Parent no" assert client.get("/parent/child/grandchild/no").data == b"Grandchild no" + + +@pytest.mark.parametrize( + "parent_init, child_init, parent_registration, child_registration", + [ + ("/parent", "/child", None, None), + ("/parent", None, None, "/child"), + (None, None, "/parent", "/child"), + ("/other", "/something", "/parent", "/child"), + ], +) +def test_nesting_url_prefixes( + parent_init, + child_init, + parent_registration, + child_registration, + app, + client, +) -> None: + parent = flask.Blueprint("parent", __name__, url_prefix=parent_init) + child = flask.Blueprint("child", __name__, url_prefix=child_init) + + @child.route("/") + def index(): + return "index" + + parent.register_blueprint(child, url_prefix=child_registration) + app.register_blueprint(parent, url_prefix=parent_registration) + + response = client.get("/parent/child/") + assert response.status_code == 200 + + +def test_unique_blueprint_names(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + bp2 = flask.Blueprint("bp", __name__) + + app.register_blueprint(bp) + + with pytest.warns(UserWarning): + app.register_blueprint(bp) # same bp, same name, warning + + app.register_blueprint(bp, name="again") # same bp, different name, ok + + with pytest.raises(ValueError): + app.register_blueprint(bp2) # different bp, same name, error + + app.register_blueprint(bp2, name="alt") # different bp, different name, ok + + +def test_self_registration(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + with pytest.raises(ValueError): + bp.register_blueprint(bp) + + +def test_blueprint_renaming(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + bp2 = flask.Blueprint("bp2", __name__) + + @bp.get("/") + 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 + + bp.register_blueprint(bp2, url_prefix="/a", name="sub") + app.register_blueprint(bp, url_prefix="/a") + app.register_blueprint(bp, url_prefix="/b", name="alt") + + assert client.get("/a/").data == b"bp.index" + 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" diff --git a/tests/test_cli.py b/tests/test_cli.py index c3a00f17..ebf8d1f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ import ssl import sys import types from functools import partial +from pathlib import Path from unittest.mock import patch import click @@ -29,8 +30,8 @@ from flask.cli import run_command from flask.cli import ScriptInfo from flask.cli import with_appcontext -cwd = os.getcwd() -test_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "test_apps")) +cwd = Path.cwd() +test_path = (Path(__file__) / ".." / "test_apps").resolve() @pytest.fixture @@ -152,29 +153,25 @@ def test_find_best_app(test_apps): ( ("test", cwd, "test"), ("test.py", cwd, "test"), - ("a/test", os.path.join(cwd, "a"), "test"), + ("a/test", cwd / "a", "test"), ("test/__init__.py", cwd, "test"), ("test/__init__", cwd, "test"), # nested package ( - os.path.join(test_path, "cliapp", "inner1", "__init__"), + test_path / "cliapp" / "inner1" / "__init__", test_path, "cliapp.inner1", ), ( - os.path.join(test_path, "cliapp", "inner1", "inner2"), + test_path / "cliapp" / "inner1" / "inner2", test_path, "cliapp.inner1.inner2", ), # dotted name ("test.a.b", cwd, "test.a.b"), - (os.path.join(test_path, "cliapp.app"), test_path, "cliapp.app"), + (test_path / "cliapp.app", test_path, "cliapp.app"), # not a Python file, will be caught during import - ( - os.path.join(test_path, "cliapp", "message.txt"), - test_path, - "cliapp.message.txt", - ), + (test_path / "cliapp" / "message.txt", test_path, "cliapp.message.txt"), ), ) def test_prepare_import(request, value, path, result): @@ -193,7 +190,7 @@ def test_prepare_import(request, value, path, result): request.addfinalizer(reset_path) assert prepare_import(value) == result - assert sys.path[0] == path + assert sys.path[0] == str(path) @pytest.mark.parametrize( @@ -278,9 +275,8 @@ def test_scriptinfo(test_apps, monkeypatch): assert obj.load_app() is app # import app with module's absolute path - cli_app_path = os.path.abspath( - os.path.join(os.path.dirname(__file__), "test_apps", "cliapp", "app.py") - ) + cli_app_path = str(test_path / "cliapp" / "app.py") + obj = ScriptInfo(app_import_path=cli_app_path) app = obj.load_app() assert app.name == "testapp" @@ -302,19 +298,13 @@ def test_scriptinfo(test_apps, monkeypatch): pytest.raises(NoAppException, obj.load_app) # import app from wsgi.py in current directory - monkeypatch.chdir( - os.path.abspath( - os.path.join(os.path.dirname(__file__), "test_apps", "helloworld") - ) - ) + monkeypatch.chdir(test_path / "helloworld") obj = ScriptInfo() app = obj.load_app() assert app.name == "hello" # import app from app.py in current directory - monkeypatch.chdir( - os.path.abspath(os.path.join(os.path.dirname(__file__), "test_apps", "cliapp")) - ) + monkeypatch.chdir(test_path / "cliapp") obj = ScriptInfo() app = obj.load_app() assert app.name == "testapp" @@ -513,7 +503,7 @@ def test_load_dotenv(monkeypatch): monkeypatch.setenv("EGGS", "3") monkeypatch.chdir(test_path) assert load_dotenv() - assert os.getcwd() == test_path + assert Path.cwd() == test_path # .flaskenv doesn't overwrite .env assert os.environ["FOO"] == "env" # set only in .flaskenv @@ -533,9 +523,8 @@ def test_dotenv_path(monkeypatch): for item in ("FOO", "BAR", "EGGS"): monkeypatch._setitem.append((os.environ, item, notset)) - cwd = os.getcwd() - load_dotenv(os.path.join(test_path, ".flaskenv")) - assert os.getcwd() == cwd + load_dotenv(test_path / ".flaskenv") + assert Path.cwd() == cwd assert "FOO" in os.environ diff --git a/tests/test_converters.py b/tests/test_converters.py index 14c92afd..d94a7658 100644 --- a/tests/test_converters.py +++ b/tests/test_converters.py @@ -1,6 +1,7 @@ from werkzeug.routing import BaseConverter -from flask import has_request_context +from flask import request +from flask import session from flask import url_for @@ -28,12 +29,13 @@ def test_custom_converters(app, client): def test_context_available(app, client): class ContextConverter(BaseConverter): def to_python(self, value): - assert has_request_context() + assert request is not None + assert session is not None return value app.url_map.converters["ctx"] = ContextConverter - @app.route("/") + @app.get("/") def index(name): return name diff --git a/tests/test_session_interface.py b/tests/test_session_interface.py index aa9ecafa..39562f5a 100644 --- a/tests/test_session_interface.py +++ b/tests/test_session_interface.py @@ -2,21 +2,26 @@ import flask from flask.sessions import SessionInterface -def test_open_session_endpoint_not_none(): - # Define a session interface that breaks if request.endpoint is None +def test_open_session_with_endpoint(): + """If request.endpoint (or other URL matching behavior) is needed + while loading the session, RequestContext.match_request() can be + called manually. + """ + class MySessionInterface(SessionInterface): - def save_session(self): + def save_session(self, app, session, response): pass - def open_session(self, _, request): + def open_session(self, app, request): + flask._request_ctx_stack.top.match_request() assert request.endpoint is not None - def index(): - return "Hello World!" - - # Confirm a 200 response, indicating that request.endpoint was NOT None app = flask.Flask(__name__) - app.route("/")(index) app.session_interface = MySessionInterface() - response = app.test_client().open("/") + + @app.get("/") + def index(): + return "Hello, World!" + + response = app.test_client().get("/") assert response.status_code == 200 diff --git a/tox.ini b/tox.ini index 9c772d38..66d1658c 100644 --- a/tox.ini +++ b/tox.ini @@ -11,12 +11,6 @@ skip_missing_interpreters = true deps = -r requirements/tests.txt - https://github.com/pallets/werkzeug/archive/master.tar.gz - https://github.com/pallets/markupsafe/archive/master.tar.gz - https://github.com/pallets/jinja/archive/master.tar.gz - https://github.com/pallets/itsdangerous/archive/master.tar.gz - - !click7: https://github.com/pallets/click/archive/master.tar.gz click7: click<8 examples/tutorial[test] @@ -30,11 +24,8 @@ commands = pre-commit run --all-files --show-diff-on-failure [testenv:typing] deps = -r requirements/typing.txt -commands = mypy +commands = mypy --install-types --non-interactive [testenv:docs] -deps = - -r requirements/docs.txt - - https://github.com/pallets/werkzeug/archive/master.tar.gz +deps = -r requirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html