mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Validation (#2600)
* A few different types of validation * Rename * Fix test * Updating validation framework * Update lockfile * Ensure validators can be None * Reworking the API a little * Convert Input.Changed to dataclass * Add utility for getting failures as strings * Update an example in Validator docstring * Remove some redundant `pass`es * Renaming variables * Validating Input on submit, attaching result to Submitted event * Testing various validation features * Update snapshots and deps * Styling unfocused -invalid Input differently * Add snapshot test around input validation and associated styles * Validation docs * Tidying validation docs in Input widget reference * Fix mypy issues * Remove __bool__ from Failure, make validator field required * Code review changes * Improving error messages in Validators
This commit is contained in:
1
docs/api/validation.md
Normal file
1
docs/api/validation.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.validation
|
||||
72
docs/examples/widgets/input_validation.py
Normal file
72
docs/examples/widgets/input_validation.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.validation import Function, Number, ValidationResult, Validator
|
||||
from textual.widgets import Input, Label, Pretty
|
||||
|
||||
|
||||
class InputApp(App):
|
||||
# (6)!
|
||||
CSS = """
|
||||
Input.-valid {
|
||||
border: tall $success 60%;
|
||||
}
|
||||
Input.-valid:focus {
|
||||
border: tall $success;
|
||||
}
|
||||
Input {
|
||||
margin: 1 1;
|
||||
}
|
||||
Label {
|
||||
margin: 1 2;
|
||||
}
|
||||
Pretty {
|
||||
margin: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Label("Enter an even number between 1 and 100 that is also a palindrome.")
|
||||
yield Input(
|
||||
placeholder="Enter a number...",
|
||||
validators=[
|
||||
Number(minimum=1, maximum=100), # (1)!
|
||||
Function(is_even, "Value is not even."), # (2)!
|
||||
Palindrome(), # (3)!
|
||||
],
|
||||
)
|
||||
yield Pretty([])
|
||||
|
||||
@on(Input.Changed)
|
||||
def show_invalid_reasons(self, event: Input.Changed) -> None:
|
||||
# Updating the UI to show the reasons why validation failed
|
||||
if not event.validation_result.is_valid: # (4)!
|
||||
self.query_one(Pretty).update(event.validation_result.failure_descriptions)
|
||||
else:
|
||||
self.query_one(Pretty).update([])
|
||||
|
||||
|
||||
def is_even(value: str) -> bool:
|
||||
try:
|
||||
return int(value) % 2 == 0
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# A custom validator
|
||||
class Palindrome(Validator): # (5)!
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
"""Check a string is equal to its reverse."""
|
||||
if self.is_palindrome(value):
|
||||
return self.success()
|
||||
else:
|
||||
return self.failure("That's not a palindrome :/")
|
||||
|
||||
@staticmethod
|
||||
def is_palindrome(value: str) -> bool:
|
||||
return value == value[::-1]
|
||||
|
||||
|
||||
app = InputApp()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
@@ -5,7 +5,9 @@ A single-line text input widget.
|
||||
- [x] Focusable
|
||||
- [ ] Container
|
||||
|
||||
## Example
|
||||
## Examples
|
||||
|
||||
### A Simple Example
|
||||
|
||||
The example below shows how you might create a simple form using two `Input` widgets.
|
||||
|
||||
@@ -20,10 +22,52 @@ The example below shows how you might create a simple form using two `Input` wid
|
||||
--8<-- "docs/examples/widgets/input.py"
|
||||
```
|
||||
|
||||
### Validating Input
|
||||
|
||||
You can supply one or more *[validators][textual.validation.Validator]* to the `Input` widget to validate the value.
|
||||
|
||||
When the value changes or the `Input` is submitted, all the supplied validators will run.
|
||||
|
||||
Validation is considered to have failed if *any* of the validators fail.
|
||||
|
||||
You can check whether the validation succeeded or failed inside an [Input.Changed][textual.widgets.Input.Changed] or
|
||||
[Input.Submitted][textual.widgets.Input.Submitted] handler by looking at the `validation_result` attribute on these events.
|
||||
|
||||
In the example below, we show how to combine multiple validators and update the UI to tell the user
|
||||
why validation failed.
|
||||
Click the tabs to see the output for validation failures and successes.
|
||||
|
||||
=== "input_validation.py"
|
||||
|
||||
```python hl_lines="8-15 31-35 42-45 56-62"
|
||||
--8<-- "docs/examples/widgets/input_validation.py"
|
||||
```
|
||||
|
||||
1. `Number` is a built-in `Validator`. It checks that the value in the `Input` is a valid number, and optionally can check that it falls within a range.
|
||||
2. `Function` lets you quickly define custom validation constraints. In this case, we check the value in the `Input` is even.
|
||||
3. `Palindrome` is a custom `Validator` defined below.
|
||||
4. The `Input.Changed` event has a `validation_result` attribute which contains information about the validation that occurred when the value changed.
|
||||
5. Here's how we can implement a custom validator which checks if a string is a palindrome. Note how the description passed into `self.failure` corresponds to the message seen on UI.
|
||||
6. Textual offers default styling for the `-invalid` CSS class (a red border), which is automatically applied to `Input` when validation fails. We can also provide custom styling for the `-valid` class, as seen here. In this case, we add a green border around the `Input` to indicate successful validation.
|
||||
|
||||
=== "Validation Failure"
|
||||
|
||||
```{.textual path="docs/examples/widgets/input_validation.py" press="-,2,3"}
|
||||
```
|
||||
|
||||
=== "Validation Success"
|
||||
|
||||
```{.textual path="docs/examples/widgets/input_validation.py" press="4,4"}
|
||||
```
|
||||
|
||||
Textual offers several [built-in validators][textual.validation] for common requirements,
|
||||
but you can easily roll your own by extending [Validator][textual.validation.Validator],
|
||||
as seen for `Palindrome` in the example above.
|
||||
|
||||
## Reactive Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ----------------- | ------ | ------- | --------------------------------------------------------------- |
|
||||
|-------------------|--------|---------|-----------------------------------------------------------------|
|
||||
| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. |
|
||||
| `value` | `str` | `""` | The value currently in the text input. |
|
||||
| `cursor_position` | `int` | `0` | The index of the cursor in the value string. |
|
||||
|
||||
2930
poetry.lock
generated
2930
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Pattern, Union
|
||||
|
||||
from rich.segment import Segment
|
||||
from typing_extensions import Protocol
|
||||
from typing_extensions import Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message import Message
|
||||
|
||||
511
src/textual/validation.py
Normal file
511
src/textual/validation.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""Framework for validating string values"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Sequence
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import rich.repr
|
||||
|
||||
from textual._types import Pattern
|
||||
|
||||
|
||||
@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
|
||||
class Palindrome(Validator):
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
def is_palindrome(value: str) -> bool:
|
||||
return value == value[::-1]
|
||||
return self.success() if is_palindrome(value) else self.failure("Not 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.
|
||||
|
||||
Args:
|
||||
value: The value to validate.
|
||||
|
||||
Returns:
|
||||
The result of the validation.
|
||||
"""
|
||||
|
||||
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)`.
|
||||
|
||||
You can return success() from a `Validator.validate` method implementation to signal
|
||||
that validation has 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.
|
||||
|
||||
You can return failure(...) from a `Validator.validate` implementation to signal validation succeeded.
|
||||
|
||||
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.
|
||||
validator: The validator that performed the validation. 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 float_value in {math.nan, math.inf, -math.inf}:
|
||||
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 f"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?
|
||||
is_integer = float(value).is_integer()
|
||||
if not is_integer:
|
||||
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.NotAnInteger):
|
||||
return f"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."
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import ClassVar
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Iterable
|
||||
|
||||
from rich.cells import cell_len, get_character_cell_size
|
||||
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
||||
@@ -16,6 +17,7 @@ from ..events import Blur, Focus, Mount
|
||||
from ..geometry import Size
|
||||
from ..message import Message
|
||||
from ..reactive import reactive
|
||||
from ..validation import Failure, ValidationResult, Validator
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
@@ -122,6 +124,12 @@ class Input(Widget, can_focus=True):
|
||||
Input>.input--placeholder {
|
||||
color: $text-disabled;
|
||||
}
|
||||
Input.-invalid {
|
||||
border: tall $error 60%;
|
||||
}
|
||||
Input.-invalid:focus {
|
||||
border: tall $error;
|
||||
}
|
||||
"""
|
||||
|
||||
cursor_blink = reactive(True)
|
||||
@@ -136,42 +144,45 @@ class Input(Widget, can_focus=True):
|
||||
password = reactive(False)
|
||||
max_size: reactive[int | None] = reactive(None)
|
||||
|
||||
class Changed(Message, bubble=True):
|
||||
@dataclass
|
||||
class Changed(Message):
|
||||
"""Posted when the value changes.
|
||||
|
||||
Can be handled using `on_input_changed` in a subclass of `Input` or in a parent
|
||||
widget in the DOM.
|
||||
|
||||
Attributes:
|
||||
value: The value that the input was changed to.
|
||||
input: The `Input` widget that was changed.
|
||||
"""
|
||||
|
||||
def __init__(self, input: Input, value: str) -> None:
|
||||
super().__init__()
|
||||
self.input: Input = input
|
||||
self.value: str = value
|
||||
input: Input
|
||||
"""The `Input` widget that was changed."""
|
||||
|
||||
value: str
|
||||
"""The value that the input was changed to."""
|
||||
|
||||
validation_result: ValidationResult | None = None
|
||||
"""The result of validating the value (formed by combining the results from each validator), or None
|
||||
if validation was not performed (for example when no validators are specified in the `Input`s init)"""
|
||||
|
||||
@property
|
||||
def control(self) -> Input:
|
||||
"""Alias for self.input."""
|
||||
return self.input
|
||||
|
||||
class Submitted(Message, bubble=True):
|
||||
@dataclass
|
||||
class Submitted(Message):
|
||||
"""Posted when the enter key is pressed within an `Input`.
|
||||
|
||||
Can be handled using `on_input_submitted` in a subclass of `Input` or in a
|
||||
parent widget in the DOM.
|
||||
|
||||
Attributes:
|
||||
value: The value of the `Input` being submitted.
|
||||
input: The `Input` widget that is being submitted.
|
||||
"""
|
||||
|
||||
def __init__(self, input: Input, value: str) -> None:
|
||||
super().__init__()
|
||||
self.input: Input = input
|
||||
self.value: str = value
|
||||
input: Input
|
||||
"""The `Input` widget that is being submitted."""
|
||||
value: str
|
||||
"""The value of the `Input` being submitted."""
|
||||
validation_result: ValidationResult | None = None
|
||||
"""The result of validating the value on submission, formed by combining the results for each validator.
|
||||
This value will be None if no validation was performed, which will be the case if no validators are supplied
|
||||
to the corresponding `Input` widget."""
|
||||
|
||||
@property
|
||||
def control(self) -> Input:
|
||||
@@ -184,6 +195,7 @@ class Input(Widget, can_focus=True):
|
||||
placeholder: str = "",
|
||||
highlighter: Highlighter | None = None,
|
||||
password: bool = False,
|
||||
validators: Validator | Iterable[Validator] | None = None,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
@@ -196,6 +208,7 @@ class Input(Widget, can_focus=True):
|
||||
placeholder: Optional placeholder text for the input.
|
||||
highlighter: An optional highlighter for the input.
|
||||
password: Flag to say if the field should obfuscate its content. Default is `False`.
|
||||
validators: An iterable of validators that the Input value will be checked against.
|
||||
name: Optional name for the input widget.
|
||||
id: Optional ID for the widget.
|
||||
classes: Optional initial classes for the widget.
|
||||
@@ -207,6 +220,13 @@ class Input(Widget, can_focus=True):
|
||||
self.placeholder = placeholder
|
||||
self.highlighter = highlighter
|
||||
self.password = password
|
||||
# Ensure we always end up with an Iterable of validators
|
||||
if isinstance(validators, Validator):
|
||||
self.validators: list[Validator] = [validators]
|
||||
elif validators is None:
|
||||
self.validators = []
|
||||
else:
|
||||
self.validators = list(validators) or []
|
||||
|
||||
def _position_to_cell(self, position: int) -> int:
|
||||
"""Convert an index within the value to cell position."""
|
||||
@@ -254,7 +274,36 @@ class Input(Widget, can_focus=True):
|
||||
async def watch_value(self, value: str) -> None:
|
||||
if self.styles.auto_dimensions:
|
||||
self.refresh(layout=True)
|
||||
self.post_message(self.Changed(self, value))
|
||||
|
||||
validation_result = self.validate(value)
|
||||
|
||||
self.post_message(self.Changed(self, value, validation_result))
|
||||
|
||||
def validate(self, value: str) -> ValidationResult | None:
|
||||
"""Run all the validators associated with this Input on the supplied value.
|
||||
|
||||
Runs all validators, combines the result into one. If any of the validators
|
||||
failed, the combined result will be a failure. If no validators are present,
|
||||
None will be returned. This also sets the `-invalid` CSS class on the Input
|
||||
if the validation fails, and sets the `-valid` CSS class on the Input if
|
||||
the validation succeeds.
|
||||
|
||||
Returns:
|
||||
A ValidationResult indicating whether *all* validators succeeded or not.
|
||||
That is, if *any* validator fails, the result will be an unsuccessful
|
||||
validation.
|
||||
"""
|
||||
# If no validators are supplied, and therefore no validation occurs, we return None.
|
||||
if not self.validators:
|
||||
return None
|
||||
|
||||
validation_results: list[ValidationResult] = [
|
||||
validator.validate(value) for validator in self.validators
|
||||
]
|
||||
combined_result = ValidationResult.merge(validation_results)
|
||||
self.set_class(not combined_result.is_valid, "-invalid")
|
||||
self.set_class(combined_result.is_valid, "-valid")
|
||||
return combined_result
|
||||
|
||||
@property
|
||||
def cursor_width(self) -> int:
|
||||
@@ -438,7 +487,7 @@ class Input(Widget, can_focus=True):
|
||||
self.value = self.value[: self.cursor_position]
|
||||
else:
|
||||
self.value = (
|
||||
f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}"
|
||||
f"{self.value[: self.cursor_position]}{after[hit.end() - 1:]}"
|
||||
)
|
||||
|
||||
def action_delete_right_all(self) -> None:
|
||||
@@ -490,5 +539,9 @@ class Input(Widget, can_focus=True):
|
||||
self.cursor_position = 0
|
||||
|
||||
async def action_submit(self) -> None:
|
||||
"""Handle a submit action (normally the user hitting Enter in the input)."""
|
||||
self.post_message(self.Submitted(self, self.value))
|
||||
"""Handle a submit action.
|
||||
|
||||
Normally triggered by the user pressing Enter. This will also run any validators.
|
||||
"""
|
||||
validation_result = self.validate(self.value)
|
||||
self.post_message(self.Submitted(self, self.value, validation_result))
|
||||
|
||||
@@ -16,7 +16,7 @@ class Pretty(Widget):
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Static {
|
||||
Pretty {
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
79
tests/input/test_input_validation.py
Normal file
79
tests/input/test_input_validation.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.validation import Number, ValidationResult
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
class InputApp(App):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
self.validator = Number(minimum=1, maximum=5)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(
|
||||
validators=self.validator,
|
||||
)
|
||||
|
||||
@on(Input.Changed)
|
||||
@on(Input.Submitted)
|
||||
def on_changed_or_submitted(self, event):
|
||||
self.messages.append(event)
|
||||
|
||||
|
||||
async def test_input_changed_message_validation_failure():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "8"
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.failure(
|
||||
failures=[
|
||||
Number.NotInRange(
|
||||
value="8",
|
||||
validator=app.validator,
|
||||
description="Must be between 1 and 5.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_input_changed_message_validation_success():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "3"
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[0].validation_result == ValidationResult.success()
|
||||
|
||||
|
||||
async def test_input_submitted_message_validation_failure():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "8"
|
||||
await input.action_submit()
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 2
|
||||
assert app.messages[1].validation_result == ValidationResult.failure(
|
||||
failures=[
|
||||
Number.NotInRange(
|
||||
value="8",
|
||||
validator=app.validator,
|
||||
description="Must be between 1 and 5.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_input_submitted_message_validation_success():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.value = "3"
|
||||
await input.action_submit()
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 2
|
||||
assert app.messages[1].validation_result == ValidationResult.success()
|
||||
File diff suppressed because one or more lines are too long
45
tests/snapshot_tests/snapshot_apps/input_validation.py
Normal file
45
tests/snapshot_tests/snapshot_apps/input_validation.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.validation import Number
|
||||
from textual.widgets import Input
|
||||
|
||||
VALIDATORS = [
|
||||
Number(minimum=1, maximum=5),
|
||||
]
|
||||
|
||||
|
||||
class InputApp(App):
|
||||
CSS = """
|
||||
Input.-valid {
|
||||
border: tall $success 60%;
|
||||
}
|
||||
Input.-valid:focus {
|
||||
border: tall $success;
|
||||
}
|
||||
Input {
|
||||
margin: 1 2;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
yield Input(
|
||||
placeholder="Enter a number between 1 and 5",
|
||||
validators=VALIDATORS,
|
||||
)
|
||||
|
||||
|
||||
app = InputApp()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
@@ -84,6 +84,19 @@ def test_input_and_focus(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press)
|
||||
|
||||
|
||||
def test_input_validation(snap_compare):
|
||||
"""Checking that invalid styling is applied. The snapshot app itself
|
||||
also adds styling for -valid which gives a green border."""
|
||||
press = [
|
||||
*"-2", # -2 is invalid, so -invalid should be applied
|
||||
"tab",
|
||||
"3", # This is valid, so -valid should be applied
|
||||
"tab",
|
||||
*"-2", # -2 is invalid, so -invalid should be applied (and :focus, since we stop here)
|
||||
]
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press)
|
||||
|
||||
|
||||
def test_buttons_render(snap_compare):
|
||||
# Testing button rendering. We press tab to focus the first button too.
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
|
||||
|
||||
@@ -1010,6 +1010,7 @@ async def test_scrolling_cursor_into_view():
|
||||
table.add_rows([(n,) for n in range(300)])
|
||||
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
assert table.scroll_y > 100
|
||||
|
||||
|
||||
|
||||
216
tests/test_validation.py
Normal file
216
tests/test_validation.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.validation import (
|
||||
URL,
|
||||
Failure,
|
||||
Function,
|
||||
Integer,
|
||||
Length,
|
||||
Number,
|
||||
Regex,
|
||||
ValidationResult,
|
||||
Validator,
|
||||
)
|
||||
|
||||
VALIDATOR = Function(lambda value: True)
|
||||
|
||||
|
||||
def test_ValidationResult_merge_successes():
|
||||
results = [ValidationResult.success(), ValidationResult.success()]
|
||||
assert ValidationResult.merge(results) == ValidationResult.success()
|
||||
|
||||
|
||||
def test_ValidationResult_merge_failures():
|
||||
failure_one = Failure(VALIDATOR, "1")
|
||||
failure_two = Failure(VALIDATOR, "2")
|
||||
results = [
|
||||
ValidationResult.failure([failure_one]),
|
||||
ValidationResult.failure([failure_two]),
|
||||
ValidationResult.success(),
|
||||
]
|
||||
expected_result = ValidationResult.failure([failure_one, failure_two])
|
||||
assert ValidationResult.merge(results) == expected_result
|
||||
|
||||
|
||||
def test_ValidationResult_failure_descriptions():
|
||||
result = ValidationResult.failure(
|
||||
[
|
||||
Failure(VALIDATOR, description="One"),
|
||||
Failure(VALIDATOR, description="Two"),
|
||||
Failure(VALIDATOR, description="Three"),
|
||||
],
|
||||
)
|
||||
assert result.failure_descriptions == ["One", "Two", "Three"]
|
||||
|
||||
|
||||
class ValidatorWithDescribeFailure(Validator):
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
return self.failure()
|
||||
|
||||
def describe_failure(self, failure: Failure) -> str | None:
|
||||
return "describe_failure"
|
||||
|
||||
|
||||
def test_Failure_description_priorities_parameter_only():
|
||||
number_validator = Number(failure_description="ABC")
|
||||
non_number_value = "x"
|
||||
result = number_validator.validate(non_number_value)
|
||||
# The inline value takes priority over the describe_failure.
|
||||
assert result.failures[0].description == "ABC"
|
||||
|
||||
|
||||
def test_Failure_description_priorities_parameter_and_describe_failure():
|
||||
validator = ValidatorWithDescribeFailure(failure_description="ABC")
|
||||
result = validator.validate("x")
|
||||
# Even though the validator has a `describe_failure`, we've provided it
|
||||
# inline and the inline value should take priority.
|
||||
assert result.failures[0].description == "ABC"
|
||||
|
||||
|
||||
def test_Failure_description_priorities_describe_failure_only():
|
||||
validator = ValidatorWithDescribeFailure()
|
||||
result = validator.validate("x")
|
||||
assert result.failures[0].description == "describe_failure"
|
||||
|
||||
|
||||
class ValidatorWithFailureMessageAndNoDescribe(Validator):
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
return self.failure(description="ABC")
|
||||
|
||||
|
||||
def test_Failure_description_parameter_and_description_inside_validate():
|
||||
validator = ValidatorWithFailureMessageAndNoDescribe()
|
||||
result = validator.validate("x")
|
||||
assert result.failures[0].description == "ABC"
|
||||
|
||||
|
||||
class ValidatorWithFailureMessageAndDescribe(Validator):
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
return self.failure(value=value, description="ABC")
|
||||
|
||||
def describe_failure(self, failure: Failure) -> str | None:
|
||||
return "describe_failure"
|
||||
|
||||
|
||||
def test_Failure_description_describe_and_description_inside_validate():
|
||||
# This is kind of a weird case - there's no reason to supply both of
|
||||
# these but lets still make sure we're sensible about how we handle it.
|
||||
validator = ValidatorWithFailureMessageAndDescribe()
|
||||
result = validator.validate("x")
|
||||
assert result.failures == [Failure(validator, "x", "ABC")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, minimum, maximum, expected_result",
|
||||
[
|
||||
("123", None, None, True), # valid number, no range
|
||||
("-123", None, None, True), # valid negative number, no range
|
||||
("123.45", None, None, True), # valid float, no range
|
||||
("1.23e-4", None, None, True), # valid scientific notation, no range
|
||||
("abc", None, None, False), # non-numeric string, no range
|
||||
("123", 100, 200, True), # valid number within range
|
||||
("99", 100, 200, False), # valid number but not in range
|
||||
("201", 100, 200, False), # valid number but not in range
|
||||
("1.23e4", 0, 50000, True), # valid scientific notation within range
|
||||
],
|
||||
)
|
||||
def test_Number_validate(value, minimum, maximum, expected_result):
|
||||
validator = Number(minimum=minimum, maximum=maximum)
|
||||
result = validator.validate(value)
|
||||
assert result.is_valid == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"regex, value, expected_result",
|
||||
[
|
||||
(r"\d+", "123", True), # matches regex for one or more digits
|
||||
(r"\d+", "abc", False), # does not match regex for one or more digits
|
||||
(r"[a-z]+", "abc", True), # matches regex for one or more lowercase letters
|
||||
(
|
||||
r"[a-z]+",
|
||||
"ABC",
|
||||
False,
|
||||
), # does not match regex for one or more lowercase letters
|
||||
(r"\w+", "abc123", True), # matches regex for one or more word characters
|
||||
(r"\w+", "!@#", False), # does not match regex for one or more word characters
|
||||
],
|
||||
)
|
||||
def test_Regex_validate(regex, value, expected_result):
|
||||
validator = Regex(regex)
|
||||
result = validator.validate(value)
|
||||
assert result.is_valid == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, minimum, maximum, expected_result",
|
||||
[
|
||||
("123", None, None, True), # valid integer, no range
|
||||
("-123", None, None, True), # valid negative integer, no range
|
||||
("123.45", None, None, False), # float, not a valid integer
|
||||
("1.23e-4", None, None, False), # scientific notation, not a valid integer
|
||||
("abc", None, None, False), # non-numeric string, not a valid integer
|
||||
("123", 100, 200, True), # valid integer within range
|
||||
("99", 100, 200, False), # valid integer but not in range
|
||||
("201", 100, 200, False), # valid integer but not in range
|
||||
("1.23e4", None, None, True), # valid integer in scientific notation
|
||||
],
|
||||
)
|
||||
def test_Integer_validate(value, minimum, maximum, expected_result):
|
||||
validator = Integer(minimum=minimum, maximum=maximum)
|
||||
result = validator.validate(value)
|
||||
assert result.is_valid == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, min_length, max_length, expected_result",
|
||||
[
|
||||
("", None, None, True), # empty string
|
||||
("test", None, None, True), # any string with no restrictions
|
||||
("test", 5, None, False), # shorter than minimum length
|
||||
("test", None, 3, False), # longer than maximum length
|
||||
("test", 4, 4, True), # exactly matches minimum and maximum length
|
||||
("test", 2, 6, True), # within length range
|
||||
],
|
||||
)
|
||||
def test_Length_validate(value, min_length, max_length, expected_result):
|
||||
validator = Length(minimum=min_length, maximum=max_length)
|
||||
result = validator.validate(value)
|
||||
assert result.is_valid == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected_result",
|
||||
[
|
||||
("http://example.com", True), # valid URL
|
||||
("https://example.com", True), # valid URL with https
|
||||
("www.example.com", False), # missing scheme
|
||||
("://example.com", False), # invalid URL (no scheme)
|
||||
("https:///path", False), # missing netloc
|
||||
(
|
||||
"redis://username:pass[word@localhost:6379/0",
|
||||
False,
|
||||
), # invalid URL characters
|
||||
("", False), # empty string
|
||||
],
|
||||
)
|
||||
def test_URL_validate(value, expected_result):
|
||||
validator = URL()
|
||||
result = validator.validate(value)
|
||||
assert result.is_valid == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"function, failure_description, is_valid",
|
||||
[
|
||||
((lambda value: True), None, True),
|
||||
((lambda value: False), "failure!", False),
|
||||
],
|
||||
)
|
||||
def test_Function_validate(function, failure_description, is_valid):
|
||||
validator = Function(function, failure_description)
|
||||
result = validator.validate("x")
|
||||
assert result.is_valid is is_valid
|
||||
if result.failure_descriptions:
|
||||
assert result.failure_descriptions[0] == failure_description
|
||||
Reference in New Issue
Block a user