Format: text/x-python
⬇ Download File (9.9 KB)
   1  from __future__ import annotations
   2  
   3  import importlib.metadata
   4  import typing as t
   5  from contextlib import contextmanager
   6  from contextlib import ExitStack
   7  from copy import copy
   8  from types import TracebackType
   9  from urllib.parse import urlsplit
  10  
  11  import werkzeug.test
  12  from click.testing import CliRunner
  13  from werkzeug.test import Client
  14  from werkzeug.wrappers import Request as BaseRequest
  15  
  16  from .cli import ScriptInfo
  17  from .sessions import SessionMixin
  18  
  19  if t.TYPE_CHECKING:  # pragma: no cover
  20      from _typeshed.wsgi import WSGIEnvironment
  21      from werkzeug.test import TestResponse
  22  
  23      from .app import Flask
  24  
  25  
  26  class EnvironBuilder(werkzeug.test.EnvironBuilder):
  27      """An :class:`~werkzeug.test.EnvironBuilder`, that takes defaults from the
  28      application.
  29  
  30      :param app: The Flask application to configure the environment from.
  31      :param path: URL path being requested.
  32      :param base_url: Base URL where the app is being served, which
  33          ``path`` is relative to. If not given, built from
  34          :data:`PREFERRED_URL_SCHEME`, ``subdomain``,
  35          :data:`SERVER_NAME`, and :data:`APPLICATION_ROOT`.
  36      :param subdomain: Subdomain name to append to :data:`SERVER_NAME`.
  37      :param url_scheme: Scheme to use instead of
  38          :data:`PREFERRED_URL_SCHEME`.
  39      :param json: If given, this is serialized as JSON and passed as
  40          ``data``. Also defaults ``content_type`` to
  41          ``application/json``.
  42      :param args: other positional arguments passed to
  43          :class:`~werkzeug.test.EnvironBuilder`.
  44      :param kwargs: other keyword arguments passed to
  45          :class:`~werkzeug.test.EnvironBuilder`.
  46      """
  47  
  48      def __init__(
  49          self,
  50          app: Flask,
  51          path: str = "/",
  52          base_url: str | None = None,
  53          subdomain: str | None = None,
  54          url_scheme: str | None = None,
  55          *args: t.Any,
  56          **kwargs: t.Any,
  57      ) -> None:
  58          assert not (base_url or subdomain or url_scheme) or (
  59              base_url is not None
  60          ) != bool(
  61              subdomain or url_scheme
  62          ), 'Cannot pass "subdomain" or "url_scheme" with "base_url".'
  63  
  64          if base_url is None:
  65              http_host = app.config.get("SERVER_NAME") or "localhost"
  66              app_root = app.config["APPLICATION_ROOT"]
  67  
  68              if subdomain:
  69                  http_host = f"{subdomain}.{http_host}"
  70  
  71              if url_scheme is None:
  72                  url_scheme = app.config["PREFERRED_URL_SCHEME"]
  73  
  74              url = urlsplit(path)
  75              base_url = (
  76                  f"{url.scheme or url_scheme}://{url.netloc or http_host}"
  77                  f"/{app_root.lstrip('/')}"
  78              )
  79              path = url.path
  80  
  81              if url.query:
  82                  path = f"{path}?{url.query}"
  83  
  84          self.app = app
  85          super().__init__(path, base_url, *args, **kwargs)
  86  
  87      def json_dumps(self, obj: t.Any, **kwargs: t.Any) -> str:  # type: ignore
  88          """Serialize ``obj`` to a JSON-formatted string.
  89  
  90          The serialization will be configured according to the config associated
  91          with this EnvironBuilder's ``app``.
  92          """
  93          return self.app.json.dumps(obj, **kwargs)
  94  
  95  
  96  _werkzeug_version = ""
  97  
  98  
  99  def _get_werkzeug_version() -> str:
 100      global _werkzeug_version
 101  
 102      if not _werkzeug_version:
 103          _werkzeug_version = importlib.metadata.version("werkzeug")
 104  
 105      return _werkzeug_version
 106  
 107  
 108  class FlaskClient(Client):
 109      """Works like a regular Werkzeug test client but has knowledge about
 110      Flask's contexts to defer the cleanup of the request context until
 111      the end of a ``with`` block. For general information about how to
 112      use this class refer to :class:`werkzeug.test.Client`.
 113  
 114      .. versionchanged:: 0.12
 115         `app.test_client()` includes preset default environment, which can be
 116         set after instantiation of the `app.test_client()` object in
 117         `client.environ_base`.
 118  
 119      Basic usage is outlined in the :doc:`/testing` chapter.
 120      """
 121  
 122      application: Flask
 123  
 124      def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
 125          super().__init__(*args, **kwargs)
 126          self.preserve_context = False
 127          self._new_contexts: list[t.ContextManager[t.Any]] = []
 128          self._context_stack = ExitStack()
 129          self.environ_base = {
 130              "REMOTE_ADDR": "127.0.0.1",
 131              "HTTP_USER_AGENT": f"Werkzeug/{_get_werkzeug_version()}",
 132          }
 133  
 134      @contextmanager
 135      def session_transaction(
 136          self, *args: t.Any, **kwargs: t.Any
 137      ) -> t.Iterator[SessionMixin]:
 138          """When used in combination with a ``with`` statement this opens a
 139          session transaction.  This can be used to modify the session that
 140          the test client uses.  Once the ``with`` block is left the session is
 141          stored back.
 142  
 143          ::
 144  
 145              with client.session_transaction() as session:
 146                  session['value'] = 42
 147  
 148          Internally this is implemented by going through a temporary test
 149          request context and since session handling could depend on
 150          request variables this function accepts the same arguments as
 151          :meth:`~flask.Flask.test_request_context` which are directly
 152          passed through.
 153          """
 154          if self._cookies is None:
 155              raise TypeError(
 156                  "Cookies are disabled. Create a client with 'use_cookies=True'."
 157              )
 158  
 159          app = self.application
 160          ctx = app.test_request_context(*args, **kwargs)
 161          self._add_cookies_to_wsgi(ctx.request.environ)
 162  
 163          with ctx:
 164              sess = app.session_interface.open_session(app, ctx.request)
 165  
 166          if sess is None:
 167              raise RuntimeError("Session backend did not open a session.")
 168  
 169          yield sess
 170          resp = app.response_class()
 171  
 172          if app.session_interface.is_null_session(sess):
 173              return
 174  
 175          with ctx:
 176              app.session_interface.save_session(app, sess, resp)
 177  
 178          self._update_cookies_from_response(
 179              ctx.request.host.partition(":")[0],
 180              ctx.request.path,
 181              resp.headers.getlist("Set-Cookie"),
 182          )
 183  
 184      def _copy_environ(self, other: WSGIEnvironment) -> WSGIEnvironment:
 185          out = {**self.environ_base, **other}
 186  
 187          if self.preserve_context:
 188              out["werkzeug.debug.preserve_context"] = self._new_contexts.append
 189  
 190          return out
 191  
 192      def _request_from_builder_args(
 193          self, args: tuple[t.Any, ...], kwargs: dict[str, t.Any]
 194      ) -> BaseRequest:
 195          kwargs["environ_base"] = self._copy_environ(kwargs.get("environ_base", {}))
 196          builder = EnvironBuilder(self.application, *args, **kwargs)
 197  
 198          try:
 199              return builder.get_request()
 200          finally:
 201              builder.close()
 202  
 203      def open(
 204          self,
 205          *args: t.Any,
 206          buffered: bool = False,
 207          follow_redirects: bool = False,
 208          **kwargs: t.Any,
 209      ) -> TestResponse:
 210          if args and isinstance(
 211              args[0], (werkzeug.test.EnvironBuilder, dict, BaseRequest)
 212          ):
 213              if isinstance(args[0], werkzeug.test.EnvironBuilder):
 214                  builder = copy(args[0])
 215                  builder.environ_base = self._copy_environ(builder.environ_base or {})  # type: ignore[arg-type]
 216                  request = builder.get_request()
 217              elif isinstance(args[0], dict):
 218                  request = EnvironBuilder.from_environ(
 219                      args[0], app=self.application, environ_base=self._copy_environ({})
 220                  ).get_request()
 221              else:
 222                  # isinstance(args[0], BaseRequest)
 223                  request = copy(args[0])
 224                  request.environ = self._copy_environ(request.environ)
 225          else:
 226              # request is None
 227              request = self._request_from_builder_args(args, kwargs)
 228  
 229          # Pop any previously preserved contexts. This prevents contexts
 230          # from being preserved across redirects or multiple requests
 231          # within a single block.
 232          self._context_stack.close()
 233  
 234          response = super().open(
 235              request,
 236              buffered=buffered,
 237              follow_redirects=follow_redirects,
 238          )
 239          response.json_module = self.application.json  # type: ignore[assignment]
 240  
 241          # Re-push contexts that were preserved during the request.
 242          while self._new_contexts:
 243              cm = self._new_contexts.pop()
 244              self._context_stack.enter_context(cm)
 245  
 246          return response
 247  
 248      def __enter__(self) -> FlaskClient:
 249          if self.preserve_context:
 250              raise RuntimeError("Cannot nest client invocations")
 251          self.preserve_context = True
 252          return self
 253  
 254      def __exit__(
 255          self,
 256          exc_type: type | None,
 257          exc_value: BaseException | None,
 258          tb: TracebackType | None,
 259      ) -> None:
 260          self.preserve_context = False
 261          self._context_stack.close()
 262  
 263  
 264  class FlaskCliRunner(CliRunner):
 265      """A :class:`~click.testing.CliRunner` for testing a Flask app's
 266      CLI commands. Typically created using
 267      :meth:`~flask.Flask.test_cli_runner`. See :ref:`testing-cli`.
 268      """
 269  
 270      def __init__(self, app: Flask, **kwargs: t.Any) -> None:
 271          self.app = app
 272          super().__init__(**kwargs)
 273  
 274      def invoke(  # type: ignore
 275          self, cli: t.Any = None, args: t.Any = None, **kwargs: t.Any
 276      ) -> t.Any:
 277          """Invokes a CLI command in an isolated environment. See
 278          :meth:`CliRunner.invoke <click.testing.CliRunner.invoke>` for
 279          full method documentation. See :ref:`testing-cli` for examples.
 280  
 281          If the ``obj`` argument is not given, passes an instance of
 282          :class:`~flask.cli.ScriptInfo` that knows how to load the Flask
 283          app being tested.
 284  
 285          :param cli: Command object to invoke. Default is the app's
 286              :attr:`~flask.app.Flask.cli` group.
 287          :param args: List of strings to invoke the command with.
 288  
 289          :return: a :class:`~click.testing.Result` object.
 290          """
 291          if cli is None:
 292              cli = self.app.cli
 293  
 294          if "obj" not in kwargs:
 295              kwargs["obj"] = ScriptInfo(create_app=lambda: self.app)
 296  
 297          return super().invoke(cli, args, **kwargs)
 298