Merge branch 'main' into multiselect

This commit is contained in:
Dave Pearson
2023-05-25 13:59:34 +01:00
committed by GitHub
25 changed files with 2846 additions and 1584 deletions

View File

@@ -11,12 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597
- Added `SelectionList` widget https://github.com/Textualize/textual/pull/2652
- `App.AUTO_FOCUS` to set auto focus on all screens https://github.com/Textualize/textual/issues/2594
### Changed
- `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590
- Footer now clears key highlight regardless of whether it's in the active screen or not https://github.com/Textualize/textual/issues/2606
- The default Widget repr no longer displays classes and pseudo-classes (to reduce noise in logs). Add them to your `__rich_repr__` method if needed. https://github.com/Textualize/textual/pull/2623
- Setting `Screen.AUTO_FOCUS` to `None` will inherit `AUTO_FOCUS` from the app instead of disabling it https://github.com/Textualize/textual/issues/2594
- Setting `Screen.AUTO_FOCUS` to `""` will disable it on the screen https://github.com/Textualize/textual/issues/2594
### Removed
@@ -34,6 +37,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Textual will now scroll focused widgets to center if not in view
## Unreleased
### Changed
- `Message.control` is now a property instead of a class variable. https://github.com/Textualize/textual/issues/2528
- `Tree` and `DirectoryTree` Messages no longer accept a `tree` parameter, using `self.node.tree` instead. https://github.com/Textualize/textual/issues/2529
## [0.25.0] - 2023-05-17
### Changed

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
- [ ] 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

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,12 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
rows = int(attrs.get("lines", 24))
columns = int(attrs.get("columns", 80))
svg = take_svg_screenshot(
None, path, press, title, terminal_size=(columns, rows)
None,
path,
press,
title,
terminal_size=(columns, rows),
wait_for_animation=False,
)
finally:
os.chdir(cwd)
@@ -56,6 +61,7 @@ def take_svg_screenshot(
title: str | None = None,
terminal_size: tuple[int, int] = (80, 24),
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
wait_for_animation: bool = True,
) -> str:
"""
@@ -68,6 +74,7 @@ def take_svg_screenshot(
run_before: An arbitrary callable that runs arbitrary code before taking the
screenshot. Use this to simulate complex user interactions with the app
that cannot be simulated by key presses.
wait_for_animation: Wait for animation to complete before taking screenshot.
Returns:
An SVG string, showing the content of the terminal window at the time
@@ -109,8 +116,9 @@ def take_svg_screenshot(
if inspect.isawaitable(result):
await result
await pilot.press(*press)
await pilot.wait_for_scheduled_animations()
await pilot.pause()
if wait_for_animation:
await pilot.wait_for_scheduled_animations()
await pilot.pause()
svg = app.export_screenshot(title=title)
app.exit(svg)

View File

@@ -65,7 +65,7 @@ def on(
parsed_selectors: dict[str, tuple[SelectorSet, ...]] = {}
for attribute, css_selector in selectors.items():
if attribute == "control":
if message_type.control is None:
if message_type.control == Message.control:
raise OnDecoratorError(
"The message class must have a 'control' to match with the on decorator"
)

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

View File

@@ -275,6 +275,14 @@ class App(Generic[ReturnType], DOMNode):
"""
SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {}
"""Screens associated with the app for the lifetime of the app."""
AUTO_FOCUS: ClassVar[str | None] = "*"
"""A selector to determine what to focus automatically when a screen is activated.
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
Setting to `None` or `""` disables auto focus.
"""
_BASE_PATH: str | None = None
CSS_PATH: ClassVar[CSSPathType | None] = None
"""File paths to load CSS from."""
@@ -1022,14 +1030,11 @@ class App(Generic[ReturnType], DOMNode):
app = self
driver = app._driver
assert driver is not None
await wait_for_idle(0)
for key in keys:
if key.startswith("wait:"):
_, wait_ms = key.split(":")
print(f"(pause {wait_ms}ms)")
await asyncio.sleep(float(wait_ms) / 1000)
await app._animator.wait_until_complete()
await wait_for_idle(0)
else:
if len(key) == 1 and not key.isalnum():
key = _character_to_key(key)
@@ -1044,9 +1049,8 @@ class App(Generic[ReturnType], DOMNode):
key_event._set_sender(app)
driver.send_event(key_event)
await wait_for_idle(0)
await app._animator.wait_until_complete()
await wait_for_idle(0)
await app._animator.wait_until_complete()
await wait_for_idle(0)
@asynccontextmanager
async def run_test(
@@ -1102,7 +1106,9 @@ class App(Generic[ReturnType], DOMNode):
# Context manager returns pilot object to manipulate the app
try:
yield Pilot(app)
pilot = Pilot(app)
await pilot._wait_for_screen()
yield pilot
finally:
# Shutdown the app cleanly
await app._shutdown()

View File

@@ -42,7 +42,6 @@ class Message:
verbose: ClassVar[bool] = False # Message is verbose
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
control: Widget | None = None
def __init__(self) -> None:
self.__post_init__()
@@ -79,6 +78,11 @@ class Message:
if namespace is not None:
cls.namespace = namespace
@property
def control(self) -> Widget | None:
"""The widget associated with this message, or None by default."""
return None
@property
def is_forwarded(self) -> bool:
"""Has the message been forwarded?"""

View File

@@ -94,11 +94,12 @@ class ResultCallback(Generic[ScreenResultType]):
class Screen(Generic[ScreenResultType], Widget):
"""The base class for screens."""
AUTO_FOCUS: ClassVar[str | None] = "*"
AUTO_FOCUS: ClassVar[str | None] = None
"""A selector to determine what to focus automatically when the screen is activated.
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
Set to `None` to disable auto focus.
Set to `None` to inherit the value from the screen's app.
Set to `""` to disable auto focus.
"""
DEFAULT_CSS = """
@@ -681,8 +682,9 @@ class Screen(Generic[ScreenResultType], Widget):
size = self.app.size
self._refresh_layout(size, full=True)
self.refresh()
if self.AUTO_FOCUS is not None and self.focused is None:
for widget in self.query(self.AUTO_FOCUS):
auto_focus = self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS
if auto_focus and self.focused is None:
for widget in self.query(auto_focus):
if widget.focusable:
self.set_focus(widget)
break

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

@@ -69,9 +69,7 @@ class DirectoryTree(Tree[DirEntry]):
`DirectoryTree` or in a parent widget in the DOM.
"""
def __init__(
self, tree: DirectoryTree, node: TreeNode[DirEntry], path: Path
) -> None:
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
"""Initialise the FileSelected object.
Args:
@@ -79,21 +77,15 @@ class DirectoryTree(Tree[DirEntry]):
path: The path of the file that was selected.
"""
super().__init__()
self.tree: DirectoryTree = tree
"""The `DirectoryTree` that had a file selected."""
self.node: TreeNode[DirEntry] = node
"""The tree node of the file that was selected."""
self.path: Path = path
"""The path of the file that was selected."""
@property
def control(self) -> DirectoryTree:
"""The `DirectoryTree` that had a file selected.
This is an alias for [`FileSelected.tree`][textual.widgets.DirectoryTree.FileSelected.tree]
which is used by the [`on`][textual.on] decorator.
"""
return self.tree
def control(self) -> Tree[DirEntry]:
"""The `Tree` that had a file selected."""
return self.node.tree
path: var[str | Path] = var["str | Path"](PATH("."), init=False, always_update=True)
"""The path that is the root of the directory tree.
@@ -361,7 +353,7 @@ class DirectoryTree(Tree[DirEntry]):
if not dir_entry.loaded:
self._add_to_load_queue(event.node)
else:
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
self.post_message(self.FileSelected(event.node, dir_entry.path))
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
event.stop()
@@ -369,4 +361,4 @@ class DirectoryTree(Tree[DirEntry]):
if dir_entry is None:
return
if not self._safe_is_dir(dir_entry.path):
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
self.post_message(self.FileSelected(event.node, dir_entry.path))

View File

@@ -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))

View File

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

View File

@@ -281,9 +281,6 @@ class ProgressBar(Widget, can_focus=False):
The percentage is a value between 0 and 1 and the returned value is only
`None` if the total progress of the bar hasn't been set yet.
In other words, after the progress bar emits the message
[`ProgressBar.Started`][textual.widgets.ProgressBar.Started],
the value of `percentage` is always not `None`.
Example:
```py

View File

@@ -227,20 +227,23 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
"""Posted when the select value was changed.
This message can be handled using a `on_select_changed` method.
"""
def __init__(self, control: Select, value: SelectType | None) -> None:
def __init__(self, select: Select, value: SelectType | None) -> None:
"""
Initialize the Changed message.
"""
super().__init__()
self.control = control
"""The select control."""
self.select = select
"""The select widget."""
self.value = value
"""The value of the Select when it changed."""
@property
def control(self) -> Select:
"""The Select that sent the message."""
return self.select
def __init__(
self,
options: Iterable[tuple[str, SelectType]],

View File

@@ -207,7 +207,7 @@ class TreeNode(Generic[TreeDataType]):
"""
self._expanded = True
self._updates += 1
self._tree.post_message(Tree.NodeExpanded(self._tree, self))
self._tree.post_message(Tree.NodeExpanded(self))
if expand_all:
for child in self.children:
child._expand(expand_all)
@@ -240,7 +240,7 @@ class TreeNode(Generic[TreeDataType]):
"""
self._expanded = False
self._updates += 1
self._tree.post_message(Tree.NodeCollapsed(self._tree, self))
self._tree.post_message(Tree.NodeCollapsed(self))
if collapse_all:
for child in self.children:
child._collapse(collapse_all)
@@ -514,23 +514,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was collapsed."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeCollapsed.tree`][textual.widgets.Tree.NodeCollapsed.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is expanded.
@@ -539,23 +531,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was expanded."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeExpanded.tree`][textual.widgets.Tree.NodeExpanded.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is highlighted.
@@ -564,23 +548,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was highlighted."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeHighlighted.tree`][textual.widgets.Tree.NodeHighlighted.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is selected.
@@ -589,23 +565,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was selected."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeSelected.tree`][textual.widgets.Tree.NodeSelected.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
def __init__(
self,
@@ -905,7 +873,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
node._selected = True
self._cursor_node = node
if previous_node != node:
self.post_message(self.NodeHighlighted(self, node))
self.post_message(self.NodeHighlighted(node))
else:
self._cursor_node = None
@@ -1236,7 +1204,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
Note:
If `auto_expand` is `True` use of this action on a non-leaf node
will cause both an expand/collapse event to occour, as well as a
will cause both an expand/collapse event to occur, as well as a
selected event.
"""
try:
@@ -1247,4 +1215,4 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
node = line.path[-1]
if self.auto_expand:
self._toggle_node(node)
self.post_message(self.NodeSelected(self, node))
self.post_message(self.NodeSelected(node))

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)
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"])

View File

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

View File

@@ -153,7 +153,9 @@ async def test_screens():
await app._shutdown()
async def test_auto_focus():
async def test_auto_focus_on_screen_if_app_auto_focus_is_none():
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
class MyScreen(Screen[None]):
def compose(self):
yield Button()
@@ -161,10 +163,11 @@ async def test_auto_focus():
yield Input(id="two")
class MyApp(App[None]):
pass
AUTO_FOCUS = None
app = MyApp()
async with app.run_test():
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
@@ -193,6 +196,80 @@ async def test_auto_focus():
assert app.focused.id == "two"
async def test_auto_focus_on_screen_if_app_auto_focus_is_disabled():
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
class MyScreen(Screen[None]):
def compose(self):
yield Button()
yield Input(id="one")
yield Input(id="two")
class MyApp(App[None]):
AUTO_FOCUS = ""
app = MyApp()
async with app.run_test():
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
MyScreen.AUTO_FOCUS = "Input"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
assert app.focused.id == "one"
app.pop_screen()
MyScreen.AUTO_FOCUS = "#two"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
assert app.focused.id == "two"
# If we push and pop another screen, focus should be preserved for #two.
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
assert app.focused.id == "two"
async def test_auto_focus_inheritance():
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
class MyScreen(Screen[None]):
def compose(self):
yield Button()
yield Input(id="one")
yield Input(id="two")
class MyApp(App[None]):
pass
app = MyApp()
async with app.run_test():
MyApp.AUTO_FOCUS = "Input"
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
app.pop_screen()
MyScreen.AUTO_FOCUS = ""
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
async def test_auto_focus_skips_non_focusable_widgets():
class MyScreen(Screen[None]):
def compose(self):

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