import mimetypes
import re
from typing import ( # noqa: F401 # pylint: disable=unused-import
Any,
Dict,
List,
Optional,
Tuple,
Union,
)
from pykechain.enums import PropertyVTypes
from pykechain.models.validators.mime_types_defaults import predefined_mimes
from pykechain.models.validators.validator_schemas import (
fileextensionvalidator_schema,
filesizevalidator_schema,
)
from pykechain.models.validators.validators_base import PropertyValidator
from pykechain.utils import EMAIL_REGEX_PATTERN
[docs]
class NumericRangeValidator(PropertyValidator):
"""
A numeric range validator, which validates a number between a range.
The range validates positively upto and **including** the minvalue and maxvalue.
An added ability is the check if the number conforms to a step within that range.
The validation checks for both integer and floats. The stepsize is only enforced when the :attr:`enforce_stepsize`
is set to `True`. This enforcement is accurate to an accuracy set in the :const:`.accuracy`
(normally set to be 1E-6).
.. versionadded:: 2.2
:ivar minvalue: minimum value of the range
:type minvalue: float or int
:ivar maxvalue: maximum value of the range
:type maxvalue: float or int
:ivar stepsize: stepsize
:type stepsize: float or int
:ivar enforce_stepsize: flag to ensure that the stepsize is enforced
:type enforce_stepsize: bool
Examples
--------
>>> validator = NumericRangeValidator(minvalue=0, maxvalue=50)
>>> validator.is_valid(42)
True
>>> validator.is_valid(50)
True
>>> validator.is_valid(50.0001)
False
>>> validator.is_valid(-1)
False
>>> validator.get_reason()
Value '-1' should be between 0 and 50
>>> stepper = NumericRangeValidator(stepsize=1000, enforce_stepsize=True)
>>> stepper.is_valid(2000)
True
"""
vtype = PropertyVTypes.NUMERICRANGE
def __init__(
self,
json=None,
minvalue=None,
maxvalue=None,
stepsize=None,
enforce_stepsize=None,
**kwargs,
):
"""Construct the numeric range validator."""
super().__init__(json=json, **kwargs)
if minvalue is not None:
self._config["minvalue"] = minvalue
if maxvalue is not None:
self._config["maxvalue"] = maxvalue
if stepsize is not None:
self._config["stepsize"] = stepsize
if enforce_stepsize is not None:
self._config["enforce_stepsize"] = enforce_stepsize
if self._config.get("minvalue") is None:
self.minvalue = float("-inf")
else:
self.minvalue = self._config.get("minvalue")
if self._config.get("maxvalue") is None:
self.maxvalue = float("inf")
else:
self.maxvalue = self._config.get("maxvalue")
self.stepsize = self._config.get("stepsize", None)
self.enforce_stepsize = self._config.get("enforce_stepsize", None)
if self.minvalue > self.maxvalue:
raise Exception(
"The minvalue ({}) should be smaller than the maxvalue ({}) of the numeric "
"range validation".format(self.minvalue, self.maxvalue)
)
if self.enforce_stepsize and self.stepsize is None:
raise Exception("The stepsize should be provided when enforcing stepsize")
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
basereason = (
f"Value '{value}' should be between {self.minvalue} and {self.maxvalue}"
)
self._validation_result, self._validation_reason = None, "No reason"
if value is not None:
self._validation_result = value >= self.minvalue and value <= self.maxvalue
if not self._validation_result:
self._validation_reason = basereason
else:
self._validation_reason = basereason.replace("should be", "is")
if self.stepsize != 1 and self.enforce_stepsize:
# to account also for floating point stepsize checks: https://stackoverflow.com/a/30445184/246235
if self.minvalue == float("-inf"):
self._validation_result = (
abs(value / self.stepsize - round(value / self.stepsize))
< self.accuracy
)
else:
self._validation_result = (
abs(
(value - self.minvalue) / self.stepsize
- round((value - self.minvalue) / self.stepsize)
)
< self.accuracy
)
if not self._validation_result:
self._validation_reason = (
"Value '{}' is not in alignment with a stepsize of {}".format(
value, self.stepsize
)
)
return self._validation_result, self._validation_reason
[docs]
class RequiredFieldValidator(PropertyValidator):
"""
Required field validator ensures that a value is provided.
Does validate all values. Does not validate `None` or `''` (empty string).
.. versionadded:: 2.2
Examples
--------
>>> validator = RequiredFieldValidator()
>>> validator.is_valid("A value")
True
>>> validator.is_valid("")
False
>>> validator.is_valid(None)
False
>>> validator.get_reason()
"Value is required"
"""
vtype = PropertyVTypes.REQUIREDFIELD
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
basereason = "Value is required"
self._validation_result, self._validation_reason = None, "No reason"
if (
value is not None
and value != ""
and value != list()
and value != tuple()
and value != set()
):
self._validation_result = True
self._validation_reason = "Value is provided"
else:
self._validation_result = False
self._validation_reason = basereason
return self._validation_result, self._validation_reason
[docs]
class BooleanFieldValidator(PropertyValidator):
"""A boolean field validator.
This is a stub implementation. Should validate if a value is either 'truthy' or 'falsy'.
"""
vtype = PropertyVTypes.BOOLEANFIELD
[docs]
class EvenNumberValidator(PropertyValidator):
"""An even number validator that validates `True` when the number is even.
Even numbers are scalar numbers which can be diveded by 2 and return a scalar. Floating point numbers are converted
to integer first. So `int(4.5)` = 4.
.. versionadded:: 2.2
Example
-------
>>> validator = EvenNumberValidator()
>>> validator.is_valid(4)
True
>>> validator.is_valid(4.5) # float is converted to integer first
True
"""
vtype = PropertyVTypes.EVENNUMBER
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
if value is None:
self._validation_result, self.validation_reason = None, "No reason"
return self._validation_result, self._validation_reason
if not isinstance(value, (int, float)):
self._validation_result, self.validation_reason = (
False,
"Value should be an integer, or float (floored)",
)
return self._validation_result, self._validation_reason
basereason = f"Value '{value}' should be an even number"
self._validation_result = int(value) % 2 < self.accuracy
if self._validation_result:
self._validation_reason = basereason.replace("should be", "is")
return self._validation_result, self._validation_reason
else:
self._validation_reason = basereason
return self._validation_result, self._validation_reason
[docs]
class OddNumberValidator(PropertyValidator):
"""A odd number validator that validates `True` when the number is odd.
Example
-------
>>> validator = OddNumberValidator()
>>> validator.is_valid(3)
True
>>> validator.is_valid(3.5) # float is converted to integer first
True
"""
vtype = PropertyVTypes.ODDNUMBER
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
if value is None:
self._validation_result, self.validation_reason = None, "No reason"
return self._validation_result, self._validation_reason
if not isinstance(value, (int, float)):
self._validation_result, self.validation_reason = (
False,
"Value should be an integer, or float (floored)",
)
return self._validation_result, self._validation_reason
basereason = f"Value '{value}' should be a odd number"
self._validation_result = int(value) % 2 > self.accuracy
if self._validation_result:
self._validation_reason = basereason.replace("should be", "is")
return self._validation_result, self._validation_reason
else:
self._validation_reason = basereason
return self._validation_result, self._validation_reason
[docs]
class SingleReferenceValidator(PropertyValidator):
"""A single reference validator, ensuring that only a single reference is selected.
.. versionadded:: 2.2
"""
vtype = PropertyVTypes.SINGLEREFERENCE
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
if value is None:
self._validation_result, self.validation_reason = None, "No reason"
return self._validation_result, self._validation_reason
if not isinstance(value, (list, tuple, set)):
self._validation_result, self.validation_reason = (
False,
"Value should be a list, tuple or set",
)
return self._validation_result, self._validation_reason
self._validation_result = len(value) == 1 or len(value) == 0
if self._validation_result:
self._validation_reason = "A single or no value is selected"
return self._validation_result, self._validation_reason
else:
self._validation_reason = "More than a single instance is selected"
return self._validation_result, self._validation_reason
[docs]
class RegexStringValidator(PropertyValidator):
"""
A regular expression string validator.
With a configured regex pattern, a string value is compared and matched against this pattern. If there is
a positive match, the validator validates correctly.
For more information on constructing regex strings, see the `python documentation`_, `regex101.com`_, or
`regexr.com`_.
.. versionadded:: 2.2
:ivar pattern: the regex pattern to which the provided value is matched against.
Example
-------
>>> validator = RegexStringValidator(pattern=r"Yes|Y|1|Ok")
>>> validator.is_valid("Yes")
True
>>> validator.is_valid("No")
False
.. _python documentation: https://docs.python.org/2/library/re.html
.. _regex101.com: https://regex101.com/
.. _regexr.com: https://regexr.com/
"""
vtype = PropertyVTypes.REGEXSTRING
def __init__(self, json=None, pattern=None, **kwargs):
"""Construct an regex string validator.
If no pattern is provided than the regexstring `'.+'` will be used, which matches all provided text with
at least a single character. Does not match `''` (empty string).
:param json: (optional) dict (json) object to construct the object from
:type json: dict
:param pattern: (optional) valid regex string, defaults to r'.+' which matches all text.
:type text: basestring
:param kwargs: (optional) additional kwargs to pass down
:type kwargs: dict
"""
super().__init__(json=json, **kwargs)
if pattern is not None:
self._config["pattern"] = pattern
self.pattern = self._config.get("pattern", r".+")
self._re = re.compile(self.pattern)
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
if value is None:
self._validation_result, self.validation_reason = None, "No reason"
return self._validation_result, self._validation_reason
basereason = f"Value '{value}' should match the regex pattern '{self.pattern}'"
self._validation_result = re.match(self._re, value) is not None
if not self._validation_result:
self._validation_reason = basereason
else:
self._validation_reason = basereason.replace("should match", "matches")
return self._validation_result, self._validation_reason
[docs]
class EmailValidator(RegexStringValidator):
"""
A email string validator.
:cvar pattern: the email regex pattern to which the provided value is matched against.
"""
pattern = EMAIL_REGEX_PATTERN
def __init__(self, json=None, **kwargs):
"""Construct an email string validator.
:param json: (optional) dict (json) object to construct the object from
:type json: dict
:param kwargs: (optional) additional kwargs to pass down
:type kwargs: dict
"""
super().__init__(json=json, pattern=self.pattern, **kwargs)
class AlwaysAllowValidator(PropertyValidator):
"""
An always allow Validator.
Will always return True.
"""
vtype = PropertyVTypes.ALWAYSALLOW
def _logic(self, value: Any = None) -> Tuple[Union[bool, None], str]:
"""Process the inner logic of the validator.
The validation results are returned as tuple (boolean (true/false), reasontext)
"""
return True, "Always True"
[docs]
class FileSizeValidator(PropertyValidator):
"""A file size Validator.
The actual size of the file cannot be checked in pykechain without downloading this from the server, hence
when the validator is used inside an attachment property, the validator returns always being valid.
:ivar max_size: maximum size to check, in MB
:type max_size: Union[int,float]
Example
-------
>>> validator = FileSizeValidator(max_size=100)
>>> validator.is_valid(100)
True
>>> validator.is_valid(-1)
False
>>> validator.is_valid("attachments/12345678-1234-5678-1234-567812345678/some_file.txt")
True
>>> validator.get_reason()
We determine the filesize of 'some_file.txt' to be valid. We cannot check it at this end.
"""
vtype = PropertyVTypes.FILESIZE
jsonschema = filesizevalidator_schema
def __init__(
self,
json: Optional[Dict] = None,
max_size: Optional[Union[int, float]] = None,
**kwargs,
):
"""Construct a file size validator.
:param json: (optional) dict (json) object to construct the object from
:type json: Optional[Dict]
:param max_size: (optional) number that counts as maximum size of the file, in MB
:type accept: Optional[Union[int,float]]
:param kwargs: (optional) additional kwargs to pass down
"""
super().__init__(json=json, **kwargs)
if max_size is not None:
if isinstance(max_size, (int, float)):
self._config["maxSize"] = int(max_size) * 1024**2 # converting from bytes to MB
else:
raise ValueError("`max_size` should be a number.")
self.max_size = self._config.get("maxSize", float("inf"))
def _logic(
self, value: Optional[Union[int, float]] = None
) -> Tuple[Optional[bool], Optional[str]]:
"""Based on a filesize (numeric) or filepath of the property (value), the filesize is checked."""
if value is None:
return None, "No reason"
basereason = f"Value '{value}' should be of a size less then '{self.max_size}'"
if isinstance(value, (int, float)):
if int(value) <= self.max_size and int(value) >= 0:
return True, basereason.replace("should", "is")
else:
return False, basereason
return (
True,
f"We determine the filesize of '{value}' to be valid. We cannot check it at this end.",
)
[docs]
class FileExtensionValidator(PropertyValidator):
"""A file extension Validator.
It checks the value of the property attachment against a list of acceptable mime types or file extensions.
Example
-------
>>> validator = FileExtensionValidator(accept=[".png", ".jpg"])
>>> validator.is_valid("picture.jpg")
True
>>> validator.is_valid("document.pdf")
False
>>> validator.is_valid("attachments/12345678-1234-5678-1234-567812345678/some_file.txt")
False
>>> validator = FileExtensionValidator(accept=["application/pdf",
... "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"])
>>> validator.is_valid("document.pdf")
True
>>> validator.is_valid("comma-separated-values.csv")
False
>>> validator.is_valid("attachments/12345678-1234-5678-1234-567812345678/modern_excel.xlsx")
True
"""
vtype = PropertyVTypes.FILEEXTENSION
jsonschema = fileextensionvalidator_schema
mimetype_regex = r"^[-\w.]+/[-\w.\*]+$"
def __init__(
self,
json: Optional[Dict] = None,
accept: Optional[Union[str, List[str]]] = None,
**kwargs,
):
"""Construct a file extension validator.
:param json: (optional) dict (json) object to construct the object from
:type json: Optional[Dict]
:param accept: (optional) list of mimetypes or file extensions (including a `.`, eg `.csv`, `.pdf`)
:type accept: Optional[List[Text]]
:param kwargs: (optional) additional kwargs to pass down
"""
super().__init__(json=json, **kwargs)
if accept is not None:
if isinstance(accept, str):
self._config["accept"] = accept.split(",")
elif isinstance(accept, List):
self._config["accept"] = accept
else:
raise ValueError(
"`accept` should be a commaseparated list or a list of strings."
)
self.accept = self._config.get("accept", None)
self._accepted_mimetypes = self._convert_to_mimetypes(self.accept)
def _convert_to_mimetypes(self, accept: List[str]) -> Optional[List[str]]:
"""
Convert accept array to array of mimetypes.
1. convert accept list to array of mimetypes
2. convert aggregator (if inside mime_types_defaults) to list of mimetypes
:param accept: list of mimetypes or extensions
:type accept: List[Text]
:return: array of mimetypes:
:rtype: List[Text]
"""
if accept is None:
return None
marray = []
for item in accept:
# check if the item in the accept array is a mimetype on its own.
if re.match(self.mimetype_regex, item):
if item in predefined_mimes:
marray.extend(predefined_mimes.get(item))
else:
marray.append(item)
else:
# we assume this is an extension.
# we can only guess a url, we make a url like: "file.ext" to check.
fake_filename = (
f"file{item}" if item.startswith(".") else f"file.{item}"
)
# do guess
guess, _ = mimetypes.guess_type(fake_filename)
marray.append(guess) if guess is not None else print(guess)
return marray
def _logic(
self, value: Optional[str] = None
) -> Tuple[Optional[bool], Optional[str]]:
"""Based on the filename of the property (value), the type is checked.
1. convert filename to mimetype
3. check if the filename is inside the array of mimetypes. self._accepted_mimetypes
"""
if value is None:
return None, "No reason"
basereason = f"Value '{value}' should match the mime types '{self.accept}'"
guessed_type, _ = mimetypes.guess_type(value)
if guessed_type is None:
return False, f"Could not determine the mimetype of '{value}'"
elif guessed_type in self._accepted_mimetypes:
return True, basereason.replace("match", "matches")
return False, basereason