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:
darrenburns
2023-05-25 13:29:33 +01:00
committed by GitHub
parent 5cb30b5ecc
commit 62fcefbd2d
14 changed files with 2689 additions and 1495 deletions

1
docs/api/validation.md Normal file
View File

@@ -0,0 +1 @@
::: textual.validation

View 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()

View File

@@ -5,7 +5,9 @@ A single-line text input widget.
- [x] Focusable - [x] Focusable
- [ ] Container - [ ] Container
## Example ## Examples
### A Simple Example
The example below shows how you might create a simple form using two `Input` widgets. 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" --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 ## Reactive Attributes
| Name | Type | Default | Description | | Name | Type | Default | Description |
| ----------------- | ------ | ------- | --------------------------------------------------------------- | |-------------------|--------|---------|-----------------------------------------------------------------|
| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. | | `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. |
| `value` | `str` | `""` | The value currently in the text input. | | `value` | `str` | `""` | The value currently in the text input. |
| `cursor_position` | `int` | `0` | The index of the cursor in the value string. | | `cursor_position` | `int` | `0` | The index of the cursor in the value string. |

2930
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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, runtime_checkable
from typing_extensions import Protocol
if TYPE_CHECKING: if TYPE_CHECKING:
from .message import Message from .message import Message

511
src/textual/validation.py Normal file
View 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."

View File

@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import re 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.cells import cell_len, get_character_cell_size
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
@@ -16,6 +17,7 @@ from ..events import Blur, Focus, Mount
from ..geometry import Size from ..geometry import Size
from ..message import Message from ..message import Message
from ..reactive import reactive from ..reactive import reactive
from ..validation import Failure, ValidationResult, Validator
from ..widget import Widget from ..widget import Widget
@@ -122,6 +124,12 @@ class Input(Widget, can_focus=True):
Input>.input--placeholder { Input>.input--placeholder {
color: $text-disabled; color: $text-disabled;
} }
Input.-invalid {
border: tall $error 60%;
}
Input.-invalid:focus {
border: tall $error;
}
""" """
cursor_blink = reactive(True) cursor_blink = reactive(True)
@@ -136,42 +144,45 @@ class Input(Widget, can_focus=True):
password = reactive(False) password = reactive(False)
max_size: reactive[int | None] = reactive(None) max_size: reactive[int | None] = reactive(None)
class Changed(Message, bubble=True): @dataclass
class Changed(Message):
"""Posted when the value changes. """Posted when the value changes.
Can be handled using `on_input_changed` in a subclass of `Input` or in a parent Can be handled using `on_input_changed` in a subclass of `Input` or in a parent
widget in the DOM. 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: input: Input
super().__init__() """The `Input` widget that was changed."""
self.input: Input = input
self.value: str = value 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 @property
def control(self) -> Input: def control(self) -> Input:
"""Alias for self.input.""" """Alias for self.input."""
return self.input return self.input
class Submitted(Message, bubble=True): @dataclass
class Submitted(Message):
"""Posted when the enter key is pressed within an `Input`. """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 Can be handled using `on_input_submitted` in a subclass of `Input` or in a
parent widget in the DOM. 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: input: Input
super().__init__() """The `Input` widget that is being submitted."""
self.input: Input = input value: str
self.value: str = value """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 @property
def control(self) -> Input: def control(self) -> Input:
@@ -184,6 +195,7 @@ class Input(Widget, can_focus=True):
placeholder: str = "", placeholder: str = "",
highlighter: Highlighter | None = None, highlighter: Highlighter | None = None,
password: bool = False, password: bool = False,
validators: Validator | Iterable[Validator] | None = None,
name: str | None = None, name: str | None = None,
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
@@ -196,6 +208,7 @@ class Input(Widget, can_focus=True):
placeholder: Optional placeholder text for the input. placeholder: Optional placeholder text for the input.
highlighter: An optional highlighter for the input. highlighter: An optional highlighter for the input.
password: Flag to say if the field should obfuscate its content. Default is `False`. 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. name: Optional name for the input widget.
id: Optional ID for the widget. id: Optional ID for the widget.
classes: Optional initial classes for the widget. classes: Optional initial classes for the widget.
@@ -207,6 +220,13 @@ class Input(Widget, can_focus=True):
self.placeholder = placeholder self.placeholder = placeholder
self.highlighter = highlighter self.highlighter = highlighter
self.password = password 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: def _position_to_cell(self, position: int) -> int:
"""Convert an index within the value to cell position.""" """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: async def watch_value(self, value: str) -> None:
if self.styles.auto_dimensions: if self.styles.auto_dimensions:
self.refresh(layout=True) 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 @property
def cursor_width(self) -> int: def cursor_width(self) -> int:
@@ -438,7 +487,7 @@ class Input(Widget, can_focus=True):
self.value = self.value[: self.cursor_position] self.value = self.value[: self.cursor_position]
else: else:
self.value = ( 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: def action_delete_right_all(self) -> None:
@@ -490,5 +539,9 @@ class Input(Widget, can_focus=True):
self.cursor_position = 0 self.cursor_position = 0
async def action_submit(self) -> None: async def action_submit(self) -> None:
"""Handle a submit action (normally the user hitting Enter in the input).""" """Handle a submit action.
self.post_message(self.Submitted(self, self.value))
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))

View File

@@ -16,7 +16,7 @@ class Pretty(Widget):
""" """
DEFAULT_CSS = """ DEFAULT_CSS = """
Static { Pretty {
height: auto; height: auto;
} }
""" """

View 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

View 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()

View File

@@ -84,6 +84,19 @@ def test_input_and_focus(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press) 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): def test_buttons_render(snap_compare):
# Testing button rendering. We press tab to focus the first button too. # Testing button rendering. We press tab to focus the first button too.
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"]) assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])

View File

@@ -1010,6 +1010,7 @@ async def test_scrolling_cursor_into_view():
table.add_rows([(n,) for n in range(300)]) table.add_rows([(n,) for n in range(300)])
await pilot.press("c") await pilot.press("c")
await pilot.pause()
assert table.scroll_y > 100 assert table.scroll_y > 100

216
tests/test_validation.py Normal file
View 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