-
Notifications
You must be signed in to change notification settings - Fork 814
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Validator Framework #2523
Comments
How about passing a dictionary of validation rules, where the key is the error message to display? Quick example below created just using a widget subclass for now. Inspired by ideas from discussion #2291 from typing import Callable, Dict
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Input, Label
class InputWithValidation(Widget):
DEFAULT_CSS = """
InputWithValidation > Input.error {
border: tall $error;
}
InputWithValidation > Label {
color: $text-muted;
padding: 0 0 0 1;
}
InputWithValidation > Label.error {
color: $error;
}
"""
def __init__(
self,
value: str | None = None,
placeholder: str = "",
validation: Dict[str, Callable] | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.value = value
self.placeholder = placeholder
self.validation = validation
def compose(self) -> ComposeResult:
yield (Input(value=self.value, placeholder=self.placeholder))
yield Label()
def on_input_changed(self, event: Input.Changed) -> None:
value = event.value
label = self.query_one(Label)
input = self.query_one(Input)
if self.validation is not None:
for message, check in self.validation.items():
if not check(value):
input.set_class(True, "error")
label.set_class(True, "error")
label.update(message)
else:
input.set_class(False, "error")
label.set_class(False, "error")
label.update()
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield (
InputWithValidation(
placeholder="Try entering more than 3 characters...",
validation={"Input too long": lambda value: len(value) < 4},
)
)
if __name__ == "__main__":
app = ExampleApp()
app.run() |
My initial reaction to this is that it doesn't seem to lend itself to letting the validator generate the error message itself, allowing for added context ("A surname of 4096 characters is too much I'm afraid!"), and likely isn't as friendly to translation either. Also, there's the danger of requiring that folk repeat themselves a lot in their code. |
I suppose my thinking is that because there are so many possible contexts for input validation, this parameter should be as flexible as possible. But I appreciate what you're saying about having "the validator generate the error message itself"... |
I'm sure the smart folks at Textual are way ahead of me on this, but if you are set on passing objects, you could borrow some ideas from the Django Validators. EDIT: Added a quick and dirty example. from abc import ABC, abstractmethod
from typing import Any
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual.widgets import Input, Label
from typing_extensions import override
class ValidationError(Exception):
def __init__(self, message: str, params: dict):
self.message = message.format(**params)
class AbstractValidator(ABC):
message = "Not implemented!"
@abstractmethod
def __call__(self, value: str) -> None:
raise NotImplementedError
@abstractmethod
def compare(self, a: Any, b: Any) -> bool:
raise NotImplementedError
class MaxLengthValidator(AbstractValidator):
message = (
"Ensure this value has at most {limit_value} characters (it has {show_value})"
)
def __init__(
self,
limit_value: int,
message: str | None = None,
):
self.limit_value = limit_value
if message:
self.message = message
def __call__(self, value: str) -> None:
if self.compare(len(value), self.limit_value):
params = {"limit_value": self.limit_value, "show_value": len(value)}
raise ValidationError(self.message, params=params)
@override
def compare(self, a: int, b: int) -> bool:
return a > b
class InputWithValidation(Widget):
DEFAULT_CSS = """
InputWithValidation {
height: auto;
}
InputWithValidation > Input.error {
border: tall $error;
}
InputWithValidation > Label {
color: $text-muted;
padding: 0 0 0 1;
}
InputWithValidation > Label.error {
color: $error;
}
"""
def __init__(
self,
value: str | None = None,
placeholder: str = "",
validator: AbstractValidator | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.value = value
self.placeholder = placeholder
self.validator = validator
def compose(self) -> ComposeResult:
yield (Input(value=self.value, placeholder=self.placeholder))
yield Label()
def on_input_changed(self, event: Input.Changed) -> None:
value = event.value
label = self.query_one(Label)
input = self.query_one(Input)
if self.validator is not None:
try:
self.validator(value)
except ValidationError as e:
input.set_class(True, "error")
label.set_class(True, "error")
label.update(e.message)
else:
input.set_class(False, "error")
label.set_class(False, "error")
label.update("")
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield (
InputWithValidation(
placeholder="Max 3 chars (default error message)...",
validator=MaxLengthValidator(limit_value=3),
)
)
yield (
InputWithValidation(
placeholder="Max 3 chars (dynamic custom error message)...",
validator=MaxLengthValidator(
limit_value=3,
message="Input of {show_value} characters is too much I'm afraid!",
),
)
)
yield (
InputWithValidation(
placeholder="Max 3 chars (static custom error message)...",
validator=MaxLengthValidator(
limit_value=3,
message="Input is too long",
),
)
)
if __name__ == "__main__":
app = ExampleApp()
app.run() |
I'm looking into this now, and the approach I'm exploring (and have implemented as a first pass) looks like this: class InputApp(App):
def compose(self) -> ComposeResult:
yield Input(
placeholder="Type a number...",
validators=Number(minimum=0, maximum=100) # Can also be a list
) All supplied validators will be checked each time the If you've supplied one or more validators, then any time validation runs, the If one or more of the validators fail, the ExampleHere's how we'd create an input which enforces that the user enters a number (could be a decimal or integer) between 0 and 100, and must contain the letter def compose(self) -> ComposeResult:
yield Input(
placeholder="Enter a number between 0 and 100",
validators=[Number(minimum=0, maximum=100), Regex("e")]
) The Input.Invalid(
value='-2',
invalid_reasons=[
"Value -2.0 is not in the range 0 and 100.", # from Number validator
"Value '-2' doesn't match regular expression 'e'." # from Regex validator
]
) Here's an example of the Screen.Recording.2023-05-18.at.13.22.34.mov |
Don't forget to star the repository! Follow @textualizeio for Textual updates. |
The validation framework will be in the next release of Textual. If you're interested, you can look at the docs in the PR here: #2600 |
We need a generic validator framework for inputs.
This should consist of small objects which are passed to Inputs (and other controls), which will check the user as entered something in the expected format and return errors.
Something like the following:
The above is just for illustration. Names of the objects TBD.
The text was updated successfully, but these errors were encountered: