"""Validators for command-line input.
A validator is a callable that takes a string as its single parameter
and raises a :exc:`~promptpy.validators.ValidationError` if the string is invalid
according to its validation rules.
"""
from collections import Counter
from datetime import datetime
from typing import Optional
[docs]
class ValidationError(Exception):
"""Raised if input validation fails."""
[docs]
class CharacterValidator:
"""Validates that all characters are or are not within a predefined list.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import CharacterValidator, ValidationError
>>> v = CharacterValidator(valid='abcde')
>>> v('abc')
>>> v('xyz')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Invalid character(s)
>>> v = CharacterValidator(invalid='abcde')
>>> v('xyz')
>>> v('abc')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Invalid character(s)
>>> v = CharacterValidator(valid='abcde', case_sensitive=True)
>>> v('abc')
>>> v('ABC')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Invalid character(s)
:param valid: All characters must be contained in this list (default=None)
:type valid: str
:param invalid: No character may be contained in this list (default=None)
:type invalid: str
:param case_sensitive: ``True`` for case_sensitive comparison (default=False)
:type case_sensitive: bool
"""
def __init__(
self,
valid: Optional[str] = None,
invalid: Optional[str] = None,
case_sensitive=False,
) -> None:
self.valid = valid
self.invalid = invalid
self.case_sensitive = case_sensitive
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
# Check that all letters are permitted
if self.valid is not None:
valid = (
self.valid
if self.case_sensitive
else "".join([self.valid.lower(), self.valid.upper()])
)
if any(letter not in valid for letter in text):
raise ValidationError("Invalid character(s)")
# Check that no letters are invalid
if self.invalid is not None:
invalid = (
self.invalid
if self.case_sensitive
else "".join([self.invalid.lower(), self.invalid.upper()])
)
if any(letter in invalid for letter in text):
raise ValidationError("Invalid character(s)")
[docs]
class IntegerValidator:
"""Validates that an input string is a valid integer.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import IntegerValidator, ValidationError
>>> v = IntegerValidator()
>>> v('3')
>>> v('xyz')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a number
>>> v('3.2')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a whole number
>>> v = IntegerValidator(min=3)
>>> v('3')
>>> v('2')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Number should be 3 or greater
>>> v = IntegerValidator(max=6)
>>> v('3')
>>> v('7')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Number should be 6 or less
>>> v = IntegerValidator()
>>> v('')
>>> v = IntegerValidator(accept_empty=False)
>>> v('')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a number
:param min: The value cannot be lower than this (default=None)
:type min: int
:param max: The value cannot be higher than this (default=None)
:type max: int
:param accept_empty: Whether the validator should accept the empty string (default=True)
:type accept_empty: bool
"""
def __init__(
self,
min: Optional[int] = None,
max: Optional[int] = None,
accept_empty=True,
) -> None:
self.min = min
self.max = max
self.accept_empty = accept_empty
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
# Check for empty string
if not text:
if self.accept_empty:
return None
else:
raise ValidationError("Not a number")
# Convert to a float
try:
f = float(text)
except ValueError:
raise ValidationError("Not a number")
# Is it an int?
if not f.is_integer():
raise ValidationError("Not a whole number")
# Check min/max
i = int(f)
if self.min is not None and i < self.min:
raise ValidationError(f"Number should be {self.min} or greater")
if self.max is not None and i > self.max:
raise ValidationError(f"Number should be {self.max} or less")
[docs]
class FloatValidator:
"""Validates that an input string is a valid float.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import FloatValidator, ValidationError
>>> v = FloatValidator()
>>> v('3')
>>> v('3.2')
>>> v('xyz')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a number
>>> v = FloatValidator(min=3.0)
>>> v('3.0')
>>> v('2.8')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Number should be 3.0 or greater
>>> v = FloatValidator(max=6.0)
>>> v('3.0')
>>> v('6.1')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Number should be 6.0 or less
>>> v = FloatValidator()
>>> v('')
>>> v = FloatValidator(accept_empty=False)
>>> v('')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a number
:param min: The value cannot be lower than this (default=None)
:type min: float
:param max: The value cannot be higher than this (default=None)
:type max: float
:param accept_empty: Whether the validator should accept the empty string (default=True)
:type accept_empty: bool
"""
def __init__(
self,
min: Optional[float] = None,
max: Optional[float] = None,
accept_empty=True,
) -> None:
self.min = min
self.max = max
self.accept_empty = accept_empty
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
# Check for empty string
if not text:
if self.accept_empty:
return None
else:
raise ValidationError("Not a number")
# Convert to a float
try:
f = float(text)
except ValueError:
raise ValidationError("Not a number")
# Check min/max
if self.min is not None and f < self.min:
raise ValidationError(f"Number should be {self.min} or greater")
if self.max is not None and f > self.max:
raise ValidationError(f"Number should be {self.max} or less")
[docs]
class Unique:
"""Validates that the text contains no repeated characters.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import Unique, ValidationError
>>> v = Unique()
>>> v('abc')
>>> v('abb')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Repeated letter 'b'
>>> v('aBb')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Repeated letter 'b'
>>> v = Unique(case_sensitive=True)
>>> v('aBb')
:param case_sensitive: If ``False`` (the default) the validator will ignore letter case
when comparing letters, so that ``aA`` will fail validation.
If ``True`` ``a`` and ``A`` are considered as different letters
and ``aA`` will pass validation.
:type case_sensitive: bool
"""
def __init__(self, case_sensitive=False) -> None:
self.case_sensitive = case_sensitive
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
c = Counter(text if self.case_sensitive else text.casefold())
if len(c) != len(text):
letter = c.most_common()[0][0]
raise ValidationError(f"Repeated letter '{letter}'")
[docs]
class Length:
"""Validates that an input string is of the correct length.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import Length, ValidationError
>>> v = Length(min=2)
>>> v('abc')
>>> v('a')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Text too short
>>> v = Length(max=3)
>>> v('xyz')
>>> v('abcde')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Text too long
>>> v = Length(exact=3)
>>> v('abc')
>>> v('abcd')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Text must be 3 characters long
:param min: The string to validate must contain at least this number of characters
:type min: int
:param max: The string to validate cannot be longer than this number of characters
:type max: int
:param exact: The string to validate must contain exactly this number of characters
:type exact: int
"""
def __init__(self, min=0, max=0, exact=0) -> None:
self.min = min
self.max = max
self.exact = exact
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
# Test exact
if self.exact > 0 and len(text) != self.exact:
raise ValidationError(
f"Text must be {self.exact} character{'s' if self.exact > 1 else ''} long"
)
# Test min
if self.min > 0 and len(text) < self.min:
raise ValidationError("Text too short")
# Test max
if self.max > 0 and len(text) > self.max:
raise ValidationError("Text too long")
[docs]
class ChoiceValidator:
"""Validates that an input string is one of a pre-defined list of choices.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import ChoiceValidator, ValidationError
>>> v = ChoiceValidator(['hello', 'goodbye'])
>>> v('hello')
>>> v('HELLO')
>>> v('')
>>> v('ciao')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: That is not a valid option
>>> v = ChoiceValidator(['hello', 'goodbye'], case_sensitive=True)
>>> v('hello')
>>> v('HELLO')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: That is not a valid option
>>> v = ChoiceValidator(['hello', 'goodbye'], accept_empty=False)
>>> v('')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: That is not a valid option
:param choices: The string to validate must be an item in this list
:type choices: list[str]
:param case_sensitive: If True, the validation will use a case-sensitive comparison (default=False)
:type case_sensitive: bool
:param accept_empty: Whether the validator should accept the empty string (default=True)
:type accept_empty: bool
"""
def __init__(
self, choices: list[str], case_sensitive=False, accept_empty=True
) -> None:
self.choices = choices
self.case_sensitive = case_sensitive
self.accept_empty = accept_empty
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
if not text:
if self.accept_empty:
return None
else:
raise ValidationError("That is not a valid option")
# Adjust for case-sensitive comparison
choice = text if self.case_sensitive else text.casefold()
choices = (
self.choices
if self.case_sensitive
else [s.casefold() for s in self.choices]
)
if choice not in choices:
raise ValidationError("That is not a valid option")
[docs]
class DateValidator:
"""Validates that an input string is a valid date.
Pass a ``strptime`` format string in the constructor,
the validator will check that the input string can
be converted to a date using :meth:`datetime.datetime.strptime`.
To validate a string pass it as a parameter when calling
the class instance. If validation fails
it will raise a :exc:`ValidationError`.
.. doctest::
>>> from promptpy.validators import DateValidator, ValidationError
>>> v = DateValidator('%d/%m/%Y')
>>> v('10/03/2022')
>>> v('xyz')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a valid date
>>> v('0/0/0')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a valid date
>>> v('')
>>> v = DateValidator('%d/%m/%Y', accept_empty=False)
>>> v('')
Traceback (most recent call last):
...
promptpy.validators.ValidationError: Not a valid date
:param format: The format string used to parse the date
:type format: str
:param accept_empty: Whether the validator should accept the empty string (default=True)
:type accept_empty: bool
"""
def __init__(self, format: str, accept_empty=True) -> None:
self.format = format
self.accept_empty = accept_empty
def __call__(self, text: str) -> None:
"""Validate a string.
Validates according to the rules set on the class instance
and raises a :exc:`ValidationError` if validation fails.
:param text: string to validate
:type text: str
:raises: :exc:`ValidationError` if validation fails
"""
# Check for empty string
if not text:
if self.accept_empty:
return None
else:
raise ValidationError("Not a valid date")
# Try parsing the text
try:
datetime.strptime(text, self.format)
except ValueError:
raise ValidationError("Not a valid date")