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