Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
Seluj78 committed Jun 10, 2024
1 parent ce0198c commit c38edc6
Show file tree
Hide file tree
Showing 8 changed files with 218 additions and 42 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ def create_user():
return "User created"
```

## Documentation

You can find the full documentation at [Read the Docs](https://flask-utils.readthedocs.io/en/latest/)


## Testing

Expand Down Expand Up @@ -77,3 +81,4 @@ tox -p
- [ ] Badges
- [ ] Automatic build/deployment (https://github.com/pypa/cibuildwheel)
- [ ] https://github.com/PyCQA/flake8-bugbear
- [ ] Versioning of docs in Read the Docs
28 changes: 24 additions & 4 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
API
===

.. autosummary::
:toctree: generated
.. module:: flask_utils
:synopsis: Flask utilities

flask_utils
This part of the documentation covers all the interfaces of Flask-Utils

.. automodule:: flask_utils
Custom exceptions
-----------------

.. automodule:: flask_utils.errors
:members:


Decorators
----------

.. automodule:: flask_utils.decorators
:members:

Private API
----------------------

.. autofunction:: flask_utils.decorators._is_optional
.. autofunction:: flask_utils.decorators._make_optional
.. autofunction:: flask_utils.decorators._is_allow_empty
.. autofunction:: flask_utils.decorators._check_type

.. autofunction:: flask_utils.errors._error_template._generate_error_json
7 changes: 7 additions & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
]

autodoc_default_options = {
"members": True,
"undoc-members": True,
"private-members": False,
}

intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None),
"sphinx": ("https://www.sphinx-doc.org/en/master/", None),
Expand Down
2 changes: 1 addition & 1 deletion flask_utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Increment versions here according to SemVer
__version__ = "0.2.4"
__version__ = "0.2.5"

from flask_utils.errors import ConflictError
from flask_utils.errors import ForbiddenError
Expand Down
181 changes: 162 additions & 19 deletions flask_utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,121 @@
VALIDATE_PARAMS_MAX_DEPTH = 4


def is_optional(type_hint: Type) -> bool:
"""Check if the type hint is Optional[SomeType]."""
def _is_optional(type_hint: Type) -> bool:
"""Check if the type hint is :data:`~typing.Optional`.
:param type_hint: Type hint to check.
:return: True if the type hint is :data:`~typing.Optional`, False otherwise.
.. versionadded:: 0.2.0
.. code-block:: python
from typing import Optional
from flask_utils.decorators import _is_optional
_is_optional(Optional[str]) # True
_is_optional(str) # False
"""
return get_origin(type_hint) is Union and type(None) in get_args(type_hint)


def make_optional(type_hint: Type) -> Type:
"""Wrap type hint with Optional if it's not already."""
if not is_optional(type_hint):
def _make_optional(type_hint: Type) -> Type:
"""Wrap type hint with :data:`~typing.Optional` if it's not already.
:param type_hint: Type hint to wrap.
:return: Type hint wrapped with :data:`~typing.Optional`.
.. versionadded:: 0.2.0
.. code-block:: python
from typing import Optional
from flask_utils.decorators import _make_optional
_make_optional(str) # Optional[str]
_make_optional(Optional[str]) # Optional[str]
"""
if not _is_optional(type_hint):
return Optional[type_hint] # type: ignore
return type_hint


def is_allow_empty(value: Any, type_hint: Type, allow_empty: bool) -> bool:
"""Determine if the value is considered empty and whether it's allowed."""
def _is_allow_empty(value: Any, type_hint: Type, allow_empty: bool) -> bool:
"""Determine if the value is considered empty and whether it's allowed.
:param value: Value to check.
:param type_hint: Type hint to check against.
:param allow_empty: Whether to allow empty values.
:return: True if the value is empty and allowed, False otherwise.
.. versionadded:: 0.2.0
.. code-block:: python
from typing import Optional
from flask_utils.decorators import _is_allow_empty
_is_allow_empty(None, str, False) # False
_is_allow_empty("", str, False) # False
_is_allow_empty(None, Optional[str], False) # True
_is_allow_empty("", Optional[str], False) # True
_is_allow_empty("", Optional[str], True) # True
_is_allow_empty("", str, True) # True
_is_allow_empty([], Optional[list], False) # True
"""
if value in [None, "", [], {}]:
# Check if type is explicitly Optional or allow_empty is True
if is_optional(type_hint) or allow_empty:
if _is_optional(type_hint) or allow_empty:
return True
return False


def check_type(value: Any, expected_type: Type, allow_empty: bool = False, curr_depth: int = 0) -> bool:
def _check_type(value: Any, expected_type: Type, allow_empty: bool = False, curr_depth: int = 0) -> bool:
"""Check if the value matches the expected type, recursively if necessary.
:param value: Value to check.
:param expected_type: Expected type.
:param allow_empty: Whether to allow empty values.
:param curr_depth: Current depth of the recursive check.
:return: True if the value matches the expected type, False otherwise.
.. versionadded:: 0.2.0
Simple examples:
.. code-block:: python
from typing import List, Dict
from flask_utils.decorators import _check_type
_check_type("hello", str) # True
_check_type(42, int) # True
_check_type(42.0, float) # True
_check_type(True, bool) # True
_check_type(["hello", "world"], List[str]) # True
_check_type({"name": "Jules", "city": "Rouen"}, Dict[str, str]) # True
It also works recursively:
.. code-block:: python
from typing import List, Dict
from flask_utils.decorators import _check_type
_check_type(["hello", "world"], List[str]) # True
_check_type(["hello", 42], List[str]) # False
_check_type([{"name": "Jules", "city": "Rouen"},
{"name": "John", "city": "Paris"}], List[Dict[str, str]]) # True
_check_type([{"name": "Jules", "city": "Rouen"},
{"name": "John", "city": 42}], List[Dict[str, str]]) # False
"""

if curr_depth >= VALIDATE_PARAMS_MAX_DEPTH:
return True
if expected_type is Any or is_allow_empty(value, expected_type, allow_empty):
if expected_type is Any or _is_allow_empty(value, expected_type, allow_empty):
return True

if isinstance(value, bool):
Expand All @@ -57,10 +147,10 @@ def check_type(value: Any, expected_type: Type, allow_empty: bool = False, curr_
args = get_args(expected_type)

if origin is Union:
return any(check_type(value, arg, allow_empty, (curr_depth + 1)) for arg in args)
return any(_check_type(value, arg, allow_empty, (curr_depth + 1)) for arg in args)
elif origin is list:
return isinstance(value, list) and all(
check_type(item, args[0], allow_empty, (curr_depth + 1)) for item in value
_check_type(item, args[0], allow_empty, (curr_depth + 1)) for item in value
)
elif origin is dict:
key_type, val_type = args
Expand All @@ -69,7 +159,7 @@ def check_type(value: Any, expected_type: Type, allow_empty: bool = False, curr_
for k, v in value.items():
if not isinstance(k, key_type):
return False
if not check_type(v, val_type, allow_empty, (curr_depth + 1)):
if not _check_type(v, val_type, allow_empty, (curr_depth + 1)):
return False
return True
else:
Expand All @@ -80,11 +170,64 @@ def validate_params(
parameters: Dict[Any, Any],
allow_empty: bool = False,
):
"""Decorator to validate request JSON body parameters.
"""
Decorator to validate request JSON body parameters.
This decorator ensures that the JSON body of a request matches the specified
parameter types and includes all required parameters.
:param parameters: Dictionary of parameters to validate. The keys are parameter names
and the values are the expected types.
:param allow_empty: Allow empty values for parameters. Defaults to False.
:raises flask_utils.errors.badrequest.BadRequestError: If the JSON body is malformed,
the Content-Type header is missing or incorrect, required parameters are missing,
or parameters are of the wrong type.
.. versionadded:: 0.2.0
.. code-block:: python
from flask import Flask, request
from typing import List, Dict
from flask_utils.decorators import validate_params
from flask_utils.errors.badrequest import BadRequestError
app = Flask(__name__)
@app.route("/example", methods=["POST"])
@validate_params(
{
"name": str,
"age": int,
"is_student": bool,
"courses": List[str],
"grades": Dict[str, int],
}
)
def example():
\"""
This route expects a JSON body with the following:
- name: str
- age: int (optional)
- is_student: bool
- courses: list of str
- grades: dict with str keys and int values
\"""
data = request.get_json()
return data
Args:
parameters (Dict[Any, Any]): Dictionary of parameters to validate.
allow_empty (bool, optional): Allow empty values for parameters.
.. tip::
You can use any of the following types:
* str
* int
* float
* bool
* List
* Dict
* Any
* Optional
* Union
"""

def decorator(fn):
Expand All @@ -106,7 +249,7 @@ def wrapper(*args, **kwargs):
raise BadRequestError("JSON body must be a dict")

for key, type_hint in parameters.items():
if not is_optional(type_hint) and key not in data:
if not _is_optional(type_hint) and key not in data:
raise BadRequestError(f"Missing key: {key}", f"Expected keys are: {parameters.keys()}")

for key in data:
Expand All @@ -117,7 +260,7 @@ def wrapper(*args, **kwargs):
)

for key in data:
if key in parameters and not check_type(data[key], parameters[key], allow_empty):
if key in parameters and not _check_type(data[key], parameters[key], allow_empty):
raise BadRequestError(f"Wrong type for key {key}.", f"It should be {parameters[key]}")

return fn(*args, **kwargs)
Expand Down
Loading

0 comments on commit c38edc6

Please sign in to comment.