Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhanced documentation #6

Merged
merged 6 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/linting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,29 @@ jobs:
pip install types-flask
- name: Lint with mypy
run: mypy flask_utils

lint-sphinx:
name: Checking documentation with Sphinx
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: |
requirements.txt
requirements-dev.txt
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install requirements
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Lint with sphinx
run: sphinx-lint -i .venv -i .tox
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ repos:
- id: requirements-txt-fixer
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- repo: https://github.com/sphinx-contrib/sphinx-lint
rev: v0.9.1
hooks:
- id: sphinx-lint
args:
- -i .venv
- -i .tox
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ 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 @@ -72,8 +75,12 @@ tox -p

# TODO

- [ ] Documentation
- [ ] Licence
- [ ] Move todo-list to GitHub issues
- [ ] Badges
- [ ] Automatic build/deployment (https://github.com/pypa/cibuildwheel)
- [ ] [cibuildwheel](https://github.com/pypa/cibuildwheel)
- [ ] https://github.com/PyCQA/flake8-bugbear
- [ ] [flake8-bugbear](https://github.com/PyCQA/flake8-bugbear)
- [ ] Versioning of docs in Read the Docs
- [ ] Refactor documentation to avoid full links in docs (have `BadRequestError` instead of `flask_utils.errors.BadRequestError`)
- [ ] Add usage examples to documentation in the Usage section
37 changes: 34 additions & 3 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
API
===

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

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

Custom exceptions
-----------------

.. warning:: For any of these errors to work, you need to register the error handlers in your Flask app.
To do this, you can call :meth:`flask_utils.errors.register_error_handlers` with your Flask app as an argument.

.. code-block:: python

from flask_utils import register_error_handlers
register_error_handlers(app)

.. 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
189 changes: 170 additions & 19 deletions flask_utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,127 @@
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.

:Example:

.. code-block:: python

from typing import Optional
from flask_utils.decorators import _is_optional

_is_optional(Optional[str]) # True
_is_optional(str) # False

.. versionadded:: 0.2.0
"""
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`.

:Example:

.. 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]

.. versionadded:: 0.2.0
"""
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.

:Example:

.. 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

.. versionadded:: 0.2.0
"""
Seluj78 marked this conversation as resolved.
Show resolved Hide resolved
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.

:Example:

.. 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

.. versionadded:: 0.2.0
"""

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 +153,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 +165,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 +176,66 @@ 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.

:Example:

.. 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

.. tip::
You can use any of the following types:
* str
* int
* float
* bool
* List
* Dict
* Any
* Optional
* Union

Args:
parameters (Dict[Any, Any]): Dictionary of parameters to validate.
allow_empty (bool, optional): Allow empty values for parameters.
.. versionadded:: 0.2.0
"""

def decorator(fn):
Expand All @@ -106,7 +257,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 +268,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