Skip to content

Commit eb22c3b

Browse files
author
Fabien Coelho
committed
Add support for pydantic and dataclasses
1 parent e412e73 commit eb22c3b

7 files changed

+145
-3
lines changed

FlaskTester.py

+42-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from typing import Any
1010
import importlib
1111
import logging
12-
import pytest
12+
import pytest # for explicit fail calls, see _pytestFail
13+
import json
14+
import dataclasses
1315

1416
log = logging.getLogger("flask_tester")
1517

@@ -346,17 +348,56 @@ def request(self, method: str, path: str, status: int|None = None, content: str|
346348
cookies.update(kwargs["cookies"])
347349
del kwargs["cookies"]
348350

351+
# FIXME allow or forbid?
352+
# if "json" in kwargs and "data" in kwargs:
353+
# log.warning("mix of json and data parameters in request")
354+
355+
# convert json parameters
356+
if "json" in kwargs:
357+
json_param = kwargs["json"]
358+
assert isinstance(json_param, dict)
359+
for name in list(json_param.keys()):
360+
val = json_param[name]
361+
if val is None:
362+
pass
363+
elif isinstance(val, (bool, int, float, str, tuple, list, dict)):
364+
pass
365+
elif "model_dump" in val.__dir__() and callable(val.model_dump):
366+
# probably pydantic
367+
json_param[name] = val.model_dump()
368+
else: # pydantic or standard dataclasses?
369+
json_param[name] = dataclasses.asdict(val)
370+
371+
# convert data parameters
372+
if "data" in kwargs:
373+
data_param = kwargs["data"]
374+
assert isinstance(data_param, dict)
375+
for name in list(data_param.keys()):
376+
val = data_param[name]
377+
if val is None:
378+
data_param[name] = "null"
379+
elif isinstance(val, (io.IOBase, tuple)):
380+
pass # file parameters?
381+
elif isinstance(val, (bool, int, float, str, list, dict)):
382+
data_param[name] = json.dumps(val)
383+
elif "model_dump_json" in val.__dir__() and callable(val.model_dump_json):
384+
data_param[name] = val.model_dump_json()
385+
else:
386+
data_param[name] = json.dumps(dataclasses.asdict(val))
387+
349388
self._auth.setAuth(login, kwargs, cookies, auth=auth)
350389
res = self._request(method, path, cookies, **kwargs) # type: ignore
351390

352391
# check status
353392
if status is not None:
354393
if res.status_code != status: # show error before aborting
394+
# FIXME what if the useful part is at the end?
355395
_pytestFail(f"bad {status} result: {res.status_code} {res.text[:512]}...")
356396

357397
# check content
358398
if content is not None:
359399
if not re.search(content, res.text, re.DOTALL):
400+
# FIXME what if the useful part is at the end?
360401
_pytestFail(f"cannot find {content} in {res.text[:512]}...")
361402

362403
return res

docs/documentation.md

+3
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ The package provides two fixtures:
8686

8787
The authentication data, here a password, must have been provided to the authenticator.
8888

89+
Direct parameters from `pydantic` and `dataclass` classes are supported and
90+
converted to JSON.
91+
8992
- `get post put patch delete` methods with the same extensions.
9093

9194
Submit a `GET` request to path `/stats` authenticated as _hobbes_,

docs/versions.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ please report any [issues](https://github.com/zx80/flask-tester/issues).
77

88
## TODO
99

10-
Add support for dataclass/pydantic parameters auto-serialization.
10+
Check whether data and json should be exclusive.
11+
12+
## ? on ?
13+
14+
Add support for transparent dataclass and pydantic parameters.
1115

1216
## 4.0 on 2024-05-20
1317

pyproject.toml

+8-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ package = "https://pypi.org/project/FlaskTester/"
3333

3434
[project.optional-dependencies]
3535
# various dev tools
36-
dev = ["mypy", "pyright", "ruff", "coverage", "pymarkdownlnt!=0.9.5", "build", "twine", "wheel", "types-flask", "types-requests", "FlaskSimpleAuth>=30.0", "passlib"]
36+
dev = [
37+
# static checks…
38+
"mypy", "pyright", "ruff", "coverage", "pymarkdownlnt!=0.9.5",
39+
# packaging
40+
"build", "twine", "wheel",
41+
# tests
42+
"types-flask", "types-requests", "FlaskSimpleAuth>=30.0", "passlib", "pydantic"
43+
]
3744
# documentation generation
3845
doc = ["sphinx", "sphinx_rtd_theme", "sphinx-autoapi", "sphinx-lint", "myst_parser"]
3946

tests/app.py

+35
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import FlaskSimpleAuth as fsa
44
import secret
5+
import model
56

67
# create application with token, param and basic authentication
78
app = fsa.Flask("app", FSA_MODE="dev", FSA_AUTH=["token", "param", "basic"])
@@ -38,3 +39,37 @@ def get_admin(user: fsa.CurrentUser):
3839
@app.get("/hello", authorize="OPEN")
3940
def get_hello(lang: fsa.Cookie = "en"):
4041
return {"lang": lang, "hello": HELLO.get(lang, "Hi")}, 200
42+
43+
# json, pydantic and dataclasses
44+
# FIXME could we drop fsa.jsonify?
45+
@app.get("/t0", authorize="OPEN")
46+
def get_t0(t: fsa.JsonData):
47+
return fsa.jsonify(t)
48+
49+
@app.post("/t0", authorize="OPEN")
50+
def post_t0(t: fsa.JsonData):
51+
return fsa.jsonify(t)
52+
53+
@app.get("/t1", authorize="OPEN")
54+
def get_t1(t: model.Thing1):
55+
return fsa.jsonify(t)
56+
57+
@app.post("/t1", authorize="OPEN")
58+
def post_t1(t: model.Thing1):
59+
return fsa.jsonify(t)
60+
61+
@app.get("/t2", authorize="OPEN")
62+
def get_t2(t: model.Thing2):
63+
return fsa.jsonify(t)
64+
65+
@app.post("/t2", authorize="OPEN")
66+
def post_t2(t: model.Thing2):
67+
return fsa.jsonify(t)
68+
69+
@app.get("/t3", authorize="OPEN")
70+
def get_t3(t: model.Thing3):
71+
return fsa.jsonify(t)
72+
73+
@app.post("/t3", authorize="OPEN")
74+
def post_t3(t: model.Thing3):
75+
return fsa.jsonify(t)

tests/model.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pydantic
2+
import dataclasses
3+
4+
class Thing1(pydantic.BaseModel):
5+
tid: int
6+
name: str
7+
owner: str
8+
9+
@pydantic.dataclasses.dataclass
10+
class Thing2:
11+
tid: int
12+
name: str
13+
owner: str
14+
15+
@dataclasses.dataclass
16+
class Thing3:
17+
tid: int
18+
name: str
19+
owner: str

tests/test_app.py

+33
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import threading
99
import io
1010
import logging
11+
import model
1112

1213
logging.basicConfig(level=logging.INFO)
1314
# logging.basicConfig(level=logging.DEBUG)
@@ -338,3 +339,35 @@ def test_client_fixture():
338339
# reset env
339340
if app:
340341
os.environ["FLASK_TESTER_APP"] = app
342+
343+
def test_classes(api):
344+
345+
def thing_eq(ta, tb):
346+
tb = model.Thing1(**tb) if isinstance(tb, dict) else tb
347+
return ta.tid == tb.tid and ta.name == tb.name and ta.owner == tb.owner
348+
349+
# Things
350+
t0 = {"tid": 0, "name": "zero", "owner": "Rosalyn"}
351+
t1 = model.Thing1(tid=1, name="one", owner="Susie")
352+
t2 = model.Thing2(tid=2, name="two", owner="Calvin")
353+
t3 = model.Thing3(tid=3, name="three", owner="Hobbes")
354+
355+
n = 0
356+
# check all combinations
357+
for path in ["/t0", "/t1", "/t2", "/t3"]:
358+
for param in [t0, t1, t2, t3]:
359+
for tclass in [model.Thing1, model.Thing2, model.Thing3]:
360+
for method in ["GET", "POST"]:
361+
for mode in ["data", "json"]:
362+
n +=1
363+
parameter = {mode: {"t": param}}
364+
res = api.request(method, path, 200, **parameter)
365+
assert res.is_json
366+
json = res.json
367+
assert isinstance(json, dict)
368+
assert thing_eq(tclass(**json), param)
369+
assert n == 192
370+
371+
# null translation
372+
assert api.get("/t0", 200, json={"t": None}).json is None
373+
assert api.get("/t0", 200, data={"t": None}).json is None

0 commit comments

Comments
 (0)