Skip to content

Commit 84c7220

Browse files
committed
new debug/test preserve context implementation
1 parent 3635583 commit 84c7220

File tree

10 files changed

+84
-220
lines changed

10 files changed

+84
-220
lines changed

CHANGES.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ Unreleased
4242
them in a ``Response``. :pr:`4629`
4343
- Add ``stream_template`` and ``stream_template_string`` functions to
4444
render a template as a stream of pieces. :pr:`4629`
45+
- A new implementation of context preservation during debugging and
46+
testing. :pr:`4666`
47+
48+
- ``request``, ``g``, and other context-locals point to the
49+
correct data when running code in the interactive debugger
50+
console. :issue:`2836`
51+
- Teardown functions are always run at the end of the request,
52+
even if the context is preserved. They are also run after the
53+
preserved context is popped.
54+
- ``stream_with_context`` preserves context separately from a
55+
``with client`` block. It will be cleaned up when
56+
``response.get_data()`` or ``response.close()`` is called.
4557

4658

4759
Version 2.1.3

docs/config.rst

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,6 @@ The following configuration values are used internally by Flask:
126126

127127
Default: ``None``
128128

129-
.. py:data:: PRESERVE_CONTEXT_ON_EXCEPTION
130-
131-
Don't pop the request context when an exception occurs. If not set, this
132-
is true if ``DEBUG`` is true. This allows debuggers to introspect the
133-
request data on errors, and should normally not need to be set directly.
134-
135-
Default: ``None``
136-
137129
.. py:data:: TRAP_HTTP_EXCEPTIONS
138130
139131
If there is no handler for an ``HTTPException``-type exception, re-raise it
@@ -392,6 +384,9 @@ The following configuration values are used internally by Flask:
392384

393385
Added :data:`MAX_COOKIE_SIZE` to control a warning from Werkzeug.
394386

387+
.. versionchanged:: 2.2
388+
Removed ``PRESERVE_CONTEXT_ON_EXCEPTION``.
389+
395390

396391
Configuring from Python Files
397392
-----------------------------

docs/reqcontext.rst

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -219,25 +219,6 @@ sent:
219219
:meth:`~Flask.teardown_request` functions are called.
220220

221221

222-
Context Preservation on Error
223-
-----------------------------
224-
225-
At the end of a request, the request context is popped and all data
226-
associated with it is destroyed. If an error occurs during development,
227-
it is useful to delay destroying the data for debugging purposes.
228-
229-
When the development server is running in development mode (the
230-
``--env`` option is set to ``'development'``), the error and data will
231-
be preserved and shown in the interactive debugger.
232-
233-
This behavior can be controlled with the
234-
:data:`PRESERVE_CONTEXT_ON_EXCEPTION` config. As described above, it
235-
defaults to ``True`` in the development environment.
236-
237-
Do not enable :data:`PRESERVE_CONTEXT_ON_EXCEPTION` in production, as it
238-
will cause your application to leak memory on exceptions.
239-
240-
241222
.. _notes-on-proxies:
242223

243224
Notes On Proxies

src/flask/app.py

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,6 @@ class Flask(Scaffold):
331331
"DEBUG": None,
332332
"TESTING": False,
333333
"PROPAGATE_EXCEPTIONS": None,
334-
"PRESERVE_CONTEXT_ON_EXCEPTION": None,
335334
"SECRET_KEY": None,
336335
"PERMANENT_SESSION_LIFETIME": timedelta(days=31),
337336
"USE_X_SENDFILE": False,
@@ -583,19 +582,6 @@ def propagate_exceptions(self) -> bool:
583582
return rv
584583
return self.testing or self.debug
585584

586-
@property
587-
def preserve_context_on_exception(self) -> bool:
588-
"""Returns the value of the ``PRESERVE_CONTEXT_ON_EXCEPTION``
589-
configuration value in case it's set, otherwise a sensible default
590-
is returned.
591-
592-
.. versionadded:: 0.7
593-
"""
594-
rv = self.config["PRESERVE_CONTEXT_ON_EXCEPTION"]
595-
if rv is not None:
596-
return rv
597-
return self.debug
598-
599585
@locked_cached_property
600586
def logger(self) -> logging.Logger:
601587
"""A standard Python :class:`~logging.Logger` for the app, with
@@ -2301,9 +2287,14 @@ def wsgi_app(self, environ: dict, start_response: t.Callable) -> t.Any:
23012287
raise
23022288
return response(environ, start_response)
23032289
finally:
2304-
if self.should_ignore_error(error):
2290+
if "werkzeug.debug.preserve_context" in environ:
2291+
environ["werkzeug.debug.preserve_context"](_app_ctx_stack.top)
2292+
environ["werkzeug.debug.preserve_context"](_request_ctx_stack.top)
2293+
2294+
if error is not None and self.should_ignore_error(error):
23052295
error = None
2306-
ctx.auto_pop(error)
2296+
2297+
ctx.pop(error)
23072298

23082299
def __call__(self, environ: dict, start_response: t.Callable) -> t.Any:
23092300
"""The WSGI server calls the Flask application object as the

src/flask/ctx.py

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -289,20 +289,12 @@ class RequestContext:
289289
functions registered on the application for teardown execution
290290
(:meth:`~flask.Flask.teardown_request`).
291291
292-
The request context is automatically popped at the end of the request
293-
for you. In debug mode the request context is kept around if
294-
exceptions happen so that interactive debuggers have a chance to
295-
introspect the data. With 0.4 this can also be forced for requests
296-
that did not fail and outside of ``DEBUG`` mode. By setting
297-
``'flask._preserve_context'`` to ``True`` on the WSGI environment the
298-
context will not pop itself at the end of the request. This is used by
299-
the :meth:`~flask.Flask.test_client` for example to implement the
300-
deferred cleanup functionality.
301-
302-
You might find this helpful for unittests where you need the
303-
information from the context local around for a little longer. Make
304-
sure to properly :meth:`~werkzeug.LocalStack.pop` the stack yourself in
305-
that situation, otherwise your unittests will leak memory.
292+
The request context is automatically popped at the end of the
293+
request. When using the interactive debugger, the context will be
294+
restored so ``request`` is still accessible. Similarly, the test
295+
client can preserve the context after the request ends. However,
296+
teardown functions may already have closed some resources such as
297+
database connections.
306298
"""
307299

308300
def __init__(
@@ -330,14 +322,6 @@ def __init__(
330322
# one is created implicitly so for each level we add this information
331323
self._implicit_app_ctx_stack: t.List[t.Optional["AppContext"]] = []
332324

333-
# indicator if the context was preserved. Next time another context
334-
# is pushed the preserved context is popped.
335-
self.preserved = False
336-
337-
# remembers the exception for pop if there is one in case the context
338-
# preservation kicks in.
339-
self._preserved_exc = None
340-
341325
# Functions that should be executed after the request on the response
342326
# object. These will be called before the regular "after_request"
343327
# functions.
@@ -400,19 +384,6 @@ def match_request(self) -> None:
400384
self.request.routing_exception = e
401385

402386
def push(self) -> None:
403-
"""Binds the request context to the current context."""
404-
# If an exception occurs in debug mode or if context preservation is
405-
# activated under exception situations exactly one context stays
406-
# on the stack. The rationale is that you want to access that
407-
# information under debug situations. However if someone forgets to
408-
# pop that context again we want to make sure that on the next push
409-
# it's invalidated, otherwise we run at risk that something leaks
410-
# memory. This is usually only a problem in test suite since this
411-
# functionality is not active in production environments.
412-
top = _request_ctx_stack.top
413-
if top is not None and top.preserved:
414-
top.pop(top._preserved_exc)
415-
416387
# Before we push the request context we have to ensure that there
417388
# is an application context.
418389
app_ctx = _app_ctx_stack.top
@@ -454,8 +425,6 @@ def pop(self, exc: t.Optional[BaseException] = _sentinel) -> None: # type: igno
454425

455426
try:
456427
if not self._implicit_app_ctx_stack:
457-
self.preserved = False
458-
self._preserved_exc = None
459428
if exc is _sentinel:
460429
exc = sys.exc_info()[1]
461430
self.app.do_teardown_request(exc)
@@ -481,13 +450,18 @@ def pop(self, exc: t.Optional[BaseException] = _sentinel) -> None: # type: igno
481450
), f"Popped wrong request context. ({rv!r} instead of {self!r})"
482451

483452
def auto_pop(self, exc: t.Optional[BaseException]) -> None:
484-
if self.request.environ.get("flask._preserve_context") or (
485-
exc is not None and self.app.preserve_context_on_exception
486-
):
487-
self.preserved = True
488-
self._preserved_exc = exc # type: ignore
489-
else:
490-
self.pop(exc)
453+
"""
454+
.. deprecated:: 2.2
455+
Will be removed in Flask 2.3.
456+
"""
457+
import warnings
458+
459+
warnings.warn(
460+
"'ctx.auto_pop' is deprecated and will be removed in Flask 2.3.",
461+
DeprecationWarning,
462+
stacklevel=2,
463+
)
464+
self.pop(exc)
491465

492466
def __enter__(self) -> "RequestContext":
493467
self.push()
@@ -499,12 +473,7 @@ def __exit__(
499473
exc_value: t.Optional[BaseException],
500474
tb: t.Optional[TracebackType],
501475
) -> None:
502-
# do not pop the request stack if we are in debug mode and an
503-
# exception happened. This will allow the debugger to still
504-
# access the request object in the interactive shell. Furthermore
505-
# the context can be force kept alive for the test client.
506-
# See flask.testing for how this works.
507-
self.auto_pop(exc_value)
476+
self.pop(exc_value)
508477

509478
def __repr__(self) -> str:
510479
return (

src/flask/scaffold.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -600,13 +600,6 @@ def teardown_request(self, f: ft.TeardownCallable) -> ft.TeardownCallable:
600600
be passed an error object.
601601
602602
The return values of teardown functions are ignored.
603-
604-
.. admonition:: Debug Note
605-
606-
In debug mode Flask will not tear down a request on an exception
607-
immediately. Instead it will keep it alive so that the interactive
608-
debugger can still access it. This behavior can be controlled
609-
by the ``PRESERVE_CONTEXT_ON_EXCEPTION`` configuration variable.
610603
"""
611604
self.teardown_request_funcs.setdefault(None, []).append(f)
612605
return f

src/flask/testing.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import typing as t
22
from contextlib import contextmanager
3+
from contextlib import ExitStack
34
from copy import copy
45
from types import TracebackType
56

@@ -108,10 +109,12 @@ class FlaskClient(Client):
108109
"""
109110

110111
application: "Flask"
111-
preserve_context = False
112112

113113
def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
114114
super().__init__(*args, **kwargs)
115+
self.preserve_context = False
116+
self._new_contexts: t.List[t.ContextManager[t.Any]] = []
117+
self._context_stack = ExitStack()
115118
self.environ_base = {
116119
"REMOTE_ADDR": "127.0.0.1",
117120
"HTTP_USER_AGENT": f"werkzeug/{werkzeug.__version__}",
@@ -173,11 +176,12 @@ def session_transaction(
173176
self.cookie_jar.extract_wsgi(c.request.environ, headers)
174177

175178
def _copy_environ(self, other):
176-
return {
177-
**self.environ_base,
178-
**other,
179-
"flask._preserve_context": self.preserve_context,
180-
}
179+
out = {**self.environ_base, **other}
180+
181+
if self.preserve_context:
182+
out["werkzeug.debug.preserve_context"] = self._new_contexts.append
183+
184+
return out
181185

182186
def _request_from_builder_args(self, args, kwargs):
183187
kwargs["environ_base"] = self._copy_environ(kwargs.get("environ_base", {}))
@@ -214,12 +218,24 @@ def open(
214218
# request is None
215219
request = self._request_from_builder_args(args, kwargs)
216220

217-
return super().open(
221+
# Pop any previously preserved contexts. This prevents contexts
222+
# from being preserved across redirects or multiple requests
223+
# within a single block.
224+
self._context_stack.close()
225+
226+
response = super().open(
218227
request,
219228
buffered=buffered,
220229
follow_redirects=follow_redirects,
221230
)
222231

232+
# Re-push contexts that were preserved during the request.
233+
while self._new_contexts:
234+
cm = self._new_contexts.pop()
235+
self._context_stack.enter_context(cm)
236+
237+
return response
238+
223239
def __enter__(self) -> "FlaskClient":
224240
if self.preserve_context:
225241
raise RuntimeError("Cannot nest client invocations")
@@ -233,18 +249,7 @@ def __exit__(
233249
tb: t.Optional[TracebackType],
234250
) -> None:
235251
self.preserve_context = False
236-
237-
# Normally the request context is preserved until the next
238-
# request in the same thread comes. When the client exits we
239-
# want to clean up earlier. Pop request contexts until the stack
240-
# is empty or a non-preserved one is found.
241-
while True:
242-
top = _request_ctx_stack.top
243-
244-
if top is not None and top.preserved:
245-
top.pop()
246-
else:
247-
break
252+
self._context_stack.close()
248253

249254

250255
class FlaskCliRunner(CliRunner):

tests/test_basic.py

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -928,13 +928,8 @@ def test_baseexception_error_handling(app, client):
928928
def broken_func():
929929
raise KeyboardInterrupt()
930930

931-
with client:
932-
with pytest.raises(KeyboardInterrupt):
933-
client.get("/")
934-
935-
ctx = flask._request_ctx_stack.top
936-
assert ctx.preserved
937-
assert type(ctx._preserved_exc) is KeyboardInterrupt
931+
with pytest.raises(KeyboardInterrupt):
932+
client.get("/")
938933

939934

940935
def test_before_request_and_routing_errors(app, client):
@@ -1769,57 +1764,6 @@ def for_bar_foo():
17691764
assert client.get("/bar/123").data == b"123"
17701765

17711766

1772-
def test_preserve_only_once(app, client):
1773-
app.debug = True
1774-
1775-
@app.route("/fail")
1776-
def fail_func():
1777-
1 // 0
1778-
1779-
for _x in range(3):
1780-
with pytest.raises(ZeroDivisionError):
1781-
client.get("/fail")
1782-
1783-
assert flask._request_ctx_stack.top is not None
1784-
assert flask._app_ctx_stack.top is not None
1785-
# implicit appctx disappears too
1786-
flask._request_ctx_stack.top.pop()
1787-
assert flask._request_ctx_stack.top is None
1788-
assert flask._app_ctx_stack.top is None
1789-
1790-
1791-
def test_preserve_remembers_exception(app, client):
1792-
app.debug = True
1793-
errors = []
1794-
1795-
@app.route("/fail")
1796-
def fail_func():
1797-
1 // 0
1798-
1799-
@app.route("/success")
1800-
def success_func():
1801-
return "Okay"
1802-
1803-
@app.teardown_request
1804-
def teardown_handler(exc):
1805-
errors.append(exc)
1806-
1807-
# After this failure we did not yet call the teardown handler
1808-
with pytest.raises(ZeroDivisionError):
1809-
client.get("/fail")
1810-
assert errors == []
1811-
1812-
# But this request triggers it, and it's an error
1813-
client.get("/success")
1814-
assert len(errors) == 2
1815-
assert isinstance(errors[0], ZeroDivisionError)
1816-
1817-
# At this point another request does nothing.
1818-
client.get("/success")
1819-
assert len(errors) == 3
1820-
assert errors[1] is None
1821-
1822-
18231767
def test_get_method_on_g(app_ctx):
18241768
assert flask.g.get("x") is None
18251769
assert flask.g.get("x", 11) == 11

0 commit comments

Comments
 (0)