Skip to content

Commit 47f81d2

Browse files
authored
Merge pull request #59 from Ge0rg3/dev/smt5541/fix_complex_docs
Fix issue where complex setups like the testing application cause API docs to break down
2 parents 32f40e3 + 0aa0ac8 commit 47f81d2

File tree

6 files changed

+103
-12
lines changed

6 files changed

+103
-12
lines changed

flask_parameter_validation/docs_blueprint.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import Enum
12
import flask
23
from flask import Blueprint, current_app, jsonify
34

@@ -33,7 +34,7 @@ def get_function_docs(func):
3334
"""
3435
fn_list = ValidateParameters().get_fn_list()
3536
for fsig, fdocs in fn_list.items():
36-
if fsig.endswith(func.__name__):
37+
if hasattr(func, "__fpv_discriminated_sig__") and func.__fpv_discriminated_sig__ == fsig:
3738
return {
3839
"docstring": format_docstring(fdocs.get("docstring")),
3940
"decorators": fdocs.get("decorators"),
@@ -74,11 +75,21 @@ def get_arg_type_hint(fdocs, arg_name):
7475
Extract the type hint for a specific argument.
7576
"""
7677
arg_type = fdocs["argspec"].annotations[arg_name]
77-
if hasattr(arg_type, "__args__"):
78-
return (
79-
f"{arg_type.__name__}[{', '.join([a.__name__ for a in arg_type.__args__])}]"
80-
)
81-
return arg_type.__name__
78+
def recursively_resolve_type_hint(type_to_resolve):
79+
if hasattr(type_to_resolve, "__name__"): # In Python 3.9, Optional and Union do not have __name__
80+
type_base_name = type_to_resolve.__name__
81+
elif hasattr(type_to_resolve, "_name") and type_to_resolve._name is not None:
82+
# In Python 3.9, _name exists on list[whatever] and has a non-None value
83+
type_base_name = type_to_resolve._name
84+
else:
85+
# But, in Python 3.9, Optional[whatever] has _name of None - but its __origin__ is Union
86+
type_base_name = type_to_resolve.__origin__._name
87+
if hasattr(type_to_resolve, "__args__"):
88+
return (
89+
f"{type_base_name}[{', '.join([recursively_resolve_type_hint(a) for a in type_to_resolve.__args__])}]"
90+
)
91+
return type_base_name
92+
return recursively_resolve_type_hint(arg_type)
8293

8394

8495
def get_arg_location(fdocs, idx):
@@ -98,6 +109,18 @@ def get_arg_location_details(fdocs, idx):
98109
if value is not None:
99110
if callable(value):
100111
loc_details[param] = f"{value.__module__}.{value.__name__}"
112+
elif issubclass(type(value), Enum):
113+
loc_details[param] = f"{type(value).__name__}.{value.name}: "
114+
if issubclass(type(value), int):
115+
loc_details[param] += f"{value.value}"
116+
elif issubclass(type(value), str):
117+
loc_details[param] += f"'{value.value}'"
118+
else:
119+
loc_details[param] = f"FPV: Unsupported Enum type"
120+
elif type(value).__name__ == 'time':
121+
loc_details[param] = value.isoformat()
122+
elif param == 'sources':
123+
loc_details[param] = [type(source).__name__ for source in value]
101124
else:
102125
loc_details[param] = value
103126
return loc_details

flask_parameter_validation/parameter_validation.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import functools
33
import inspect
44
import re
5+
import uuid
56
from inspect import signature
67
from flask import request, Response
78
from werkzeug.datastructures import ImmutableMultiDict
@@ -28,6 +29,11 @@ def __call__(self, f):
2829
Parent flow for validating each required parameter
2930
"""
3031
fsig = f.__module__ + "." + f.__name__
32+
# Add a discriminator to the function signature, store it in the properties of the function
33+
# This is used in documentation generation to associate the info gathered from inspecting the
34+
# function with the properties passed to the ValidateParameters decorator
35+
f.__fpv_discriminated_sig__ = f"{uuid.uuid4()}_{fsig}"
36+
fsig = f.__fpv_discriminated_sig__
3137
argspec = inspect.getfullargspec(f)
3238
source = inspect.getsource(f)
3339
index = source.find("def ")

flask_parameter_validation/test/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
def app():
77
app = create_app()
88
app.config.update({"TESTING": True})
9-
yield app
9+
with app.app_context():
10+
yield app
1011

1112

1213
@pytest.fixture()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import sys
2+
from flask_parameter_validation.docs_blueprint import get_route_docs
3+
4+
def test_http_ok(client):
5+
r = client.get("/docs/")
6+
assert r.status_code == 200
7+
r = client.get("/docs/json")
8+
assert r.status_code == 200
9+
import sys
10+
def test_routes_added(app):
11+
routes = []
12+
for rule in app.url_map.iter_rules():
13+
routes.append(str(rule))
14+
for doc in get_route_docs():
15+
assert doc["rule"] in routes
16+
17+
def test_doc_types_of_default(app):
18+
locs = {
19+
"form": "Form",
20+
"json": "Json",
21+
"query": "Query",
22+
"route": "Route"
23+
}
24+
optional_as_str = "Optional" if sys.version_info >= (3,10) else "Union"
25+
types = {
26+
"bool": {"opt": f"{optional_as_str}[bool, NoneType]", "n_opt": "bool"},
27+
"date": {"opt": f"{optional_as_str}[date, NoneType]", "n_opt": "date"},
28+
"datetime": {"opt": f"{optional_as_str}[datetime, NoneType]", "n_opt": "datetime"},
29+
"dict": {"opt": f"{optional_as_str}[dict, NoneType]", "n_opt": "dict"},
30+
"float": {"opt": f"{optional_as_str}[float, NoneType]", "n_opt": "float"},
31+
"int": {"opt": f"{optional_as_str}[int, NoneType]", "n_opt": "int"},
32+
"int_enum": {"opt": f"{optional_as_str}[Binary, NoneType]", "n_opt": "Binary"},
33+
"list": {"opt": f"{optional_as_str}[List[int], NoneType]", "n_opt": "List[str]"},
34+
"str": {"opt": f"{optional_as_str}[str, NoneType]", "n_opt": "str"},
35+
"str_enum": {"opt": f"{optional_as_str}[Fruits, NoneType]", "n_opt": "Fruits"},
36+
"time": {"opt": f"{optional_as_str}[time, NoneType]", "n_opt": "time"},
37+
"union": {"opt": "Union[bool, int, NoneType]", "n_opt": "Union[bool, int]"},
38+
"uuid": {"opt": f"{optional_as_str}[UUID, NoneType]", "n_opt": "UUID"}
39+
}
40+
route_unsupported_types = ["dict", "list"]
41+
route_docs = get_route_docs()
42+
for loc in locs.keys():
43+
for arg_type in types.keys():
44+
if loc == "route" and arg_type in route_unsupported_types:
45+
continue
46+
route_to_check = f"/{loc}/{arg_type}/default"
47+
for doc in route_docs:
48+
if doc["rule"] == route_to_check:
49+
args = doc["args"][locs[loc]]
50+
if args[0]["name"] == "n_opt":
51+
n_opt = args[0]
52+
opt = args[1]
53+
else:
54+
opt = args[0]
55+
n_opt = args[1]
56+
assert n_opt["type"] == types[arg_type]["n_opt"]
57+
assert opt["type"] == types[arg_type]["opt"]

flask_parameter_validation/test/testing_application.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from flask_parameter_validation.test.testing_blueprints.file_blueprint import get_file_blueprint
77
from flask_parameter_validation.test.testing_blueprints.multi_source_blueprint import get_multi_source_blueprint
88
from flask_parameter_validation.test.testing_blueprints.parameter_blueprint import get_parameter_blueprint
9+
from flask_parameter_validation.docs_blueprint import docs_blueprint
910

1011
multi_source_sources = [
1112
{"class": Query, "name": "query"},
@@ -22,8 +23,11 @@ def create_app():
2223
app.register_blueprint(get_parameter_blueprint(Form, "form", "form", "post"))
2324
app.register_blueprint(get_parameter_blueprint(Route, "route", "route", "get"))
2425
app.register_blueprint(get_file_blueprint("file"))
26+
app.register_blueprint(docs_blueprint)
2527
for source_a in multi_source_sources:
2628
for source_b in multi_source_sources:
27-
combined_name = f"ms_{source_a['name']}_{source_b['name']}"
28-
app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name))
29+
if source_a["name"] != source_b["name"]:
30+
# There's no reason to test multi-source with two of the same source
31+
combined_name = f"ms_{source_a['name']}_{source_b['name']}"
32+
app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name))
2933
return app

flask_parameter_validation/test/testing_blueprints/dict_blueprint.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def optional(v: Optional[dict] = ParamType()):
3131
@ValidateParameters()
3232
def default(
3333
n_opt: dict = ParamType(default={"a": "b"}),
34-
opt: dict = ParamType(default={"c": "d"})
34+
opt: Optional[dict] = ParamType(default={"c": "d"})
3535
):
3636
return jsonify({
3737
"n_opt": n_opt,
@@ -43,7 +43,7 @@ def default(
4343
@ValidateParameters()
4444
def decorator_default(
4545
n_opt: dict = ParamType(default={"a": "b"}),
46-
opt: dict = ParamType(default={"c": "d"})
46+
opt: Optional[dict] = ParamType(default={"c": "d"})
4747
):
4848
return jsonify({
4949
"n_opt": n_opt,
@@ -55,7 +55,7 @@ def decorator_default(
5555
@ValidateParameters()
5656
async def async_decorator_default(
5757
n_opt: dict = ParamType(default={"a": "b"}),
58-
opt: dict = ParamType(default={"c": "d"})
58+
opt: Optional[dict] = ParamType(default={"c": "d"})
5959
):
6060
return jsonify({
6161
"n_opt": n_opt,

0 commit comments

Comments
 (0)