ai-station/.venv/lib/python3.12/site-packages/textual/validation.py

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