FlaskTester 0.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- FlaskTester-0.9.dist-info/METADATA +126 -0
- FlaskTester-0.9.dist-info/RECORD +5 -0
- FlaskTester-0.9.dist-info/WHEEL +5 -0
- FlaskTester-0.9.dist-info/top_level.txt +1 -0
- FlaskTester.py +373 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: FlaskTester
|
|
3
|
+
Version: 0.9
|
|
4
|
+
Summary: Pytest fixtures for Flask authenticated internal and external tests
|
|
5
|
+
Author-email: Fabien Coelho <flask.tester@coelho.net>
|
|
6
|
+
License: CC0
|
|
7
|
+
Project-URL: repository, https://github.com/zx80/flask-tester
|
|
8
|
+
Project-URL: documentation, https://zx80.github.io/flask-tester/
|
|
9
|
+
Project-URL: issues, https://github.com/zx80/flask-tester/issues
|
|
10
|
+
Project-URL: package, https://pypi.org/project/FlaskTester/
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Environment :: Web Environment
|
|
16
|
+
Classifier: Framework :: Flask
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: flask
|
|
22
|
+
Requires-Dist: pytest
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy ; extra == 'dev'
|
|
25
|
+
Requires-Dist: pyright ; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff ; extra == 'dev'
|
|
27
|
+
Requires-Dist: coverage ; extra == 'dev'
|
|
28
|
+
Requires-Dist: pymarkdownlnt !=0.9.5 ; extra == 'dev'
|
|
29
|
+
Requires-Dist: build ; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine ; extra == 'dev'
|
|
31
|
+
Requires-Dist: wheel ; extra == 'dev'
|
|
32
|
+
Requires-Dist: types-flask ; extra == 'dev'
|
|
33
|
+
Requires-Dist: types-requests ; extra == 'dev'
|
|
34
|
+
Requires-Dist: FlaskSimpleAuth ; extra == 'dev'
|
|
35
|
+
Requires-Dist: passlib ; extra == 'dev'
|
|
36
|
+
Provides-Extra: doc
|
|
37
|
+
Requires-Dist: sphinx ; extra == 'doc'
|
|
38
|
+
Requires-Dist: sphinx-rtd-theme ; extra == 'doc'
|
|
39
|
+
Requires-Dist: sphinx-autoapi ; extra == 'doc'
|
|
40
|
+
Requires-Dist: sphinx-lint ; extra == 'doc'
|
|
41
|
+
|
|
42
|
+
# FlaskTester - fixtures for Flask internal and external pytest tests
|
|
43
|
+
|
|
44
|
+
This package allows to run authenticated tests against a Flask application,
|
|
45
|
+
either with internal Flask tests (aka `test_client`) or external tests (with
|
|
46
|
+
`requests` which performs actual HTTP requests).
|
|
47
|
+
|
|
48
|
+
Only one set of tests is needed, switching from internal to external is
|
|
49
|
+
achieved through environment variables.
|
|
50
|
+
|
|
51
|
+
## Fixtures
|
|
52
|
+
|
|
53
|
+
The package provides two fixtures:
|
|
54
|
+
|
|
55
|
+
- `ft_authenticator` for app authentication, which depends on two environment variables:
|
|
56
|
+
|
|
57
|
+
- `FLASK_TESTER_ALLOW` space-separated list of allowed authentication schemes.
|
|
58
|
+
- `FLASK_TESTER_AUTH` comma-separated list of _login:password_ credentials.
|
|
59
|
+
|
|
60
|
+
- `ft_client` for app testing, which depends on the previous fixture, plus environment
|
|
61
|
+
variables which allow to find the application, at least one must be defined.
|
|
62
|
+
|
|
63
|
+
- `FLASK_TESTER_URL` URL of the running application for external tests.
|
|
64
|
+
- `FLASK_TESTER_APP` package (filename with `.py`) to be imported for the application.
|
|
65
|
+
- the application is expected to be named `app`
|
|
66
|
+
- if not available, look and call for `create_app`
|
|
67
|
+
|
|
68
|
+
The authenticator is available as `_auth` in the client fixture.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import os
|
|
72
|
+
import pytest
|
|
73
|
+
from FlaskTester import ft_authenticator, ft_client
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def api(ft_client):
|
|
77
|
+
# get Calvin's token, assume {"token": "<token-value>"}
|
|
78
|
+
res = ft_client.get("/token", login="calvin", auth="basic", status=200)
|
|
79
|
+
assert res.is_json
|
|
80
|
+
ft_client._auth.setToken("calvin", res.json["token"])
|
|
81
|
+
yield ft_client
|
|
82
|
+
|
|
83
|
+
def test_app(api):
|
|
84
|
+
api.get("/admin", login="calvin", auth="bearer", status=200)
|
|
85
|
+
api.get("/admin", login="calvin", auth="basic", status=200)
|
|
86
|
+
api.get("/admin", login="hobbes", auth="basic", status=401)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Authenticator environment variables can be set from the pytest Python test file by
|
|
90
|
+
assigning them through `os.environ`.
|
|
91
|
+
|
|
92
|
+
## Classes
|
|
93
|
+
|
|
94
|
+
The implementation of these fixtures is based on four classes:
|
|
95
|
+
|
|
96
|
+
- `Authenticator` class to store test credentials.
|
|
97
|
+
|
|
98
|
+
Use `setPass` and `setToken` to add user credentials.
|
|
99
|
+
|
|
100
|
+
- `Client` abstract class to run test, with two implementations.
|
|
101
|
+
|
|
102
|
+
The class provides usual `get`, `post`… per-HTTP-method methods,
|
|
103
|
+
and a more generic `request` method.
|
|
104
|
+
|
|
105
|
+
These methods expect the following named parameters:
|
|
106
|
+
|
|
107
|
+
- `login` for user login to use for authentication.
|
|
108
|
+
- `auth` for the authentication scheme to use for this request,
|
|
109
|
+
otherwise allowed schemes are tried, with tokens first.
|
|
110
|
+
- `status` for the expected HTTP status code.
|
|
111
|
+
|
|
112
|
+
- `FlaskClient` implementation class for internal tests.
|
|
113
|
+
- `RequestClient` implementation class for external (real HTTP) tests.
|
|
114
|
+
|
|
115
|
+
## TODO
|
|
116
|
+
|
|
117
|
+
- API documentation generation
|
|
118
|
+
- coverage tests
|
|
119
|
+
- useless but decorative badges
|
|
120
|
+
- pypi distribution
|
|
121
|
+
|
|
122
|
+
## Versions
|
|
123
|
+
|
|
124
|
+
### 0.9 on ?
|
|
125
|
+
|
|
126
|
+
Initial revision extracted from a separate project.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
FlaskTester.py,sha256=QX-i6MZbBLQNCjwoNV1r58jxHZqVvuhWTpAOjHu9wYA,13103
|
|
2
|
+
FlaskTester-0.9.dist-info/METADATA,sha256=Pd4-Z6jemBhriz8zaLz6gdr1nvwBWESysLzLpNEFAj8,4522
|
|
3
|
+
FlaskTester-0.9.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
4
|
+
FlaskTester-0.9.dist-info/top_level.txt,sha256=NbMYh-l3nEJbj5WDGVAfqJt7pTZzVh325WT3qegXoXo,12
|
|
5
|
+
FlaskTester-0.9.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
FlaskTester
|
FlaskTester.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import io
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("flask_tester")
|
|
10
|
+
log.setLevel(level=logging.DEBUG)
|
|
11
|
+
|
|
12
|
+
class FlaskTesterError(BaseException):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(FlaskTesterError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Authenticator:
|
|
21
|
+
"""Manage authentication for test requests.
|
|
22
|
+
|
|
23
|
+
Supported schemes:
|
|
24
|
+
- ``basic``: HTTP Basic Authentication
|
|
25
|
+
- ``param``: password with HTTP or JSON parameters
|
|
26
|
+
- ``bearer``: token in ``Authorization`` _bearer_ header
|
|
27
|
+
- ``header``: token in a header
|
|
28
|
+
- ``cookie``: token in a cookie
|
|
29
|
+
- ``tparam``: token in a parameter
|
|
30
|
+
- ``fake``: fake scheme, login directly passed as a parameter
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_AUTH_SCHEMES = ("basic", "param", "bearer", "header", "cookie", "tparam", "fake")
|
|
34
|
+
|
|
35
|
+
def __init__(self,
|
|
36
|
+
allow: list[str] = ["bearer", "basic", "param"],
|
|
37
|
+
# parameter names for "basic" and "param"
|
|
38
|
+
user: str = "USER",
|
|
39
|
+
pwd: str = "PASS",
|
|
40
|
+
# parameter name for "fake"
|
|
41
|
+
login: str = "LOGIN",
|
|
42
|
+
# 4 token options
|
|
43
|
+
bearer: str = "Bearer",
|
|
44
|
+
header: str = "Auth",
|
|
45
|
+
cookie: str = "auth",
|
|
46
|
+
tparam: str = "AUTH",
|
|
47
|
+
):
|
|
48
|
+
"""Constructor parameters:
|
|
49
|
+
|
|
50
|
+
- ``allow``: list of allowed schemes.
|
|
51
|
+
default is ``["bearer", "basic", "param"]``
|
|
52
|
+
- ``user``: parameter for user on ``param`` password authentication,
|
|
53
|
+
default is ``USER``
|
|
54
|
+
- ``pwd``: parameter for password on ``param`` password authentication,
|
|
55
|
+
default is ``PASS``
|
|
56
|
+
- ``login``: parameter for user on ``fake`` authentication,
|
|
57
|
+
default is ``LOGIN``
|
|
58
|
+
- ``bearer``: name of bearer scheme for token,
|
|
59
|
+
default is ``Bearer``
|
|
60
|
+
- ``header``: name of header for token,
|
|
61
|
+
default is ``Auth``
|
|
62
|
+
- ``cookie``: name of cookie for token,
|
|
63
|
+
default is ``auth``
|
|
64
|
+
- ``tparam``: name of parameter for token,
|
|
65
|
+
default is ``AUTH``
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
for auth in allow:
|
|
69
|
+
assert auth in self._AUTH_SCHEMES
|
|
70
|
+
self._allow = allow
|
|
71
|
+
|
|
72
|
+
# authentication scheme parameters
|
|
73
|
+
self._user = user
|
|
74
|
+
self._pass = pwd
|
|
75
|
+
self._login = login
|
|
76
|
+
self._bearer = bearer
|
|
77
|
+
self._header = header
|
|
78
|
+
self._cookie = cookie
|
|
79
|
+
self._tparam = tparam
|
|
80
|
+
|
|
81
|
+
# password and token credentials
|
|
82
|
+
self._passes: dict[str, str] = {}
|
|
83
|
+
self._tokens: dict[str, str] = {}
|
|
84
|
+
|
|
85
|
+
def _set(self, login: str, val: str|None, store: dict[str, str]):
|
|
86
|
+
"""Set a key/value in a directory, with None for delete."""
|
|
87
|
+
if val is None:
|
|
88
|
+
if login in store:
|
|
89
|
+
del store[login]
|
|
90
|
+
else:
|
|
91
|
+
store[login] = val
|
|
92
|
+
|
|
93
|
+
def setPass(self, login: str, pw: str|None):
|
|
94
|
+
"""Associate a password to a user."""
|
|
95
|
+
self._set(login, pw, self._passes)
|
|
96
|
+
|
|
97
|
+
def setPasses(self, pws: list[str]):
|
|
98
|
+
"""Associate a list of login:password."""
|
|
99
|
+
for lp in pws:
|
|
100
|
+
login, pw = lp.split(":", 1)
|
|
101
|
+
self.setPass(login, pw)
|
|
102
|
+
|
|
103
|
+
def setToken(self, login: str, token: str|None):
|
|
104
|
+
"""Associate a token to a user."""
|
|
105
|
+
self._set(login, token, self._tokens)
|
|
106
|
+
|
|
107
|
+
def _param(self, kwargs: dict[str, Any], key: str, val: Any):
|
|
108
|
+
"""Add request parameter to "json" or "data"."""
|
|
109
|
+
|
|
110
|
+
if "json" in kwargs:
|
|
111
|
+
assert isinstance(kwargs["json"], dict)
|
|
112
|
+
kwargs["json"][key] = val
|
|
113
|
+
elif "data" in kwargs:
|
|
114
|
+
assert isinstance(kwargs["data"], dict)
|
|
115
|
+
kwargs["data"][key] = val
|
|
116
|
+
else:
|
|
117
|
+
kwargs["data"] = {key: val}
|
|
118
|
+
|
|
119
|
+
def _try_auth(self, auth: str|None, scheme: str) -> bool:
|
|
120
|
+
"""Whether to try this authentication scheme."""
|
|
121
|
+
return auth in (None, scheme) and scheme in self._allow
|
|
122
|
+
|
|
123
|
+
def setAuth(self, login: str|None, kwargs: dict[str, Any], auth: str|None = None):
|
|
124
|
+
"""Set request authentication.
|
|
125
|
+
|
|
126
|
+
- login: login target, None means no authentication
|
|
127
|
+
- kwargs: request parameters
|
|
128
|
+
- auth: authentication method, default None is to try allowed schemes, tokens first.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
log.debug(f"setAuth: login={login} auth={auth} allow={self._allow}")
|
|
132
|
+
|
|
133
|
+
if login is None: # not needed
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if auth is not None:
|
|
137
|
+
if auth not in self._AUTH_SCHEMES:
|
|
138
|
+
raise AuthError(f"unexpected auth: {auth}")
|
|
139
|
+
if auth not in self._allow:
|
|
140
|
+
raise AuthError(f"auth is not allowed: {auth}")
|
|
141
|
+
|
|
142
|
+
# use token if available and allowed
|
|
143
|
+
if login in self._tokens and auth in (None, "bearer", "header", "cookie", "tparam"):
|
|
144
|
+
|
|
145
|
+
token = self._tokens[login]
|
|
146
|
+
|
|
147
|
+
if "headers" not in kwargs:
|
|
148
|
+
kwargs["headers"] = {}
|
|
149
|
+
|
|
150
|
+
if self._try_auth(auth, "bearer"):
|
|
151
|
+
kwargs["headers"]["Authorization"] = self._bearer + " " + token
|
|
152
|
+
elif self._try_auth(auth, "header"):
|
|
153
|
+
kwargs["headers"][self._header] = token
|
|
154
|
+
elif self._try_auth(auth, "tparam"):
|
|
155
|
+
self._param(kwargs, self._tparam, token)
|
|
156
|
+
elif self._try_auth(auth, "cookie"):
|
|
157
|
+
# FIXME cookie?
|
|
158
|
+
kwargs["headers"]["Cookie"] = self._cookie + "=" + token
|
|
159
|
+
else:
|
|
160
|
+
raise AuthError(f"no token carrier: login={login} auth={auth} allow={self._allow}")
|
|
161
|
+
|
|
162
|
+
elif login in self._passes and auth in (None, "basic", "param"):
|
|
163
|
+
|
|
164
|
+
if self._try_auth(auth, "basic"):
|
|
165
|
+
kwargs["auth"] = (login, self._passes[login])
|
|
166
|
+
elif self._try_auth(auth, "param"):
|
|
167
|
+
self._param(kwargs, self._user, login)
|
|
168
|
+
self._param(kwargs, self._pass, self._passes[login])
|
|
169
|
+
else:
|
|
170
|
+
raise AuthError(f"no password carrier: login={login} auth={auth} allow={self._allow}")
|
|
171
|
+
|
|
172
|
+
elif self._try_auth(auth, "fake"):
|
|
173
|
+
|
|
174
|
+
self._param(kwargs, self._login, login)
|
|
175
|
+
|
|
176
|
+
else:
|
|
177
|
+
raise AuthError(f"no authentication for login={login} auth={auth} allow={self._allow}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class RequestFlaskResponse:
|
|
181
|
+
"""Wrapper to return a Flask-looking response from a request response.
|
|
182
|
+
|
|
183
|
+
This only work for simple responses.
|
|
184
|
+
|
|
185
|
+
Available attributes:
|
|
186
|
+
- status_code: integer status code
|
|
187
|
+
- data: body as bytes
|
|
188
|
+
- text: body as a string
|
|
189
|
+
- headers: dict of headers and their values
|
|
190
|
+
- cookies: dict of cookies
|
|
191
|
+
- json: JSON-converted body, or None
|
|
192
|
+
- is_json: whether body was in JSON
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def __init__(self, response):
|
|
196
|
+
self._response = response
|
|
197
|
+
self.status_code = response.status_code
|
|
198
|
+
self.data = response.content
|
|
199
|
+
self.text = response.text
|
|
200
|
+
self.headers = response.headers
|
|
201
|
+
self.cookies = response.cookies
|
|
202
|
+
try:
|
|
203
|
+
self.json = response.json()
|
|
204
|
+
self.is_json = True
|
|
205
|
+
except Exception:
|
|
206
|
+
self.json = None
|
|
207
|
+
self.is_json = False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Client:
|
|
211
|
+
"""Common class for flask authenticated testing."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, auth: Authenticator, default_login: str|None = None):
|
|
214
|
+
self._auth = auth
|
|
215
|
+
self._default_login = default_login
|
|
216
|
+
|
|
217
|
+
def setToken(self, login: str, token: str|None):
|
|
218
|
+
"""Associate a token to a login, None to remove."""
|
|
219
|
+
self._auth.setToken(login, token)
|
|
220
|
+
|
|
221
|
+
def setPass(self, login: str, password: str|None):
|
|
222
|
+
"""Associate a password to a login, None to remove."""
|
|
223
|
+
self._auth.setPass(login, password)
|
|
224
|
+
|
|
225
|
+
def _request(self, method: str, path: str, **kwargs):
|
|
226
|
+
"""Run a request and return response."""
|
|
227
|
+
raise NotImplementedError()
|
|
228
|
+
|
|
229
|
+
def request(self, method: str, path: str, status: int|None = None, auth: str|None = None, **kwargs):
|
|
230
|
+
"""Run a possibly authenticated HTTP request.
|
|
231
|
+
|
|
232
|
+
Mandatory parameters:
|
|
233
|
+
- method: HTTP method ("GET", "POST", "PATCH", "DELETE"…)
|
|
234
|
+
- path: local path under the base URL
|
|
235
|
+
|
|
236
|
+
Optional parameters:
|
|
237
|
+
- status: expected HTTP status, None to skip status check
|
|
238
|
+
- login: authenticated user, use **explicit** None to skip
|
|
239
|
+
- auth: authentication schme to use
|
|
240
|
+
- **kwargs: more request parameters (headers, data, json…)
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
if "login" in kwargs:
|
|
244
|
+
login = kwargs["login"]
|
|
245
|
+
del kwargs["login"]
|
|
246
|
+
else: # if unset, use default
|
|
247
|
+
login = self._default_login
|
|
248
|
+
|
|
249
|
+
self._auth.setAuth(login, kwargs, auth=auth)
|
|
250
|
+
res = self._request(method, path, **kwargs) # type: ignore
|
|
251
|
+
|
|
252
|
+
if status is not None:
|
|
253
|
+
if res.status_code != status: # show error before aborting
|
|
254
|
+
log.error(f"bad {status} result: {res.status_code} {res.text[:512]}")
|
|
255
|
+
assert res.status_code == status
|
|
256
|
+
|
|
257
|
+
return res
|
|
258
|
+
|
|
259
|
+
def get(self, path, **kwargs):
|
|
260
|
+
return self.request("GET", path, **kwargs)
|
|
261
|
+
|
|
262
|
+
def post(self, path, **kwargs):
|
|
263
|
+
return self.request("POST", path, **kwargs)
|
|
264
|
+
|
|
265
|
+
def put(self, path, **kwargs):
|
|
266
|
+
return self.request("PUT", path, **kwargs)
|
|
267
|
+
|
|
268
|
+
def patch(self, path, **kwargs):
|
|
269
|
+
return self.request("PATCH", path, **kwargs)
|
|
270
|
+
|
|
271
|
+
def delete(self, path, **kwargs):
|
|
272
|
+
return self.request("DELETE", path, **kwargs)
|
|
273
|
+
|
|
274
|
+
def check(self, method: str, path: str, status: int, content: str|None = None, **kwargs):
|
|
275
|
+
"""Run a query and check the response.
|
|
276
|
+
|
|
277
|
+
Mandatory parameters:
|
|
278
|
+
- method: HTTP method ("GET", "POST", "PATCH", "DELETE"…)
|
|
279
|
+
- path: local path under the base URL
|
|
280
|
+
- status: expected HTTP status
|
|
281
|
+
|
|
282
|
+
Optional parameters:
|
|
283
|
+
- content: regular expression in response body
|
|
284
|
+
- login: authenticated user, use explicit None to skip
|
|
285
|
+
- **kwargs: more request parameters (headers, data, json…)
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
# get HTTP response
|
|
289
|
+
res = self.request(method, path, status=status, **kwargs)
|
|
290
|
+
|
|
291
|
+
if content is not None:
|
|
292
|
+
if not re.search(content, res.text, re.DOTALL):
|
|
293
|
+
log.error(f"cannot find {content} in {res.text}")
|
|
294
|
+
assert False, "content not found"
|
|
295
|
+
|
|
296
|
+
return res
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class RequestClient(Client):
|
|
300
|
+
"""Request-based test provider."""
|
|
301
|
+
|
|
302
|
+
def __init__(self, auth: Authenticator, base_url: str, default_login=None):
|
|
303
|
+
super().__init__(auth, default_login)
|
|
304
|
+
self._base_url = base_url
|
|
305
|
+
# reuse connections, otherwise it is too slow…
|
|
306
|
+
from requests import Session
|
|
307
|
+
self._requests = Session()
|
|
308
|
+
|
|
309
|
+
def _request(self, method: str, path: str, **kwargs):
|
|
310
|
+
# ensure file upload compatibility
|
|
311
|
+
if "data" in kwargs:
|
|
312
|
+
data = kwargs["data"]
|
|
313
|
+
files: dict[str, Any] = {}
|
|
314
|
+
for name, whatever in data.items():
|
|
315
|
+
# FIXME what types should be accepted?
|
|
316
|
+
if isinstance(whatever, io.BufferedReader):
|
|
317
|
+
files[name] = whatever
|
|
318
|
+
elif isinstance(whatever, tuple):
|
|
319
|
+
# reorder tuple to match requests expectations:
|
|
320
|
+
file_handle, file_name, file_type = whatever
|
|
321
|
+
files[name] = (file_name, file_handle, file_type)
|
|
322
|
+
else:
|
|
323
|
+
pass
|
|
324
|
+
# complete "data" to "files" parameter transfer
|
|
325
|
+
for name in files:
|
|
326
|
+
del data[name]
|
|
327
|
+
assert "files" not in kwargs
|
|
328
|
+
kwargs["files"] = files
|
|
329
|
+
# sanity
|
|
330
|
+
assert not (files and "json" in kwargs), "cannot mix file upload and json?"
|
|
331
|
+
res = self._requests.request(method, self._base_url + path, **kwargs)
|
|
332
|
+
return RequestFlaskResponse(res)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class FlaskClient(Client):
|
|
336
|
+
"""Flask-based test provider."""
|
|
337
|
+
|
|
338
|
+
def __init__(self, auth: Authenticator, client, default_login=None):
|
|
339
|
+
super().__init__(auth, default_login)
|
|
340
|
+
self._client = client
|
|
341
|
+
|
|
342
|
+
def _request(self, method: str, path: str, **kwargs):
|
|
343
|
+
return self._client.open(method=method, path=path, **kwargs)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@pytest.fixture
|
|
347
|
+
def ft_authenticator():
|
|
348
|
+
allow = os.environ["FLASK_TESTER_ALLOW"].split(" ") if "FLASK_TESTER_ALLOW" in os.environ else ["bearer", "basic", "param"]
|
|
349
|
+
auth = Authenticator(allow)
|
|
350
|
+
if "FLASK_TESTER_AUTH" in os.environ:
|
|
351
|
+
auth.setPasses(os.environ["FLASK_TESTER_AUTH"].split(","))
|
|
352
|
+
yield auth
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@pytest.fixture
|
|
356
|
+
def ft_client(ft_authenticator):
|
|
357
|
+
client: Client
|
|
358
|
+
if "FLASK_TESTER_URL" in os.environ:
|
|
359
|
+
app_url = os.environ["FLASK_TESTER_URL"]
|
|
360
|
+
client = RequestClient(ft_authenticator, app_url)
|
|
361
|
+
elif "FLASK_TESTER_APP" in os.environ:
|
|
362
|
+
pkg_name = os.environ["FLASK_TESTER_APP"]
|
|
363
|
+
pkg = importlib.import_module(pkg_name)
|
|
364
|
+
if hasattr(pkg, "app"):
|
|
365
|
+
app = getattr(pkg, "app")
|
|
366
|
+
elif hasattr(pkg, "create_app"):
|
|
367
|
+
app = getattr(pkg, "create_app")()
|
|
368
|
+
else:
|
|
369
|
+
raise FlaskTesterError(f"cannot find Flask app in {pkg_name}")
|
|
370
|
+
client = FlaskClient(ft_authenticator, app.test_client())
|
|
371
|
+
else:
|
|
372
|
+
raise FlaskTesterError("no Flask application to test")
|
|
373
|
+
yield client
|