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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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