-
Notifications
You must be signed in to change notification settings - Fork 10
/
flask_webtest.py
334 lines (266 loc) · 12.3 KB
/
flask_webtest.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# coding: utf-8
import importlib.metadata
from http import cookiejar
from copy import copy
from functools import partial
from contextlib import contextmanager, nullcontext
from werkzeug.local import LocalStack
from flask import g, session, get_flashed_messages
from flask.signals import template_rendered, request_started, request_finished
from webtest import (TestApp as BaseTestApp,
TestRequest as BaseTestRequest,
TestResponse as BaseTestResponse)
flask_version = importlib.metadata.version('flask')
try:
import flask_sqlalchemy
except ImportError:
flask_sqlalchemy = None
try:
# Available starting with Flask 0.10
from flask.signals import message_flashed
except ImportError:
message_flashed = None
_session_scope_stack = LocalStack()
class SessionScope(object):
"""Session scope, being pushed, changes the value of
:func:`.scopefunc` and, as a result, calls to `db.session`
are proxied to the new underlying session.
When popped, removes the current session and swap the value of
:func:`.scopefunc` to the one that was before.
:param db: :class:`flask_sqlalchemy.SQLAlchemy` instance
"""
def __init__(self, db):
self.db = db
def push(self):
"""Pushes the session scope."""
_session_scope_stack.push(self)
def pop(self):
"""Removes the current session and pops the session scope."""
self.db.session.remove()
rv = _session_scope_stack.pop()
assert rv is self, 'Popped wrong session scope. (%r instead of %r)' \
% (rv, self)
def __enter__(self):
self.push()
return self
def __exit__(self, exc_type, exc_value, tb):
self.pop()
def get_scopefunc(original_scopefunc=None):
"""Returns :func:`.SessionScope`-aware `scopefunc` that has to be used
during testing.
"""
if original_scopefunc is None:
assert flask_sqlalchemy, 'Is Flask-SQLAlchemy installed?'
try:
# for flask_sqlalchemy older than 2.2 where the connection_stack
# was either the app stack or the request stack
original_scopefunc = flask_sqlalchemy.connection_stack.__ident_func__
except AttributeError:
try:
# when flask_sqlalchemy 2.2 or newer, which supports only flask 0.10
# or newer, we use app stack
from flask import _app_ctx_stack
original_scopefunc = _app_ctx_stack.__ident_func__
except (AttributeError, ImportError):
# flask 3.0.0 or newer does not export _app_ctx_stack
# newer flask does not expose an __ident_func__, use greenlet directly
import greenlet
original_scopefunc = greenlet.getcurrent
def scopefunc():
rv = original_scopefunc()
sqlalchemy_scope = _session_scope_stack.top
if sqlalchemy_scope:
rv = (rv, id(sqlalchemy_scope))
return rv
return scopefunc
def store_rendered_template(app, template, context, **extra):
g._flask_webtest.setdefault('contexts', []).append((template.name, context))
def store_flashed_message(app, message, category, **extra):
g._flask_webtest.setdefault('flashes', []).append((category, message))
def set_up(app, *args, **extra):
g._flask_webtest = {}
if not message_flashed:
def _get_flashed_messages(*args, **kwargs):
# `get_flashed_messages` removes messages from session,
# so we store them in `g._flask_webtest`
flashes_to_be_consumed = copy(session.get('_flashes', []))
g._flask_webtest.setdefault('flashes', []).extend(flashes_to_be_consumed)
return get_flashed_messages(*args, **kwargs)
app.jinja_env.globals['get_flashed_messages'] = _get_flashed_messages
def tear_down(store, app, response, *args, **extra):
g._flask_webtest['session'] = dict(session)
store.update(g._flask_webtest)
del g._flask_webtest
if not message_flashed:
app.jinja_env.globals['get_flashed_messages'] = get_flashed_messages
class TestResponse(BaseTestResponse):
contexts = {}
def _make_contexts_assertions(self):
assert self.contexts, 'No templates used to render the response.'
assert len(self.contexts) == 1, \
('More than one template used to render the response. '
'Use `contexts` attribute to access their names and contexts.')
@property
def context(self):
self._make_contexts_assertions()
return list(self.contexts.values())[0]
@property
def template(self):
self._make_contexts_assertions()
return list(self.contexts.keys())[0]
class TestRequest(BaseTestRequest):
ResponseClass = TestResponse
class CookieJar(cookiejar.CookieJar):
"""CookieJar that always sets ASCII headers, even if cookies have
unicode parts such as name, value or path. It is necessary to make
:meth:`TestApp.session_transaction` work correctly.
"""
def _cookie_attrs(self, cookies):
attrs = cookiejar.CookieJar._cookie_attrs(self, cookies)
return map(str, attrs)
class TestApp(BaseTestApp):
"""Extends :class:`webtest.TestApp` by adding few fields to responses:
.. attribute:: templates
Dictionary containing information about what templates were used to
build the response and what their contexts were.
The keys are template names and the values are template contexts.
.. attribute:: flashes
List of tuples (category, message) containing messages that were
flashed during request.
Note: Fully supported only starting with Flask 0.10. If you use
previous version, `flashes` will contain only those messages that
were consumed by :func:`flask.get_flashed_messages` template calls.
.. attribute:: session
Dictionary that contains session data.
If exactly one template was used to render the response, it's name and context
can be accessed using `response.template` and `response.context` properties.
If `app` config sets SERVER_NAME and HTTP_HOST is not specified in
`extra_environ`, :class:`TestApp` will also set HTTP_HOST to SERVER_NAME
for all requests to the app.
:param app: :class:`flask.Flask` instance
:param db: :class:`flask_sqlalchemy.SQLAlchemy` instance
:param use_session_scopes: if specified, application performs each request
within it's own separate session scope
"""
RequestClass = TestRequest
def __init__(self, app, db=None, use_session_scopes=False, cookiejar=None,
extra_environ=None, *args, **kwargs):
if use_session_scopes:
assert db, ('`db` (instance of `flask_sqlalchemy.SQLAlchemy`) '
'must be passed to use session scopes.')
self.db = db
self.use_session_scopes = use_session_scopes
if extra_environ is None:
extra_environ = {}
if app.config['SERVER_NAME'] and 'HTTP_HOST' not in extra_environ:
extra_environ['HTTP_HOST'] = app.config['SERVER_NAME']
super(TestApp, self).__init__(app, extra_environ=extra_environ,
*args, **kwargs)
# cookielib.CookieJar defines __len__ and empty CookieJar evaluates
# to False in boolan context. That's why we explicitly compare
# `cookiejar` with None:
self.cookiejar = CookieJar() if cookiejar is None else cookiejar
def do_request(self, *args, **kwargs):
store = {}
tear_down_ = partial(tear_down, store)
request_started.connect(set_up)
request_finished.connect(tear_down_)
template_rendered.connect(store_rendered_template)
if message_flashed:
message_flashed.connect(store_flashed_message)
if self.use_session_scopes:
scope = SessionScope(self.db)
scope.push()
context = nullcontext
if self.app.config.get('FLASK_WEBTEST_PUSH_APP_CONTEXT', False):
context = self.app.app_context
try:
with context():
response = super(TestApp, self).do_request(*args, **kwargs)
finally:
if self.use_session_scopes:
scope.pop()
template_rendered.disconnect(store_rendered_template)
request_finished.disconnect(tear_down_)
request_started.disconnect(set_up)
if message_flashed:
message_flashed.disconnect(store_flashed_message)
response.session = store.get('session', {})
response.flashes = store.get('flashes', [])
response.contexts = dict(store.get('contexts', []))
return response
def set_werkzeug_cookie(self, name, value, domain, path):
"""
As of Werkzeug 2.3.0, cookie implementation was refactored, and cookies
no longer have the same footprint as http.cookiejar.Cookie. But, webtest
expects the http-lib cookies to set up the test request.
Do some basic translation here for any cookies set in a session transaction.
"""
# Match what webtest.set_cookie() does for the domain or we can end up with "duplicate"
# cookies with different domains when using session_transaction()
if '.' not in domain:
domain = "%s.local" % domain
if flask_version.startswith('2.2.') and not domain.startswith('.'):
# Flask 2.3 dropped the leading dot for cookie domains, but we still need it for < 2.3
domain = f'.{domain}'
cookie = cookiejar.Cookie(
version=0,
name=name,
value=value,
port=None,
port_specified=False,
domain=domain,
domain_specified=True,
domain_initial_dot=False,
path=path,
path_specified=True,
secure=False,
expires=None,
discard=False,
comment=None,
comment_url=None,
rest=None
)
self.cookiejar.set_cookie(cookie)
@contextmanager
def session_transaction(self):
"""When used in combination with a with statement this opens
a session transaction. This can be used to modify the session
that the test client uses. Once the with block is left the session
is stored back.
For example, if you use Flask-Login, you can log in a user using
this method::
with client.session_transaction() as sess:
sess['user_id'] = 1
Internally it uses :meth:`flask.testing.FlaskClient.session_transaction`.
"""
with self.app.test_client() as client:
translate_werkzeug_cookie = hasattr(client, 'get_cookie')
for cookie in self.cookiejar:
if translate_werkzeug_cookie:
client.set_cookie(
cookie.name,
value=cookie.value,
# http.cookiejar has code everywhere that normalizes "localhost" to
# localhost.local everywhere. But, Flask/Werkzeug just use "localhost".
# If this isn't changed, then FlaskClient looks for cookies that match
# "localhost.local" and "localhost" doesn't match that. This results in
# losing the existing session (if there is one).
domain='localhost' if cookie.domain == 'localhost.local' else cookie.domain,
path=cookie.path,
)
else:
client.cookie_jar.set_cookie(cookie)
with client.session_transaction() as sess:
yield sess
if translate_werkzeug_cookie:
for cookie in client._cookies.values():
self.set_werkzeug_cookie(cookie.key, cookie.value, cookie.domain, cookie.path)
else:
for cookie in client.cookie_jar:
# Cookies from `client.cookie_jar` may contain unicode name
# and value. It would make WebTest linter (:mod:`webtest.lint`)
# throw assertion errors about unicode environmental
# variable (HTTP_COOKIE), but we use custom CookieJar that is
# aware of this oddity and always sets 8-bit headers.
self.cookiejar.set_cookie(cookie)