Merge branch '2.1.x'

This commit is contained in:
David Lord 2022-06-06 09:30:30 -07:00
commit 7a2d5fb6df
No known key found for this signature in database
GPG key ID: 7A1C87E3F5BC42A8
11 changed files with 203 additions and 64 deletions

View file

@ -30,6 +30,8 @@ Unreleased
- Inline some optional imports that are only used for certain CLI - Inline some optional imports that are only used for certain CLI
commands. :pr:`4606` commands. :pr:`4606`
- Relax type annotation for ``after_request`` functions. :issue:`4600` - Relax type annotation for ``after_request`` functions. :issue:`4600`
- ``instance_path`` for namespace packages uses the path closest to
the imported submodule. :issue:`4600`
Version 2.1.2 Version 2.1.2

View file

@ -92,7 +92,7 @@ The ``client`` has methods that match the common HTTP request methods,
such as ``client.get()`` and ``client.post()``. They take many arguments such as ``client.get()`` and ``client.post()``. They take many arguments
for building the request; you can find the full documentation in for building the request; you can find the full documentation in
:class:`~werkzeug.test.EnvironBuilder`. Typically you'll use ``path``, :class:`~werkzeug.test.EnvironBuilder`. Typically you'll use ``path``,
``query``, ``headers``, and ``data`` or ``json``. ``query_string``, ``headers``, and ``data`` or ``json``.
To make a request, call the method the request should use with the path To make a request, call the method the request should use with the path
to the route to test. A :class:`~werkzeug.test.TestResponse` is returned to the route to test. A :class:`~werkzeug.test.TestResponse` is returned
@ -108,9 +108,9 @@ provides ``response.text``, or use ``response.get_data(as_text=True)``.
assert b"<h2>Hello, World!</h2>" in response.data assert b"<h2>Hello, World!</h2>" in response.data
Pass a dict ``query={"key": "value", ...}`` to set arguments in the Pass a dict ``query_string={"key": "value", ...}`` to set arguments in
query string (after the ``?`` in the URL). Pass a dict ``headers={}`` the query string (after the ``?`` in the URL). Pass a dict
to set request headers. ``headers={}`` to set request headers.
To send a request body in a POST or PUT request, pass a value to To send a request body in a POST or PUT request, pass a value to
``data``. If raw bytes are passed, that exact body is used. Usually, ``data``. If raw bytes are passed, that exact body is used. Usually,

View file

@ -87,7 +87,7 @@ per-file-ignores =
src/flask/__init__.py: F401 src/flask/__init__.py: F401
[mypy] [mypy]
files = src/flask files = src/flask, tests/typing
python_version = 3.7 python_version = 3.7
show_error_codes = True show_error_codes = True
allow_redefinition = True allow_redefinition = True

View file

@ -1076,7 +1076,7 @@ class Flask(Scaffold):
self, self,
rule: str, rule: str,
endpoint: t.Optional[str] = None, endpoint: t.Optional[str] = None,
view_func: t.Optional[t.Callable] = None, view_func: t.Optional[ft.ViewCallable] = None,
provide_automatic_options: t.Optional[bool] = None, provide_automatic_options: t.Optional[bool] = None,
**options: t.Any, **options: t.Any,
) -> None: ) -> None:
@ -1862,7 +1862,7 @@ class Flask(Scaffold):
if isinstance(rv[1], (Headers, dict, tuple, list)): if isinstance(rv[1], (Headers, dict, tuple, list)):
rv, headers = rv rv, headers = rv
else: else:
rv, status = rv # type: ignore[misc] rv, status = rv # type: ignore[assignment,misc]
# other sized tuples are not allowed # other sized tuples are not allowed
else: else:
raise TypeError( raise TypeError(

View file

@ -392,7 +392,7 @@ class Blueprint(Scaffold):
self, self,
rule: str, rule: str,
endpoint: t.Optional[str] = None, endpoint: t.Optional[str] = None,
view_func: t.Optional[t.Callable] = None, view_func: t.Optional[ft.ViewCallable] = None,
provide_automatic_options: t.Optional[bool] = None, provide_automatic_options: t.Optional[bool] = None,
**options: t.Any, **options: t.Any,
) -> None: ) -> None:
@ -580,12 +580,14 @@ class Blueprint(Scaffold):
return f return f
@setupmethod @setupmethod
def app_errorhandler(self, code: t.Union[t.Type[Exception], int]) -> t.Callable: def app_errorhandler(
self, code: t.Union[t.Type[Exception], int]
) -> t.Callable[[ft.ErrorHandlerDecorator], ft.ErrorHandlerDecorator]:
"""Like :meth:`Flask.errorhandler` but for a blueprint. This """Like :meth:`Flask.errorhandler` but for a blueprint. This
handler is used for all requests, even if outside of the blueprint. handler is used for all requests, even if outside of the blueprint.
""" """
def decorator(f: ft.ErrorHandlerCallable) -> ft.ErrorHandlerCallable: def decorator(f: ft.ErrorHandlerDecorator) -> ft.ErrorHandlerDecorator:
self.record_once(lambda s: s.app.errorhandler(code)(f)) self.record_once(lambda s: s.app.errorhandler(code)(f))
return f return f

View file

@ -1,5 +1,6 @@
import importlib.util import importlib.util
import os import os
import pathlib
import pkgutil import pkgutil
import sys import sys
import typing as t import typing as t
@ -111,7 +112,7 @@ class Scaffold:
self.view_functions: t.Dict[str, t.Callable] = {} self.view_functions: t.Dict[str, t.Callable] = {}
#: A data structure of registered error handlers, in the format #: A data structure of registered error handlers, in the format
#: ``{scope: {code: {class: handler}}}```. The ``scope`` key is #: ``{scope: {code: {class: handler}}}``. The ``scope`` key is
#: the name of a blueprint the handlers are active for, or #: the name of a blueprint the handlers are active for, or
#: ``None`` for all requests. The ``code`` key is the HTTP #: ``None`` for all requests. The ``code`` key is the HTTP
#: status code for ``HTTPException``, or ``None`` for #: status code for ``HTTPException``, or ``None`` for
@ -351,14 +352,16 @@ class Scaffold:
method: str, method: str,
rule: str, rule: str,
options: dict, options: dict,
) -> t.Callable[[F], F]: ) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
if "methods" in options: if "methods" in options:
raise TypeError("Use the 'route' decorator to use the 'methods' argument.") raise TypeError("Use the 'route' decorator to use the 'methods' argument.")
return self.route(rule, methods=[method], **options) return self.route(rule, methods=[method], **options)
@setupmethod @setupmethod
def get(self, rule: str, **options: t.Any) -> t.Callable[[F], F]: def get(
self, rule: str, **options: t.Any
) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
"""Shortcut for :meth:`route` with ``methods=["GET"]``. """Shortcut for :meth:`route` with ``methods=["GET"]``.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -366,7 +369,9 @@ class Scaffold:
return self._method_route("GET", rule, options) return self._method_route("GET", rule, options)
@setupmethod @setupmethod
def post(self, rule: str, **options: t.Any) -> t.Callable[[F], F]: def post(
self, rule: str, **options: t.Any
) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
"""Shortcut for :meth:`route` with ``methods=["POST"]``. """Shortcut for :meth:`route` with ``methods=["POST"]``.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -374,7 +379,9 @@ class Scaffold:
return self._method_route("POST", rule, options) return self._method_route("POST", rule, options)
@setupmethod @setupmethod
def put(self, rule: str, **options: t.Any) -> t.Callable[[F], F]: def put(
self, rule: str, **options: t.Any
) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
"""Shortcut for :meth:`route` with ``methods=["PUT"]``. """Shortcut for :meth:`route` with ``methods=["PUT"]``.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -382,7 +389,9 @@ class Scaffold:
return self._method_route("PUT", rule, options) return self._method_route("PUT", rule, options)
@setupmethod @setupmethod
def delete(self, rule: str, **options: t.Any) -> t.Callable[[F], F]: def delete(
self, rule: str, **options: t.Any
) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
"""Shortcut for :meth:`route` with ``methods=["DELETE"]``. """Shortcut for :meth:`route` with ``methods=["DELETE"]``.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -390,7 +399,9 @@ class Scaffold:
return self._method_route("DELETE", rule, options) return self._method_route("DELETE", rule, options)
@setupmethod @setupmethod
def patch(self, rule: str, **options: t.Any) -> t.Callable[[F], F]: def patch(
self, rule: str, **options: t.Any
) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
"""Shortcut for :meth:`route` with ``methods=["PATCH"]``. """Shortcut for :meth:`route` with ``methods=["PATCH"]``.
.. versionadded:: 2.0 .. versionadded:: 2.0
@ -398,7 +409,9 @@ class Scaffold:
return self._method_route("PATCH", rule, options) return self._method_route("PATCH", rule, options)
@setupmethod @setupmethod
def route(self, rule: str, **options: t.Any) -> t.Callable[[F], F]: def route(
self, rule: str, **options: t.Any
) -> t.Callable[[ft.RouteDecorator], ft.RouteDecorator]:
"""Decorate a view function to register it with the given URL """Decorate a view function to register it with the given URL
rule and options. Calls :meth:`add_url_rule`, which has more rule and options. Calls :meth:`add_url_rule`, which has more
details about the implementation. details about the implementation.
@ -422,7 +435,7 @@ class Scaffold:
:class:`~werkzeug.routing.Rule` object. :class:`~werkzeug.routing.Rule` object.
""" """
def decorator(f: F) -> F: def decorator(f: ft.RouteDecorator) -> ft.RouteDecorator:
endpoint = options.pop("endpoint", None) endpoint = options.pop("endpoint", None)
self.add_url_rule(rule, endpoint, f, **options) self.add_url_rule(rule, endpoint, f, **options)
return f return f
@ -434,7 +447,7 @@ class Scaffold:
self, self,
rule: str, rule: str,
endpoint: t.Optional[str] = None, endpoint: t.Optional[str] = None,
view_func: t.Optional[t.Callable] = None, view_func: t.Optional[ft.ViewCallable] = None,
provide_automatic_options: t.Optional[bool] = None, provide_automatic_options: t.Optional[bool] = None,
**options: t.Any, **options: t.Any,
) -> None: ) -> None:
@ -637,7 +650,7 @@ class Scaffold:
@setupmethod @setupmethod
def errorhandler( def errorhandler(
self, code_or_exception: t.Union[t.Type[Exception], int] self, code_or_exception: t.Union[t.Type[Exception], int]
) -> t.Callable[[ft.ErrorHandlerCallable], ft.ErrorHandlerCallable]: ) -> t.Callable[[ft.ErrorHandlerDecorator], ft.ErrorHandlerDecorator]:
"""Register a function to handle errors by code or exception class. """Register a function to handle errors by code or exception class.
A decorator that is used to register a function given an A decorator that is used to register a function given an
@ -667,7 +680,7 @@ class Scaffold:
an arbitrary exception an arbitrary exception
""" """
def decorator(f: ft.ErrorHandlerCallable) -> ft.ErrorHandlerCallable: def decorator(f: ft.ErrorHandlerDecorator) -> ft.ErrorHandlerDecorator:
self.register_error_handler(code_or_exception, f) self.register_error_handler(code_or_exception, f)
return f return f
@ -766,30 +779,55 @@ def _matching_loader_thinks_module_is_package(loader, mod_name):
) )
def _find_package_path(root_mod_name): def _path_is_relative_to(path: pathlib.PurePath, base: str) -> bool:
"""Find the path that contains the package or module.""" # Path.is_relative_to doesn't exist until Python 3.9
try: try:
spec = importlib.util.find_spec(root_mod_name) path.relative_to(base)
return True
except ValueError:
return False
if spec is None:
def _find_package_path(import_name):
"""Find the path that contains the package or module."""
root_mod_name, _, _ = import_name.partition(".")
try:
root_spec = importlib.util.find_spec(root_mod_name)
if root_spec is None:
raise ValueError("not found") raise ValueError("not found")
# ImportError: the machinery told us it does not exist # ImportError: the machinery told us it does not exist
# ValueError: # ValueError:
# - the module name was invalid # - the module name was invalid
# - the module name is __main__ # - the module name is __main__
# - *we* raised `ValueError` due to `spec` being `None` # - *we* raised `ValueError` due to `root_spec` being `None`
except (ImportError, ValueError): except (ImportError, ValueError):
pass # handled below pass # handled below
else: else:
# namespace package # namespace package
if spec.origin in {"namespace", None}: if root_spec.origin in {"namespace", None}:
return os.path.dirname(next(iter(spec.submodule_search_locations))) package_spec = importlib.util.find_spec(import_name)
if package_spec is not None and package_spec.submodule_search_locations:
# Pick the path in the namespace that contains the submodule.
package_path = pathlib.Path(
os.path.commonpath(package_spec.submodule_search_locations)
)
search_locations = (
location
for location in root_spec.submodule_search_locations
if _path_is_relative_to(package_path, location)
)
else:
# Pick the first path.
search_locations = iter(root_spec.submodule_search_locations)
return os.path.dirname(next(search_locations))
# a package (with __init__.py) # a package (with __init__.py)
elif spec.submodule_search_locations: elif root_spec.submodule_search_locations:
return os.path.dirname(os.path.dirname(spec.origin)) return os.path.dirname(os.path.dirname(root_spec.origin))
# just a normal module # just a normal module
else: else:
return os.path.dirname(spec.origin) return os.path.dirname(root_spec.origin)
# we were unable to find the `package_path` using PEP 451 loaders # we were unable to find the `package_path` using PEP 451 loaders
loader = pkgutil.get_loader(root_mod_name) loader = pkgutil.get_loader(root_mod_name)
@ -831,12 +869,11 @@ def find_package(import_name: str):
for import. If the package is not installed, it's assumed that the for import. If the package is not installed, it's assumed that the
package was imported from the current working directory. package was imported from the current working directory.
""" """
root_mod_name, _, _ = import_name.partition(".") package_path = _find_package_path(import_name)
package_path = _find_package_path(root_mod_name)
py_prefix = os.path.abspath(sys.prefix) py_prefix = os.path.abspath(sys.prefix)
# installed to the system # installed to the system
if package_path.startswith(py_prefix): if _path_is_relative_to(pathlib.PurePath(package_path), py_prefix):
return py_prefix, package_path return py_prefix, package_path
site_parent, site_folder = os.path.split(package_path) site_parent, site_folder = os.path.split(package_path)

View file

@ -1,37 +1,30 @@
import typing as t import typing as t
if t.TYPE_CHECKING: # pragma: no cover if t.TYPE_CHECKING: # pragma: no cover
from _typeshed.wsgi import WSGIApplication # noqa: F401 from _typeshed.wsgi import WSGIApplication # noqa: F401
from werkzeug.datastructures import Headers # noqa: F401 from werkzeug.datastructures import Headers # noqa: F401
from werkzeug.wrappers import Response # noqa: F401 from werkzeug.wrappers import Response # noqa: F401
# The possible types that are directly convertible or are a Response object. # The possible types that are directly convertible or are a Response object.
ResponseValue = t.Union[ ResponseValue = t.Union["Response", str, bytes, t.Dict[str, t.Any]]
"Response",
str,
bytes,
t.Dict[str, t.Any], # any jsonify-able dict
t.Iterator[str],
t.Iterator[bytes],
]
StatusCode = int
# the possible types for an individual HTTP header # the possible types for an individual HTTP header
HeaderName = str # This should be a Union, but mypy doesn't pass unless it's a TypeVar.
HeaderValue = t.Union[str, t.List[str], t.Tuple[str, ...]] HeaderValue = t.Union[str, t.List[str], t.Tuple[str, ...]]
# the possible types for HTTP headers # the possible types for HTTP headers
HeadersValue = t.Union[ HeadersValue = t.Union[
"Headers", t.Dict[HeaderName, HeaderValue], t.List[t.Tuple[HeaderName, HeaderValue]] "Headers",
t.Mapping[str, HeaderValue],
t.Sequence[t.Tuple[str, HeaderValue]],
] ]
# The possible types returned by a route function. # The possible types returned by a route function.
ResponseReturnValue = t.Union[ ResponseReturnValue = t.Union[
ResponseValue, ResponseValue,
t.Tuple[ResponseValue, HeadersValue], t.Tuple[ResponseValue, HeadersValue],
t.Tuple[ResponseValue, StatusCode], t.Tuple[ResponseValue, int],
t.Tuple[ResponseValue, StatusCode, HeadersValue], t.Tuple[ResponseValue, int, HeadersValue],
"WSGIApplication", "WSGIApplication",
] ]
@ -51,6 +44,7 @@ TemplateGlobalCallable = t.Callable[..., t.Any]
TemplateTestCallable = t.Callable[..., bool] TemplateTestCallable = t.Callable[..., bool]
URLDefaultCallable = t.Callable[[str, dict], None] URLDefaultCallable = t.Callable[[str, dict], None]
URLValuePreprocessorCallable = t.Callable[[t.Optional[str], t.Optional[dict]], None] URLValuePreprocessorCallable = t.Callable[[t.Optional[str], t.Optional[dict]], None]
# This should take Exception, but that either breaks typing the argument # This should take Exception, but that either breaks typing the argument
# with a specific exception, or decorating multiple times with different # with a specific exception, or decorating multiple times with different
# exceptions (and using a union type on the argument). # exceptions (and using a union type on the argument).
@ -58,3 +52,7 @@ URLValuePreprocessorCallable = t.Callable[[t.Optional[str], t.Optional[dict]], N
# https://github.com/pallets/flask/issues/4295 # https://github.com/pallets/flask/issues/4295
# https://github.com/pallets/flask/issues/4297 # https://github.com/pallets/flask/issues/4297
ErrorHandlerCallable = t.Callable[[t.Any], ResponseReturnValue] ErrorHandlerCallable = t.Callable[[t.Any], ResponseReturnValue]
ErrorHandlerDecorator = t.TypeVar("ErrorHandlerDecorator", bound=ErrorHandlerCallable)
ViewCallable = t.Callable[..., ResponseReturnValue]
RouteDecorator = t.TypeVar("RouteDecorator", bound=ViewCallable)

View file

@ -1,4 +1,3 @@
import os
import sys import sys
import pytest import pytest
@ -15,19 +14,6 @@ def test_explicit_instance_paths(modules_tmpdir):
assert app.instance_path == str(modules_tmpdir) assert app.instance_path == str(modules_tmpdir)
@pytest.mark.xfail(reason="weird interaction with tox")
def test_main_module_paths(modules_tmpdir, purge_module):
app = modules_tmpdir.join("main_app.py")
app.write('import flask\n\napp = flask.Flask("__main__")')
purge_module("main_app")
from main_app import app
here = os.path.abspath(os.getcwd())
assert app.instance_path == os.path.join(here, "instance")
@pytest.mark.xfail(reason="weird interaction with tox")
def test_uninstalled_module_paths(modules_tmpdir, purge_module): def test_uninstalled_module_paths(modules_tmpdir, purge_module):
app = modules_tmpdir.join("config_module_app.py").write( app = modules_tmpdir.join("config_module_app.py").write(
"import os\n" "import os\n"
@ -42,7 +28,6 @@ def test_uninstalled_module_paths(modules_tmpdir, purge_module):
assert app.instance_path == str(modules_tmpdir.join("instance")) assert app.instance_path == str(modules_tmpdir.join("instance"))
@pytest.mark.xfail(reason="weird interaction with tox")
def test_uninstalled_package_paths(modules_tmpdir, purge_module): def test_uninstalled_package_paths(modules_tmpdir, purge_module):
app = modules_tmpdir.mkdir("config_package_app") app = modules_tmpdir.mkdir("config_package_app")
init = app.join("__init__.py") init = app.join("__init__.py")
@ -59,6 +44,25 @@ def test_uninstalled_package_paths(modules_tmpdir, purge_module):
assert app.instance_path == str(modules_tmpdir.join("instance")) assert app.instance_path == str(modules_tmpdir.join("instance"))
def test_uninstalled_namespace_paths(tmpdir, monkeypatch, purge_module):
def create_namespace(package):
project = tmpdir.join(f"project-{package}")
monkeypatch.syspath_prepend(str(project))
project.join("namespace").join(package).join("__init__.py").write(
"import flask\napp = flask.Flask(__name__)\n", ensure=True
)
return project
_ = create_namespace("package1")
project2 = create_namespace("package2")
purge_module("namespace.package2")
purge_module("namespace")
from namespace.package2 import app
assert app.instance_path == str(project2.join("instance"))
def test_installed_module_paths( def test_installed_module_paths(
modules_tmpdir, modules_tmpdir_prefix, purge_module, site_packages, limit_loader modules_tmpdir, modules_tmpdir_prefix, purge_module, site_packages, limit_loader
): ):

View file

@ -0,0 +1,33 @@
from __future__ import annotations
from http import HTTPStatus
from werkzeug.exceptions import BadRequest
from werkzeug.exceptions import NotFound
from flask import Flask
app = Flask(__name__)
@app.errorhandler(400)
@app.errorhandler(HTTPStatus.BAD_REQUEST)
@app.errorhandler(BadRequest)
def handle_400(e: BadRequest) -> str:
return ""
@app.errorhandler(ValueError)
def handle_custom(e: ValueError) -> str:
return ""
@app.errorhandler(ValueError)
def handle_accept_base(e: Exception) -> str:
return ""
@app.errorhandler(BadRequest)
@app.errorhandler(404)
def handle_multiple(e: BadRequest | NotFound) -> str:
return ""

View file

@ -0,0 +1,62 @@
from __future__ import annotations
from http import HTTPStatus
from flask import Flask
from flask import jsonify
from flask.templating import render_template
from flask.views import View
from flask.wrappers import Response
app = Flask(__name__)
@app.route("/str")
def hello_str() -> str:
return "<p>Hello, World!</p>"
@app.route("/bytes")
def hello_bytes() -> bytes:
return b"<p>Hello, World!</p>"
@app.route("/json")
def hello_json() -> Response:
return jsonify({"response": "Hello, World!"})
@app.route("/status")
@app.route("/status/<int:code>")
def tuple_status(code: int = 200) -> tuple[str, int]:
return "hello", code
@app.route("/status-enum")
def tuple_status_enum() -> tuple[str, int]:
return "hello", HTTPStatus.OK
@app.route("/headers")
def tuple_headers() -> tuple[str, dict[str, str]]:
return "Hello, World!", {"Content-Type": "text/plain"}
@app.route("/template")
@app.route("/template/<name>")
def return_template(name: str | None = None) -> str:
return render_template("index.html", name=name)
class RenderTemplateView(View):
def __init__(self: RenderTemplateView, template_name: str) -> None:
self.template_name = template_name
def dispatch_request(self: RenderTemplateView) -> str:
return render_template(self.template_name)
app.add_url_rule(
"/about",
view_func=RenderTemplateView.as_view("about_page", template_name="about.html"),
)

View file

@ -9,6 +9,7 @@ envlist =
skip_missing_interpreters = true skip_missing_interpreters = true
[testenv] [testenv]
envtmpdir = {toxworkdir}/tmp/{envname}
deps = deps =
-r requirements/tests.txt -r requirements/tests.txt
min: -r requirements/tests-pallets-min.txt min: -r requirements/tests-pallets-min.txt