520 lines
18 KiB
Python
520 lines
18 KiB
Python
"""
|
|
|
|
This module provides a number of classes for validating input.
|
|
|
|
See [Validating Input](/widgets/input/#validating-input) for details.
|
|
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import re
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Pattern, Sequence
|
|
from urllib.parse import urlparse
|
|
|
|
import rich.repr
|
|
|
|
|
|
@dataclass
|
|
class ValidationResult:
|
|
"""The result of calling a `Validator.validate` method."""
|
|
|
|
failures: Sequence[Failure] = field(default_factory=list)
|
|
"""A list of reasons why the value was invalid. Empty if valid=True"""
|
|
|
|
@staticmethod
|
|
def merge(results: Sequence["ValidationResult"]) -> "ValidationResult":
|
|
"""Merge multiple ValidationResult objects into one.
|
|
|
|
Args:
|
|
results: List of ValidationResult objects to merge.
|
|
|
|
Returns:
|
|
Merged ValidationResult object.
|
|
"""
|
|
is_valid = all(result.is_valid for result in results)
|
|
failures = [failure for result in results for failure in result.failures]
|
|
if is_valid:
|
|
return ValidationResult.success()
|
|
else:
|
|
return ValidationResult.failure(failures)
|
|
|
|
@staticmethod
|
|
def success() -> ValidationResult:
|
|
"""Construct a successful ValidationResult.
|
|
|
|
Returns:
|
|
A successful ValidationResult.
|
|
"""
|
|
return ValidationResult()
|
|
|
|
@staticmethod
|
|
def failure(failures: Sequence[Failure]) -> ValidationResult:
|
|
"""Construct a failure ValidationResult.
|
|
|
|
Args:
|
|
failures: The failures.
|
|
|
|
Returns:
|
|
A failure ValidationResult.
|
|
"""
|
|
return ValidationResult(failures)
|
|
|
|
@property
|
|
def failure_descriptions(self) -> list[str]:
|
|
"""Utility for extracting failure descriptions as strings.
|
|
|
|
Useful if you don't care about the additional metadata included in the `Failure` objects.
|
|
|
|
Returns:
|
|
A list of the string descriptions explaining the failing validations.
|
|
"""
|
|
return [
|
|
failure.description
|
|
for failure in self.failures
|
|
if failure.description is not None
|
|
]
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
"""True if the validation was successful."""
|
|
return len(self.failures) == 0
|
|
|
|
|
|
@dataclass
|
|
class Failure:
|
|
"""Information about a validation failure."""
|
|
|
|
validator: Validator
|
|
"""The Validator which produced the failure."""
|
|
value: str | None = None
|
|
"""The value which resulted in validation failing."""
|
|
description: str | None = None
|
|
"""An optional override for describing this failure. Takes precedence over any messages set in the Validator."""
|
|
|
|
def __post_init__(self) -> None:
|
|
# If a failure message isn't supplied, try to get it from the Validator.
|
|
if self.description is None:
|
|
if self.validator.failure_description is not None:
|
|
self.description = self.validator.failure_description
|
|
else:
|
|
self.description = self.validator.describe_failure(self)
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
|
|
yield self.value
|
|
yield self.validator
|
|
yield self.description
|
|
|
|
|
|
class Validator(ABC):
|
|
'''Base class for the validation of string values.
|
|
|
|
Commonly used in conjunction with the `Input` widget, which accepts a
|
|
list of validators via its constructor. This validation framework can also be used to validate any 'stringly-typed'
|
|
values (for example raw command line input from `sys.args`).
|
|
|
|
To implement your own `Validator`, subclass this class.
|
|
|
|
Example:
|
|
```python
|
|
def is_palindrome(value: str) -> bool:
|
|
"""Check has string has the same code points left to right, as right to left."""
|
|
return value == value[::-1]
|
|
|
|
class Palindrome(Validator):
|
|
def validate(self, value: str) -> ValidationResult:
|
|
if is_palindrome(value):
|
|
return self.success()
|
|
else:
|
|
return self.failure("Not a palindrome!")
|
|
```
|
|
'''
|
|
|
|
def __init__(self, failure_description: str | None = None) -> None:
|
|
self.failure_description = failure_description
|
|
"""A description of why the validation failed.
|
|
|
|
The description (intended to be user-facing) to attached to the Failure if the validation fails.
|
|
This failure description is ultimately accessible at the time of validation failure via the `Input.Changed`
|
|
or `Input.Submitted` event, and you can access it on your message handler (a method called, for example,
|
|
`on_input_changed` or a method decorated with `@on(Input.Changed)`.
|
|
"""
|
|
|
|
@abstractmethod
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Validate the value and return a ValidationResult describing the outcome of the validation.
|
|
|
|
Implement this method when defining custom validators.
|
|
|
|
Args:
|
|
value: The value to validate.
|
|
|
|
Returns:
|
|
The result of the validation ([`self.success()`][textual.validation.Validator.success) or [`self.failure(...)`][textual.validation.Validator.failure]).
|
|
"""
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Return a string description of the Failure.
|
|
|
|
Used to provide a more fine-grained description of the failure. A Validator could fail for multiple
|
|
reasons, so this method could be used to provide a different reason for different types of failure.
|
|
|
|
!!! warning
|
|
|
|
This method is only called if no other description has been supplied. If you supply a description
|
|
inside a call to `self.failure(description="...")`, or pass a description into the constructor of
|
|
the validator, those will take priority, and this method won't be called.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
return self.failure_description
|
|
|
|
def success(self) -> ValidationResult:
|
|
"""Shorthand for `ValidationResult(True)`.
|
|
|
|
Return `self.success()` from [`validate()`][textual.validation.Validator.validate] to indicated that validation *succeeded*.
|
|
|
|
Returns:
|
|
A ValidationResult indicating validation succeeded.
|
|
"""
|
|
return ValidationResult()
|
|
|
|
def failure(
|
|
self,
|
|
description: str | None = None,
|
|
value: str | None = None,
|
|
failures: Failure | Sequence[Failure] | None = None,
|
|
) -> ValidationResult:
|
|
"""Shorthand for signaling validation failure.
|
|
|
|
Return `self.failure(...)` from [`validate()`][textual.validation.Validator.validate] to indicated that validation *failed*.
|
|
|
|
Args:
|
|
description: The failure description that will be used. When used in conjunction with the Input widget,
|
|
this is the description that will ultimately be available inside the handler for `Input.Changed`. If not
|
|
supplied, the `failure_description` from the `Validator` will be used. If that is not supplied either,
|
|
then the `describe_failure` method on `Validator` will be called.
|
|
value: The value that was considered invalid. This is optional, and only needs to be supplied if required
|
|
in your `Input.Changed` handler.
|
|
failures: The reasons the validator failed. If not supplied, a generic `Failure` will be included in the
|
|
ValidationResult returned from this function.
|
|
|
|
Returns:
|
|
A ValidationResult representing failed validation, and containing the metadata supplied
|
|
to this function.
|
|
"""
|
|
if isinstance(failures, Failure):
|
|
failures = [failures]
|
|
|
|
result = ValidationResult(
|
|
failures or [Failure(validator=self, value=value, description=description)],
|
|
)
|
|
return result
|
|
|
|
|
|
class Regex(Validator):
|
|
"""A validator that checks the value matches a regex (via `re.fullmatch`)."""
|
|
|
|
def __init__(
|
|
self,
|
|
regex: str | Pattern[str],
|
|
flags: int | re.RegexFlag = 0,
|
|
failure_description: str | None = None,
|
|
) -> None:
|
|
super().__init__(failure_description=failure_description)
|
|
self.regex = regex
|
|
"""The regex which we'll validate is matched by the value."""
|
|
self.flags = flags
|
|
"""The flags to pass to `re.fullmatch`."""
|
|
|
|
class NoResults(Failure):
|
|
"""Indicates validation failed because the regex could not be found within the value string."""
|
|
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Ensure that the value matches the regex.
|
|
|
|
Args:
|
|
value: The value that should match the regex.
|
|
|
|
Returns:
|
|
The result of the validation.
|
|
"""
|
|
regex = self.regex
|
|
has_match = re.fullmatch(regex, value, flags=self.flags) is not None
|
|
if not has_match:
|
|
failures = [Regex.NoResults(self, value)]
|
|
return self.failure(failures=failures)
|
|
return self.success()
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Describes why the validator failed.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
return f"Must match regular expression {self.regex!r} (flags={self.flags})."
|
|
|
|
|
|
class Number(Validator):
|
|
"""Validator that ensures the value is a number, with an optional range check."""
|
|
|
|
def __init__(
|
|
self,
|
|
minimum: float | None = None,
|
|
maximum: float | None = None,
|
|
failure_description: str | None = None,
|
|
) -> None:
|
|
super().__init__(failure_description=failure_description)
|
|
self.minimum = minimum
|
|
"""The minimum value of the number, inclusive. If `None`, the minimum is unbounded."""
|
|
self.maximum = maximum
|
|
"""The maximum value of the number, inclusive. If `None`, the maximum is unbounded."""
|
|
|
|
class NotANumber(Failure):
|
|
"""Indicates a failure due to the value not being a valid number (decimal/integer, inc. scientific notation)"""
|
|
|
|
class NotInRange(Failure):
|
|
"""Indicates a failure due to the number not being within the range [minimum, maximum]."""
|
|
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Ensure that `value` is a valid number, optionally within a range.
|
|
|
|
Args:
|
|
value: The value to validate.
|
|
|
|
Returns:
|
|
The result of the validation.
|
|
"""
|
|
try:
|
|
float_value = float(value)
|
|
except ValueError:
|
|
return ValidationResult.failure([Number.NotANumber(self, value)])
|
|
|
|
if math.isnan(float_value) or math.isinf(float_value):
|
|
return ValidationResult.failure([Number.NotANumber(self, value)])
|
|
|
|
if not self._validate_range(float_value):
|
|
return ValidationResult.failure(
|
|
[Number.NotInRange(self, value)],
|
|
)
|
|
return self.success()
|
|
|
|
def _validate_range(self, value: float) -> bool:
|
|
"""Return a boolean indicating whether the number is within the range specified in the attributes."""
|
|
if self.minimum is not None and value < self.minimum:
|
|
return False
|
|
if self.maximum is not None and value > self.maximum:
|
|
return False
|
|
return True
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Describes why the validator failed.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
if isinstance(failure, Number.NotANumber):
|
|
return "Must be a valid number."
|
|
elif isinstance(failure, Number.NotInRange):
|
|
if self.minimum is None and self.maximum is not None:
|
|
return f"Must be less than or equal to {self.maximum}."
|
|
elif self.minimum is not None and self.maximum is None:
|
|
return f"Must be greater than or equal to {self.minimum}."
|
|
else:
|
|
return f"Must be between {self.minimum} and {self.maximum}."
|
|
else:
|
|
return None
|
|
|
|
|
|
class Integer(Number):
|
|
"""Validator which ensures the value is an integer which falls within a range."""
|
|
|
|
class NotAnInteger(Failure):
|
|
"""Indicates a failure due to the value not being a valid integer."""
|
|
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Ensure that `value` is an integer, optionally within a range.
|
|
|
|
Args:
|
|
value: The value to validate.
|
|
|
|
Returns:
|
|
The result of the validation.
|
|
"""
|
|
# First, check that we're dealing with a number in the range.
|
|
number_validation_result = super().validate(value)
|
|
if not number_validation_result.is_valid:
|
|
return number_validation_result
|
|
|
|
# We know it's a number, but is that number an integer?
|
|
try:
|
|
int_value = int(value)
|
|
except ValueError:
|
|
return ValidationResult.failure([Integer.NotAnInteger(self, value)])
|
|
return self.success()
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Describes why the validator failed.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
if isinstance(failure, (Integer.NotANumber, Integer.NotAnInteger)):
|
|
return "Must be a valid integer."
|
|
elif isinstance(failure, Integer.NotInRange):
|
|
if self.minimum is None and self.maximum is not None:
|
|
return f"Must be less than or equal to {self.maximum}."
|
|
elif self.minimum is not None and self.maximum is None:
|
|
return f"Must be greater than or equal to {self.minimum}."
|
|
else:
|
|
return f"Must be between {self.minimum} and {self.maximum}."
|
|
else:
|
|
return None
|
|
|
|
|
|
class Length(Validator):
|
|
"""Validate that a string is within a range (inclusive)."""
|
|
|
|
def __init__(
|
|
self,
|
|
minimum: int | None = None,
|
|
maximum: int | None = None,
|
|
failure_description: str | None = None,
|
|
) -> None:
|
|
super().__init__(failure_description=failure_description)
|
|
self.minimum = minimum
|
|
"""The inclusive minimum length of the value, or None if unbounded."""
|
|
self.maximum = maximum
|
|
"""The inclusive maximum length of the value, or None if unbounded."""
|
|
|
|
class Incorrect(Failure):
|
|
"""Indicates a failure due to the length of the value being outside the range."""
|
|
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Ensure that value falls within the maximum and minimum length constraints.
|
|
|
|
Args:
|
|
value: The value to validate.
|
|
|
|
Returns:
|
|
The result of the validation.
|
|
"""
|
|
too_short = self.minimum is not None and len(value) < self.minimum
|
|
too_long = self.maximum is not None and len(value) > self.maximum
|
|
if too_short or too_long:
|
|
return ValidationResult.failure([Length.Incorrect(self, value)])
|
|
return self.success()
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Describes why the validator failed.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
if isinstance(failure, Length.Incorrect):
|
|
if self.minimum is None and self.maximum is not None:
|
|
return f"Must be shorter than {self.maximum} characters."
|
|
elif self.minimum is not None and self.maximum is None:
|
|
return f"Must be longer than {self.minimum} characters."
|
|
else:
|
|
return f"Must be between {self.minimum} and {self.maximum} characters."
|
|
return None
|
|
|
|
|
|
class Function(Validator):
|
|
"""A flexible validator which allows you to provide custom validation logic."""
|
|
|
|
def __init__(
|
|
self,
|
|
function: Callable[[str], bool],
|
|
failure_description: str | None = None,
|
|
) -> None:
|
|
super().__init__(failure_description=failure_description)
|
|
self.function = function
|
|
"""Function which takes the value to validate and returns True if valid, and False otherwise."""
|
|
|
|
class ReturnedFalse(Failure):
|
|
"""Indicates validation failed because the supplied function returned False."""
|
|
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Validate that the supplied function returns True.
|
|
|
|
Args:
|
|
value: The value to pass into the supplied function.
|
|
|
|
Returns:
|
|
A ValidationResult indicating success if the function returned True,
|
|
and failure if the function return False.
|
|
"""
|
|
is_valid = self.function(value)
|
|
if is_valid:
|
|
return self.success()
|
|
return self.failure(failures=Function.ReturnedFalse(self, value))
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Describes why the validator failed.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
return self.failure_description
|
|
|
|
|
|
class URL(Validator):
|
|
"""Validator that checks if a URL is valid (ensuring a scheme is present)."""
|
|
|
|
class InvalidURL(Failure):
|
|
"""Indicates that the URL is not valid."""
|
|
|
|
def validate(self, value: str) -> ValidationResult:
|
|
"""Validates that `value` is a valid URL (contains a scheme).
|
|
|
|
Args:
|
|
value: The value to validate.
|
|
|
|
Returns:
|
|
The result of the validation.
|
|
"""
|
|
invalid_url = ValidationResult.failure([URL.InvalidURL(self, value)])
|
|
try:
|
|
parsed_url = urlparse(value)
|
|
if not all([parsed_url.scheme, parsed_url.netloc]):
|
|
return invalid_url
|
|
except ValueError:
|
|
return invalid_url
|
|
|
|
return self.success()
|
|
|
|
def describe_failure(self, failure: Failure) -> str | None:
|
|
"""Describes why the validator failed.
|
|
|
|
Args:
|
|
failure: Information about why the validation failed.
|
|
|
|
Returns:
|
|
A string description of the failure.
|
|
"""
|
|
return "Must be a valid URL."
|