From 320305e1ed87295e67a1fec5e5114e6af29619f9 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 18:35:24 -0500 Subject: [PATCH 1/8] Make app test fixture run with app_context --- flask_parameter_validation/test/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_parameter_validation/test/conftest.py b/flask_parameter_validation/test/conftest.py index 9fbaf6e..500622a 100644 --- a/flask_parameter_validation/test/conftest.py +++ b/flask_parameter_validation/test/conftest.py @@ -6,7 +6,8 @@ def app(): app = create_app() app.config.update({"TESTING": True}) - yield app + with app.app_context(): + yield app @pytest.fixture() From f8dbfd8e099e1afa8313cf3a2131fe86ec0461cc Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 18:36:45 -0500 Subject: [PATCH 2/8] Fix incorrect test blueprint type hints --- .../test/testing_blueprints/dict_blueprint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py b/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py index 16d71b8..f914a91 100644 --- a/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py +++ b/flask_parameter_validation/test/testing_blueprints/dict_blueprint.py @@ -31,7 +31,7 @@ def optional(v: Optional[dict] = ParamType()): @ValidateParameters() def default( n_opt: dict = ParamType(default={"a": "b"}), - opt: dict = ParamType(default={"c": "d"}) + opt: Optional[dict] = ParamType(default={"c": "d"}) ): return jsonify({ "n_opt": n_opt, @@ -43,7 +43,7 @@ def default( @ValidateParameters() def decorator_default( n_opt: dict = ParamType(default={"a": "b"}), - opt: dict = ParamType(default={"c": "d"}) + opt: Optional[dict] = ParamType(default={"c": "d"}) ): return jsonify({ "n_opt": n_opt, @@ -55,7 +55,7 @@ def decorator_default( @ValidateParameters() async def async_decorator_default( n_opt: dict = ParamType(default={"a": "b"}), - opt: dict = ParamType(default={"c": "d"}) + opt: Optional[dict] = ParamType(default={"c": "d"}) ): return jsonify({ "n_opt": n_opt, From 26ca9ff466c6ba01de084d73bfe58f28075bd545 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 18:38:01 -0500 Subject: [PATCH 3/8] Prevent generation of multi-source routes in the testing app where both sources are the same --- flask_parameter_validation/test/testing_application.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flask_parameter_validation/test/testing_application.py b/flask_parameter_validation/test/testing_application.py index 85fd02e..6f4794c 100644 --- a/flask_parameter_validation/test/testing_application.py +++ b/flask_parameter_validation/test/testing_application.py @@ -24,6 +24,8 @@ def create_app(): app.register_blueprint(get_file_blueprint("file")) for source_a in multi_source_sources: for source_b in multi_source_sources: - combined_name = f"ms_{source_a['name']}_{source_b['name']}" - app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name)) + if source_a["name"] != source_b["name"]: + # There's no reason to test multi-source with two of the same source + combined_name = f"ms_{source_a['name']}_{source_b['name']}" + app.register_blueprint(get_multi_source_blueprint([source_a['class'], source_b['class']], combined_name)) return app From e28734555e79e9207e25612c39884d36378566d0 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 18:40:37 -0500 Subject: [PATCH 4/8] Tidy type hint resolution in API docs --- flask_parameter_validation/docs_blueprint.py | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 47a8c8f..8da9cad 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -1,3 +1,4 @@ +from enum import Enum import flask from flask import Blueprint, current_app, jsonify @@ -74,11 +75,13 @@ def get_arg_type_hint(fdocs, arg_name): Extract the type hint for a specific argument. """ arg_type = fdocs["argspec"].annotations[arg_name] - if hasattr(arg_type, "__args__"): - return ( - f"{arg_type.__name__}[{', '.join([a.__name__ for a in arg_type.__args__])}]" - ) - return arg_type.__name__ + def recursively_resolve_type_hint(type_to_resolve): + if hasattr(type_to_resolve, "__args__"): + return ( + f"{type_to_resolve.__name__}[{', '.join([recursively_resolve_type_hint(a) for a in type_to_resolve.__args__])}]" + ) + return type_to_resolve.__name__ + return recursively_resolve_type_hint(arg_type) def get_arg_location(fdocs, idx): @@ -98,6 +101,18 @@ def get_arg_location_details(fdocs, idx): if value is not None: if callable(value): loc_details[param] = f"{value.__module__}.{value.__name__}" + elif issubclass(type(value), Enum): + loc_details[param] = f"{type(value).__name__}.{value.name}: " + if issubclass(type(value), int): + loc_details[param] += f"{value.value}" + elif issubclass(type(value), str): + loc_details[param] += f"'{value.value}'" + else: + loc_details[param] = f"FPV: Unsupported Enum type" + elif type(value).__name__ == 'time': + loc_details[param] = value.isoformat() + elif param == 'sources': + loc_details[param] = [type(source).__name__ for source in value] else: loc_details[param] = value return loc_details From 6253528a22f5fe23d65ff21636fe9d9a5e97b408 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 18:41:17 -0500 Subject: [PATCH 5/8] Implement use of UUID prepended to function signature to ensure that each registered flask route can be associated with its function declaration --- flask_parameter_validation/docs_blueprint.py | 2 +- flask_parameter_validation/parameter_validation.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 8da9cad..91d1045 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -34,7 +34,7 @@ def get_function_docs(func): """ fn_list = ValidateParameters().get_fn_list() for fsig, fdocs in fn_list.items(): - if fsig.endswith(func.__name__): + if hasattr(func, "__fpv_discriminated_sig__") and func.__fpv_discriminated_sig__ == fsig: return { "docstring": format_docstring(fdocs.get("docstring")), "decorators": fdocs.get("decorators"), diff --git a/flask_parameter_validation/parameter_validation.py b/flask_parameter_validation/parameter_validation.py index 2eaef20..e3e24f3 100644 --- a/flask_parameter_validation/parameter_validation.py +++ b/flask_parameter_validation/parameter_validation.py @@ -2,6 +2,7 @@ import functools import inspect import re +import uuid from inspect import signature from flask import request, Response from werkzeug.datastructures import ImmutableMultiDict @@ -28,6 +29,11 @@ def __call__(self, f): Parent flow for validating each required parameter """ fsig = f.__module__ + "." + f.__name__ + # Add a discriminator to the function signature, store it in the properties of the function + # This is used in documentation generation to associate the info gathered from inspecting the + # function with the properties passed to the ValidateParameters decorator + f.__fpv_discriminated_sig__ = f"{uuid.uuid4()}_{fsig}" + fsig = f.__fpv_discriminated_sig__ argspec = inspect.getfullargspec(f) source = inspect.getsource(f) index = source.find("def ") From 84654c9bfeda4756f849b49f54b749392bfdefbd Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 18:41:42 -0500 Subject: [PATCH 6/8] Add limited tests for API docs --- .../test/test_api_docs.py | 54 +++++++++++++++++++ .../test/testing_application.py | 2 + 2 files changed, 56 insertions(+) create mode 100644 flask_parameter_validation/test/test_api_docs.py diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py new file mode 100644 index 0000000..0a27df4 --- /dev/null +++ b/flask_parameter_validation/test/test_api_docs.py @@ -0,0 +1,54 @@ +from flask_parameter_validation.docs_blueprint import get_route_docs + +def test_http_ok(client): + r = client.get("/docs/") + assert r.status_code == 200 + r = client.get("/docs/json") + assert r.status_code == 200 + +def test_routes_added(app): + routes = [] + for rule in app.url_map.iter_rules(): + routes.append(str(rule)) + for doc in get_route_docs(): + assert doc["rule"] in routes + +def test_doc_types_of_default(app): + locs = { + "form": "Form", + "json": "Json", + "query": "Query", + "route": "Route" + } + types = { + "bool": {"opt": "Optional[bool, NoneType]", "n_opt": "bool"}, + "date": {"opt": "Optional[date, NoneType]", "n_opt": "date"}, + "datetime": {"opt": "Optional[datetime, NoneType]", "n_opt": "datetime"}, + "dict": {"opt": "Optional[dict, NoneType]", "n_opt": "dict"}, + "float": {"opt": "Optional[float, NoneType]", "n_opt": "float"}, + "int": {"opt": "Optional[int, NoneType]", "n_opt": "int"}, + "int_enum": {"opt": "Optional[Binary, NoneType]", "n_opt": "Binary"}, + "list": {"opt": "Optional[List[int], NoneType]", "n_opt": "List[str]"}, + "str": {"opt": "Optional[str, NoneType]", "n_opt": "str"}, + "str_enum": {"opt": "Optional[Fruits, NoneType]", "n_opt": "Fruits"}, + "time": {"opt": "Optional[time, NoneType]", "n_opt": "time"}, + "union": {"opt": "Union[bool, int, NoneType]", "n_opt": "Union[bool, int]"} + } + route_unsupported_types = ["dict", "list"] + route_docs = get_route_docs() + for loc in locs.keys(): + for arg_type in types.keys(): + if loc == "route" and arg_type in route_unsupported_types: + continue + route_to_check = f"/{loc}/{arg_type}/default" + for doc in route_docs: + if doc["rule"] == route_to_check: + args = doc["args"][locs[loc]] + if args[0]["name"] == "n_opt": + n_opt = args[0] + opt = args[1] + else: + opt = args[0] + n_opt = args[1] + assert n_opt["type"] == types[arg_type]["n_opt"] + assert opt["type"] == types[arg_type]["opt"] diff --git a/flask_parameter_validation/test/testing_application.py b/flask_parameter_validation/test/testing_application.py index 6f4794c..e25bd10 100644 --- a/flask_parameter_validation/test/testing_application.py +++ b/flask_parameter_validation/test/testing_application.py @@ -6,6 +6,7 @@ from flask_parameter_validation.test.testing_blueprints.file_blueprint import get_file_blueprint from flask_parameter_validation.test.testing_blueprints.multi_source_blueprint import get_multi_source_blueprint from flask_parameter_validation.test.testing_blueprints.parameter_blueprint import get_parameter_blueprint +from flask_parameter_validation.docs_blueprint import docs_blueprint multi_source_sources = [ {"class": Query, "name": "query"}, @@ -22,6 +23,7 @@ def create_app(): app.register_blueprint(get_parameter_blueprint(Form, "form", "form", "post")) app.register_blueprint(get_parameter_blueprint(Route, "route", "route", "get")) app.register_blueprint(get_file_blueprint("file")) + app.register_blueprint(docs_blueprint) for source_a in multi_source_sources: for source_b in multi_source_sources: if source_a["name"] != source_b["name"]: From 9cb6f79fcc698c04911a4831da59246f3449fc5f Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sat, 1 Mar 2025 19:47:38 -0500 Subject: [PATCH 7/8] Fix tests for Python 3.9 --- flask_parameter_validation/docs_blueprint.py | 12 +++++++-- .../test/test_api_docs.py | 26 ++++++++++--------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/flask_parameter_validation/docs_blueprint.py b/flask_parameter_validation/docs_blueprint.py index 91d1045..671e251 100644 --- a/flask_parameter_validation/docs_blueprint.py +++ b/flask_parameter_validation/docs_blueprint.py @@ -76,11 +76,19 @@ def get_arg_type_hint(fdocs, arg_name): """ arg_type = fdocs["argspec"].annotations[arg_name] def recursively_resolve_type_hint(type_to_resolve): + if hasattr(type_to_resolve, "__name__"): # In Python 3.9, Optional and Union do not have __name__ + type_base_name = type_to_resolve.__name__ + elif hasattr(type_to_resolve, "_name") and type_to_resolve._name is not None: + # In Python 3.9, _name exists on list[whatever] and has a non-None value + type_base_name = type_to_resolve._name + else: + # But, in Python 3.9, Optional[whatever] has _name of None - but its __origin__ is Union + type_base_name = type_to_resolve.__origin__._name if hasattr(type_to_resolve, "__args__"): return ( - f"{type_to_resolve.__name__}[{', '.join([recursively_resolve_type_hint(a) for a in type_to_resolve.__args__])}]" + f"{type_base_name}[{', '.join([recursively_resolve_type_hint(a) for a in type_to_resolve.__args__])}]" ) - return type_to_resolve.__name__ + return type_base_name return recursively_resolve_type_hint(arg_type) diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py index 0a27df4..d5602ec 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -1,3 +1,4 @@ +import sys from flask_parameter_validation.docs_blueprint import get_route_docs def test_http_ok(client): @@ -5,7 +6,7 @@ def test_http_ok(client): assert r.status_code == 200 r = client.get("/docs/json") assert r.status_code == 200 - +import sys def test_routes_added(app): routes = [] for rule in app.url_map.iter_rules(): @@ -20,18 +21,19 @@ def test_doc_types_of_default(app): "query": "Query", "route": "Route" } + optional_as_str = "Optional" if sys.version_info >= (3,10) else "Union" types = { - "bool": {"opt": "Optional[bool, NoneType]", "n_opt": "bool"}, - "date": {"opt": "Optional[date, NoneType]", "n_opt": "date"}, - "datetime": {"opt": "Optional[datetime, NoneType]", "n_opt": "datetime"}, - "dict": {"opt": "Optional[dict, NoneType]", "n_opt": "dict"}, - "float": {"opt": "Optional[float, NoneType]", "n_opt": "float"}, - "int": {"opt": "Optional[int, NoneType]", "n_opt": "int"}, - "int_enum": {"opt": "Optional[Binary, NoneType]", "n_opt": "Binary"}, - "list": {"opt": "Optional[List[int], NoneType]", "n_opt": "List[str]"}, - "str": {"opt": "Optional[str, NoneType]", "n_opt": "str"}, - "str_enum": {"opt": "Optional[Fruits, NoneType]", "n_opt": "Fruits"}, - "time": {"opt": "Optional[time, NoneType]", "n_opt": "time"}, + "bool": {"opt": f"{optional_as_str}[bool, NoneType]", "n_opt": "bool"}, + "date": {"opt": f"{optional_as_str}[date, NoneType]", "n_opt": "date"}, + "datetime": {"opt": f"{optional_as_str}[datetime, NoneType]", "n_opt": "datetime"}, + "dict": {"opt": f"{optional_as_str}[dict, NoneType]", "n_opt": "dict"}, + "float": {"opt": f"{optional_as_str}[float, NoneType]", "n_opt": "float"}, + "int": {"opt": f"{optional_as_str}[int, NoneType]", "n_opt": "int"}, + "int_enum": {"opt": f"{optional_as_str}[Binary, NoneType]", "n_opt": "Binary"}, + "list": {"opt": f"{optional_as_str}[List[int], NoneType]", "n_opt": "List[str]"}, + "str": {"opt": f"{optional_as_str}[str, NoneType]", "n_opt": "str"}, + "str_enum": {"opt": f"{optional_as_str}[Fruits, NoneType]", "n_opt": "Fruits"}, + "time": {"opt": f"{optional_as_str}[time, NoneType]", "n_opt": "time"}, "union": {"opt": "Union[bool, int, NoneType]", "n_opt": "Union[bool, int]"} } route_unsupported_types = ["dict", "list"] From 0aa0ac8917ad31a8b85cb0f3e0f32c77321e6cf1 Mon Sep 17 00:00:00 2001 From: Seth Teichman Date: Sun, 13 Apr 2025 18:23:00 -0400 Subject: [PATCH 8/8] Add test for UUID to API docs tests --- flask_parameter_validation/test/test_api_docs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flask_parameter_validation/test/test_api_docs.py b/flask_parameter_validation/test/test_api_docs.py index d5602ec..33cc773 100644 --- a/flask_parameter_validation/test/test_api_docs.py +++ b/flask_parameter_validation/test/test_api_docs.py @@ -34,7 +34,8 @@ def test_doc_types_of_default(app): "str": {"opt": f"{optional_as_str}[str, NoneType]", "n_opt": "str"}, "str_enum": {"opt": f"{optional_as_str}[Fruits, NoneType]", "n_opt": "Fruits"}, "time": {"opt": f"{optional_as_str}[time, NoneType]", "n_opt": "time"}, - "union": {"opt": "Union[bool, int, NoneType]", "n_opt": "Union[bool, int]"} + "union": {"opt": "Union[bool, int, NoneType]", "n_opt": "Union[bool, int]"}, + "uuid": {"opt": f"{optional_as_str}[UUID, NoneType]", "n_opt": "UUID"} } route_unsupported_types = ["dict", "list"] route_docs = get_route_docs()