Skip to content
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

Restrictors #3784

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## [0.44.0] - 2023-12-1

### Added

- Added Restrictor class to allow arbitrary text restriction on Input

### Changed

- Breaking change: Dropped 3.7 support https://github.com/Textualize/textual/pull/3766
Expand Down
1 change: 1 addition & 0 deletions docs/api/restriction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textual.restriction
38 changes: 38 additions & 0 deletions docs/examples/widgets/input_restriction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from textual.app import App, ComposeResult
from textual.restriction import Restrictor
from textual.widgets import Input, Label, Pretty


class InputApp(App):
# (6)!
CSS = """
Input {
margin: 1 1;
}
Label {
margin: 1 2;
}
Pretty {
margin: 1 2;
}
"""

def compose(self) -> ComposeResult:
yield Label("Enter uppercase text")
yield Input(
placeholder="Enter uppercase text...",
restrictors=[UppercaseRestrictor()],
)
yield Pretty([])


# A custom restrictor
class UppercaseRestrictor(Restrictor):
def allowed(self, value: str) -> bool:
return value.isupper()


app = InputApp()

if __name__ == "__main__":
app.run()
35 changes: 35 additions & 0 deletions src/textual/restriction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Framework for validating string values"""

from __future__ import annotations

from abc import ABC, abstractmethod


class Restrictor(ABC):
"""Base class for the restriction of values.

Commonly used in conjunction with the `Input` widget, which accepts a list
of restrictors via its constructor. The key difference between a Restrictor
and a Validator is, in the case of the `Input` widget, the new value will
not appear in the `Input` similar to the behavior of `max_length`.

To implement your own `Restrictor`, subclass this class.

Example:
```python
class UppercaseRestrictor(Restrictor):
def allowed(self, value: str) -> bool:
return value.isupper()
```
"""

@abstractmethod
def allowed(self, value: str) -> bool:
"""Tests if the value is allowed or not.

Args:
value: The value to test.

Returns:
The result of the test.
"""
31 changes: 29 additions & 2 deletions src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ..geometry import Offset, Size
from ..message import Message
from ..reactive import reactive, var
from ..restriction import Restrictor
from ..suggester import Suggester, SuggestionReady
from ..timer import Timer
from ..validation import ValidationResult, Validator
Expand Down Expand Up @@ -242,6 +243,7 @@ def __init__(
password: bool = False,
*,
restrict: str | None = None,
restrictors: Restrictor | Iterable[Restrictor] | None = None,
type: InputType = "text",
max_length: int = 0,
suggester: Suggester | None = None,
Expand All @@ -261,6 +263,7 @@ def __init__(
highlighter: An optional highlighter for the input.
password: Flag to say if the field should obfuscate its content.
restrict: A regex to restrict character inputs.
restrictors: An iterable of restrictors that the Input value will be checked against.
type: The type of the input.
max_length: The maximum length of the input, or 0 for no maximum length.
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
Expand All @@ -285,6 +288,14 @@ def __init__(
self.password = password
self.suggester = suggester

# Ensure we always end up with an Iterable of restrictors
if isinstance(restrictors, Restrictor):
self.restrictors: list[Restrictor] = [restrictors]
elif restrictors is None:
self.restrictors = []
else:
self.restrictors = list(restrictors)

# Ensure we always end up with an Iterable of validators
if isinstance(validators, Validator):
self.validators: list[Validator] = [validators]
Expand Down Expand Up @@ -407,6 +418,23 @@ def _watch_valid_empty(self) -> None:
"""Repeat validation when valid_empty changes."""
self._watch_value(self.value)

def restrictors_allow(self, value: str) -> bool:
"""Run all the restrictors associated with this Input on the supplied value.

Runs all restrictors, combines with `and` into one bool. If any of the restrictors
failed, the combined result will be `False`. If no restrictors are present,
`True` will be returned.

Returns:
A bool indicating whether or not *all* restrictors allow the value
or not. That is, if *any* restrictor disallows the value, the result
will be a disallowal of the value.
"""
if not self.restrictors:
return True

return all([restrictor.allowed(value) for restrictor in self.restrictors])

def validate(self, value: str) -> ValidationResult | None:
"""Run all the validators associated with this Input on the supplied value.

Expand Down Expand Up @@ -577,8 +605,7 @@ def check_allowed_value(value: str) -> bool:
and re.fullmatch(type_restrict, value) is None
):
return False
# Character is allowed
return True
return self.restrictors_allow(value)

if self.cursor_position >= len(self.value):
new_value = self.value + text
Expand Down
22 changes: 22 additions & 0 deletions tests/input/test_input_restrict.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from textual.app import App, ComposeResult
from textual.restriction import Restrictor
from textual.widgets import Input
from textual.widgets._input import _RESTRICT_TYPES

Expand Down Expand Up @@ -143,3 +144,24 @@ def compose(self) -> ComposeResult:
text_input.focus()
await pilot.press("!", "x", "9")
assert text_input.value == "!x9"


class UppercaseRestrictor(Restrictor):
def allowed(self, value: str) -> bool:
return value.isupper()


async def test_restrictors():
class InputApp(App):
def compose(self) -> ComposeResult:
yield Input(type="text", id="text", restrictors=[UppercaseRestrictor()])

async with InputApp().run_test() as pilot:
text_input = pilot.app.query_one("#text", Input)

text_input.focus()
await pilot.press("A")
assert text_input.value == "A"

await pilot.press("a")
assert text_input.value == "A"
34 changes: 34 additions & 0 deletions tests/test_restriction.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

from textual.restriction import Restrictor


class AllowedRestrictor(Restrictor):
def allowed(self, value: str) -> bool:
return True


class DisallowedRestrictor(Restrictor):
def allowed(self, value: str) -> bool:
return False


class UppercaseRestrictor(Restrictor):
def allowed(self, value: str) -> bool:
return value.isupper()


def test_allowed_restrictor():
restrictor = AllowedRestrictor()
assert restrictor.allowed("string") == True


def test_disallowed_restrictor():
restrictor = DisallowedRestrictor()
assert restrictor.allowed("string") == False


def test_uppercase_restrictor():
restrictor = UppercaseRestrictor()
assert restrictor.allowed("STRINg") == False
assert restrictor.allowed("STRING") == True