testing.py 10 KB

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