Skip to content

Commit

Permalink
Merge pull request #88 from d-ganchar/4.3.0
Browse files Browse the repository at this point in the history
4.3.0
  • Loading branch information
d-ganchar authored Dec 12, 2023
2 parents 4174c85 + 6e0c1b8 commit 3934835
Show file tree
Hide file tree
Showing 11 changed files with 691 additions and 174 deletions.
18 changes: 2 additions & 16 deletions flask_request_validator/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,5 @@
from .nested_json import JsonParam
from .valid_request import ValidRequest
from .after_param import AbstractAfterParam
from .rules import (
AbstractRule,
CompositeRule,
Enum,
IsDatetimeIsoFormat,
IsEmail,
MaxLength,
MinLength,
Max,
Min,
NotEmpty,
Pattern,
Datetime,
Number,
REGEX_EMAIL,
)
from .rules import *
from .files import File, FileChain
43 changes: 41 additions & 2 deletions flask_request_validator/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Union, Dict, Any
from typing import List, Union, Dict, Any, Iterable


class RequestError(Exception):
Expand Down Expand Up @@ -136,7 +136,10 @@ def __str__(self) -> str:
return 'invalid email address'


class NumberError(RuleError):
class NumberError(TypeConversionError, RuleError):
"""
@deprecated v5.0. Number rules should raise TypeConversionError
"""
def __str__(self) -> str:
return 'expected number'

Expand Down Expand Up @@ -181,15 +184,51 @@ def __str__(self) -> str:
return '. '.join(formatted)


class FileError(RequestError):
def __init__(self, file_name: str) -> None:
self.file_name = file_name


class FilesLimitError(FileError):
def __init__(self, files_limit: int) -> None:
self.files_limit = files_limit


class FileSizeError(FileError):
def __init__(self, file_name: str, file_size: int, size_limit: int) -> None:
self.file_size = file_size
self.size_limit = size_limit
super().__init__(file_name)


class FileNameError(FileError):
def __init__(self, file_names: list, names_pattern: str) -> None:
self.names_pattern = names_pattern
self.file_names = file_names


class FileMimeTypeError(FileError):
def __init__(self, file_name: str, mime_type: str, available_mime_types: Iterable) -> None:
self.mime_type = mime_type
self.available_mime_types = available_mime_types
super().__init__(file_name)


class FileMissingError(FileError):
pass


class InvalidRequestError(RequestError):
def __init__(
self,
get: Dict[str, RulesError],
form: Dict[str, RulesError],
path: Dict[str, RulesError],
json: Union[List[JsonError], Dict[str, RulesError]],
files: List[FileError],
):
self.json = json # list when nested json validation
self.path = path
self.get = get
self.form = form
self.files = files
58 changes: 58 additions & 0 deletions flask_request_validator/files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import mimetypes
import re
from typing import Iterable, Dict

from werkzeug.datastructures import FileStorage

from .exceptions import FilesLimitError, FileMimeTypeError, FileSizeError, FileNameError, FileMissingError


class File:
def __init__(self, name: str, mime_types: Iterable, max_size: int) -> None:
self._mime_types = mime_types
self._max_size = max_size
self._name = name

def validate(self, files: Dict[str, FileStorage]):
file = files.get(self._name)

if not file:
raise FileMissingError(self._name)
if file.mimetype not in self._mime_types:
raise FileMimeTypeError(file.name, file.mimetype, self._mime_types)

file_length = len(file.read())
if file_length > self._max_size:
raise FileSizeError(file.name, file_length, self._max_size)


class FileChain:
def __init__(self, mime_types: Iterable, max_size: int, max_files: int, name_pattern: str = '') -> None:
self._name_pattern = name_pattern
self._max_files = max_files
self._mime_types = mime_types
self._max_size = max_size

def validate(self, files: Dict[str, FileStorage]) -> None:
if len(files) > self._max_files:
raise FilesLimitError(self._max_files)

bad_names = []
mime_types = mimetypes.types_map
for name, file in files.items():
if self._name_pattern:
pattern = re.compile(self._name_pattern)
file_name = file.filename
for ext in mime_types.keys():
if file_name.endswith(ext):
file_name = file_name[0:file_name.rfind(ext)]
break

if not pattern.match(file_name):
bad_names.append(file.filename)
continue

File(name, self._mime_types, self._max_size).validate(files)

if bad_names:
raise FileNameError(bad_names, self._name_pattern)
123 changes: 113 additions & 10 deletions flask_request_validator/rules.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import numbers
import re
import sys
import numbers
from abc import ABC, abstractmethod
from copy import deepcopy
from datetime import datetime
from typing import Iterable

from .dt_utils import dt_from_iso
from .exceptions import *
Expand All @@ -15,18 +15,26 @@ class AbstractRule(ABC):
@abstractmethod
def validate(self, value: Any) -> Any:
"""
The returned value does not have to match the input value.
Feel free to implement conversion logic.
The returned value does not have to match the input value.
Feel free to implement conversion logic.
:param Any value:
:raises RuleError:
:param Any value:
:raises:
TypeConversionError: when a value type is incorrect. skips logical checks
RuleError: if TypeConversionError was not raised but logic restrictions
"""
pass


class CompositeRule(AbstractRule):
def __init__(self, *rules: AbstractRule) -> None:
self._rules = rules
type_checkers = (Number, BoolRule, IntRule, FloatRule)
rules_by_priority = sorted(rules, key=lambda x: 0 if isinstance(x, type_checkers) else 1)
if len(rules_by_priority) > 1 and isinstance(rules_by_priority[1], type_checkers):
raise WrongUsageError(f'You can use only 1 type. '
f'Choose one of: {", ".join([t.__name__ for t in type_checkers])}')

self._rules = rules_by_priority

def __iter__(self):
for rule in self._rules:
Expand All @@ -37,10 +45,13 @@ def validate(self, value: Any) -> Any:
:raises RulesError:
"""
errors = []
new_value = value
new_value = deepcopy(value)
for rule in self._rules:
try:
new_value = rule.validate(value=value)
new_value = rule.validate(value=new_value)
except TypeConversionError as e:
errors.append(e)
break
except RuleError as e:
errors.append(e)

Expand Down Expand Up @@ -173,5 +184,97 @@ def validate(self, value: str) -> datetime:
class Number(AbstractRule):
def validate(self, value: Any) -> Any:
if not isinstance(value, numbers.Number):
raise NumberError
raise NumberError()
return value


class IntRule(AbstractRule):
"""
>>> IntRule().validate(7)
7
>>> IntRule().validate('7')
7 # int
"""
def __init__(self, str_to_int: bool = True) -> None:
self._str_to_int = str_to_int

def validate(self, value: Any) -> Any:
if isinstance(value, int):
return value

if isinstance(value, str) and self._str_to_int:
try:
return int(value)
except ValueError:
pass

raise TypeConversionError()


class FloatRule(AbstractRule):
"""
>>> FloatRule().validate(9.99)
9.99
>>> FloatRule({','}).validate('9.99')
9.99 # float
"""
def __init__(self, delimiters: set = None) -> None:
self._delimiters = delimiters or {}

def validate(self, value: Any) -> Any:
if isinstance(value, float):
return value

if isinstance(value, str):
for char in self._delimiters:
try:
return float(value.replace(char, '.', 1))
except ValueError:
pass

raise TypeConversionError()


class BoolRule(AbstractRule):
"""
>>> BoolRule().validate(True)
True
>>> BoolRule().validate(False)
False
>>> BoolRule(yes={'plus'}).validate('PluS')
True # bool
>>> BoolRule(yes={1}).validate(1)
True # bool
>>> BoolRule(no={'no'}).validate('No')
False # bool
>>> BoolRule(no={0}).validate(0)
False # bool
"""
def __init__(self, yes: set = None, no: set = None) -> None:
self._yes = yes or set()
self._no = no or set()

def validate(self, value: Any) -> Any:
if isinstance(value, bool):
return value

if isinstance(value, int):
for yes in self._yes:
if yes == value:
return True

for no in self._no:
if no == value:
return False

if isinstance(value, str):
low_val = value.lower()
for yes in self._yes:
if yes == low_val:
return True

for no in self._no:
if no == low_val:
return False

raise TypeConversionError()
6 changes: 6 additions & 0 deletions flask_request_validator/valid_request.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from abc import abstractmethod, ABC
from typing import Dict, Any

from flask import Request


class ValidRequest(ABC):
@abstractmethod
Expand All @@ -22,3 +24,7 @@ def get_params(self) -> Dict[str, Any]:
@abstractmethod
def get_path_params(self) -> Dict[str, Any]:
pass

@abstractmethod
def get_flask_request(self) -> Request:
pass
Loading

0 comments on commit 3934835

Please sign in to comment.