Skip to content

Commit

Permalink
Merge pull request #50 from Ge0rg3/dev/smt5541/typing_list_deprecated
Browse files Browse the repository at this point in the history
Add support for standard list type hints
  • Loading branch information
Ge0rg3 authored Jul 13, 2024
2 parents 1db7e5a + e425b8c commit 2edb5e5
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 31 deletions.
12 changes: 8 additions & 4 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ jobs:

runs-on: ubuntu-latest

strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v3
- name: Set up Python 3
uses: actions/setup-python@v3
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: "3.x"
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
38 changes: 19 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
## Usage Example
```py
from flask import Flask
from typing import List, Optional
from typing import Optional
from flask_parameter_validation import ValidateParameters, Route, Json, Query
from datetime import datetime

Expand All @@ -22,7 +22,7 @@ def hello(
id: int = Route(),
username: str = Json(min_str_length=5, blacklist="<>"),
age: int = Json(min_int=18, max_int=99),
nicknames: List[str] = Json(),
nicknames: list[str] = Json(),
date_of_birth: datetime = Json(),
password_expiry: Optional[int] = Json(5),
is_admin: bool = Query(False),
Expand All @@ -38,7 +38,7 @@ if __name__ == "__main__":
## Usage
To validate parameters with flask-parameter-validation, two conditions must be met.
1. The `@ValidateParameters()` decorator must be applied to the function
2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter flask-parameter-validation parameter
2. Type hints ([supported types](#type-hints-and-accepted-input-types)) and a default of a subclass of `Parameter` must be supplied per parameter


### Enable and customize Validation for a Route with the @ValidateParameters decorator
Expand Down Expand Up @@ -108,20 +108,20 @@ Note: "**POST Methods**" refers to the HTTP methods that send data in the reques
#### Type Hints and Accepted Input Types
Type Hints allow for inline specification of the input type of a parameter. Some types are only available to certain `Parameter` subclasses.

| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` |
|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------|
| `str` | | Y | Y | Y | Y | N |
| `int` | | Y | Y | Y | Y | N |
| `bool` | | Y | Y | Y | Y | N |
| `float` | | Y | Y | Y | Y | N |
| `typing.List` (must not be `list`) | For `Query` and `Form` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list`. | N | Y | Y | Y | N |
| `typing.Union` | Cannot be used inside of `typing.List` | Y | Y | Y | Y | N |
| `typing.Optional` | | Y | Y | Y | Y | Y |
| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N |
| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N |
| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N |
| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N |
| `FileStorage` | | N | N | N | N | Y |
| Type Hint / Expected Python Type | Notes | `Route` | `Form` | `Json` | `Query` | `File` |
|------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|--------|--------|---------|--------|
| `str` | | Y | Y | Y | Y | N |
| `int` | | Y | Y | Y | Y | N |
| `bool` | | Y | Y | Y | Y | N |
| `float` | | Y | Y | Y | Y | N |
| `list`/`typing.List` (`typing.List` is [deprecated](https://docs.python.org/3/library/typing.html#typing.List)) | For `Query` and `Form` inputs, users can pass via either `value=1&value=2&value=3`, or `value=1,2,3`, both will be transformed to a `list` | N | Y | Y | Y | N |
| `typing.Union` | Cannot be used inside of `typing.List` | Y | Y | Y | Y | N |
| `typing.Optional` | | Y | Y | Y | Y | Y |
| `datetime.datetime` | Received as a `str` in ISO-8601 date-time format | Y | Y | Y | Y | N |
| `datetime.date` | Received as a `str` in ISO-8601 full-date format | Y | Y | Y | Y | N |
| `datetime.time` | Received as a `str` in ISO-8601 partial-time format | Y | Y | Y | Y | N |
| `dict` | For `Query` and `Form` inputs, users should pass the stringified JSON | N | Y | Y | Y | N |
| `FileStorage` | | N | N | N | N | Y |

These can be used in tandem to describe a parameter to validate: `parameter_name: type_hint = ParameterSubclass()`
- `parameter_name`: The field name itself, such as username
Expand All @@ -136,8 +136,8 @@ Validation beyond type-checking can be done by passing arguments into the constr
| `default` | any | All | Specifies the default value for the field, makes non-Optional fields not required |
| `min_str_length` | `int` | `str` | Specifies the minimum character length for a string input |
| `max_str_length` | `int` | `str` | Specifies the maximum character length for a string input |
| `min_list_length` | `int` | `typing.List` | Specifies the minimum number of elements in a list |
| `max_list_length` | `int` | `typing.List` | Specifies the maximum number of elements in a list |
| `min_list_length` | `int` | `list` | Specifies the minimum number of elements in a list |
| `max_list_length` | `int` | `list` | Specifies the maximum number of elements in a list |
| `min_int` | `int` | `int` | Specifies the minimum number for an integer input |
| `max_int` | `int` | `int` | Specifies the maximum number for an integer input |
| `whitelist` | `str` | `str` | A string containing allowed characters for the value |
Expand Down
14 changes: 7 additions & 7 deletions flask_parameter_validation/parameter_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

fn_list = dict()

list_type_hints = ["typing.List", "typing.Optional[typing.List", "list", "typing.Optional[list"]

class ValidateParameters:
@classmethod
Expand Down Expand Up @@ -65,8 +66,7 @@ def nested_func_helper(**kwargs):
# Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists)
expected_list_params = []
for name, param in expected_inputs.items():
if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith(
"typing.Optional[typing.List"):
if True in [str(param.annotation).startswith(list_hint) for list_hint in list_type_hints]:
expected_list_params.append(param.default.alias or name)

# Step 4 - Convert request inputs to dicts
Expand Down Expand Up @@ -209,7 +209,7 @@ def validate(self, expected_input, all_request_inputs):
user_inputs = [user_input]
# If typing.List in union and user supplied valid list, convert remaining check only for list
for exp_type in expected_input_types:
if str(exp_type).startswith("typing.List"):
if any(str(exp_type).startswith(list_hint) for list_hint in list_type_hints):
if type(user_input) is list:
# Only convert if validation passes
if hasattr(exp_type, "__args__"):
Expand All @@ -219,7 +219,7 @@ def validate(self, expected_input, all_request_inputs):
expected_input_type_str = str(exp_type)
user_inputs = user_input
# If list, expand inner typing items. Otherwise, convert to list to match anyway.
elif expected_input_type_str.startswith("typing.List"):
elif any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints):
expected_input_types = expected_input_type.__args__
if type(user_input) is list:
user_inputs = user_input
Expand All @@ -244,15 +244,15 @@ def validate(self, expected_input, all_request_inputs):
)

# Validate that if lists are required, lists are given
if expected_input_type_str.startswith("typing.List"):
if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints):
if type(user_input) is not list:
validation_success = False

# Error if types don't match
if not validation_success:
if hasattr(
original_expected_input_type, "__name__"
) and not original_expected_input_type_str.startswith("typing."):
) and not (original_expected_input_type_str.startswith("typing.") or original_expected_input_type_str.startswith("list")):
type_name = original_expected_input_type.__name__
else:
type_name = original_expected_input_type_str
Expand All @@ -272,6 +272,6 @@ def validate(self, expected_input, all_request_inputs):
raise ValidationError(str(e), expected_name, expected_input_type)

# Return input back to parent function
if expected_input_type_str.startswith("typing.List"):
if any(expected_input_type_str.startswith(list_hint) for list_hint in list_type_hints):
return user_inputs
return user_inputs[0]
42 changes: 42 additions & 0 deletions flask_parameter_validation/test/test_form_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,3 +903,45 @@ def test_max_list_length(client):
# Test that above length yields error
r = client.post(url, data={"v": ["the", "longest", "of", "lists"]})
assert "error" in r.json


def test_non_typing_list_str(client):
url = "/form/list/non_typing"
# Test that present single str input yields [input value]
r = client.post(url, data={"v": "w"})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 1
assert type(r.json["v"][0]) is str
assert r.json["v"][0] == "w"
# Test that present CSV str input yields [input values]
v = ["x", "y"]
r = client.post(url, data={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
# Test that missing input yields error
r = client.post(url)
assert "error" in r.json

def test_non_typing_optional_list_str(client):
url = "/form/list/optional_non_typing"
# Test that missing input yields None
r = client.post(url)
assert "v" in r.json
assert r.json["v"] is None
# Test that present str input yields [input value]
r = client.post(url, data={"v": "test"})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 1
assert type(r.json["v"][0]) is str
assert r.json["v"][0] == "test"
# Test that present CSV str input yields [input values]
v = ["two", "tests"]
r = client.post(url, data={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
31 changes: 30 additions & 1 deletion flask_parameter_validation/test/test_json_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -1034,4 +1034,33 @@ def test_dict_json_schema(client):
"last_name": "Doe"
}
r = client.post(url, json={"v": v})
assert "error" in r.json
assert "error" in r.json


def test_non_typing_list_str(client):
url = "/json/list/non_typing"
# Test that present list[str] input yields [input values]
v = ["x", "y"]
r = client.post(url, json={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
# Test that missing input yields error
r = client.post(url)
assert "error" in r.json


def test_non_typing_optional_list_str(client):
url = "/json/list/optional_non_typing"
# Test that missing input yields None
r = client.post(url)
assert "v" in r.json
assert r.json["v"] is None
# Test that present list[str] input yields [input values]
v = ["two", "tests"]
r = client.post(url, json={"v": v})
assert "v" in r.json
assert type(r.json["v"]) is list
assert len(r.json["v"]) == 2
list_assertion_helper(2, str, v, r.json["v"])
Loading

0 comments on commit 2edb5e5

Please sign in to comment.