Textualize / textual

The lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.
https://textual.textualize.io/
MIT License
25.13k stars 769 forks source link

Validator Framework #2523

Closed willmcgugan closed 1 year ago

willmcgugan commented 1 year ago

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:

yield Input("A number", validators=[Integer(), Range(0, 10)]

The above is just for illustration. Names of the objects TBD.

TomJGooding commented 1 year ago

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()
davep commented 1 year ago
validation={"Input too long": lambda value: len(value) < 4}`

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.

TomJGooding commented 1 year ago

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

TomJGooding commented 1 year ago

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. EDIT: Added examples of custom error messages

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()
darrenburns commented 1 year ago

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 Input changes. We could optionally have a flag to only validate on "submission" (pressing Enter).

If you've supplied one or more validators, then any time validation runs, the Input will post an Input.Valid or Input.Invalid event. This means users can handle valid or invalid input however they wish. For example, they may wish to display a custom error message somewhere on screen.

If one or more of the validators fail, the Input will be given the -invalid CSS class. By default, it makes the border around the Input red. When the validator succeeds, the -invalid class will be removed.

Example

Here'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 e (must be scientific notation).

    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 event contains a list of "invalid reasons" (strings) which are returned from the validators. The Input.Invalid message looks like this:

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 -invalid class being applied and removed from the Input as the value changes, using the validators from the above example...

https://github.com/Textualize/textual/assets/5740731/ebbd9bba-310d-4470-bff2-3ae473674b64

github-actions[bot] commented 1 year ago

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

darrenburns commented 1 year ago

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: https://github.com/Textualize/textual/pull/2600