Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ on:

jobs:
uv-example-linux:
name: python-linux
name: python-${{ matrix.python-version }}-pydantic-${{ matrix.pydantic-version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
pydantic-version: ["v1", "v2"]
exclude:
# Pydantic v1 doesn't support Python 3.13+
- python-version: "3.13"
pydantic-version: "v1"
- python-version: "3.14"
pydantic-version: "v1"
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
Expand All @@ -25,5 +32,13 @@ jobs:
- name: Install dependencies
run: uv sync --all-extras --dev

- name: Install Pydantic v1
if: matrix.pydantic-version == 'v1'
run: uv pip install "pydantic<2"

- name: Install Pydantic v2
if: matrix.pydantic-version == 'v2'
run: uv pip install "pydantic>=2"

- name: Run tests
run: uv run pytest
36 changes: 36 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
release type: minor
---

Add Pydantic v1/v2 compatibility for Input validators using a Protocol-based approach.

The `Input` component now accepts any object with a `validate_python` method through the new `Validator` protocol, making it compatible with both Pydantic v1 and v2.

**Usage with Pydantic v2:**
```python
from pydantic import TypeAdapter

validator = TypeAdapter(int)
app.input("Enter a number:", validator=validator)
```

**Usage with Pydantic v1:**
```python
from pydantic import parse_obj_as

class V1Validator:
def __init__(self, type_):
self.type_ = type_

def validate_python(self, value):
return parse_obj_as(self.type_, value)

validator = V1Validator(int)
app.input("Enter a number:", validator=validator)
```

**Changes:**
- Added `Validator` protocol that accepts any object with a `validate_python` method
- Improved error message extraction from Pydantic validation errors
- Added cross-version compatibility tests
- Updated CI to test both Pydantic v1 and v2 across Python 3.8-3.14
3 changes: 2 additions & 1 deletion src/rich_toolkit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .input import Validator
from .toolkit import RichToolkit, RichToolkitTheme

__all__ = ["RichToolkit", "RichToolkitTheme"]
__all__ = ["RichToolkit", "RichToolkitTheme", "Validator"]
46 changes: 40 additions & 6 deletions src/rich_toolkit/input.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Optional, Protocol

from ._input_handler import TextInputHandler

from .element import CursorOffset, Element

if TYPE_CHECKING:
from pydantic import TypeAdapter

from .styles.base import BaseStyle


class Validator(Protocol):
"""Protocol for validators that can validate input values.

Any object with a validate_python method can be used as a validator.
This includes Pydantic's TypeAdapter or custom validators.

Example with Pydantic TypeAdapter:
>>> from pydantic import TypeAdapter
>>> validator = TypeAdapter(int)
>>> input_field = Input(validator=validator)

Example with custom validator:
>>> class MyValidator:
... def validate_python(self, value):
... if not value.startswith("x"):
... raise ValueError("Must start with x")
... return value
>>> input_field = Input(validator=MyValidator())
"""

def validate_python(self, value: Any) -> Any:
"""Validate a Python value and return the validated result.

Args:
value: The value to validate

Returns:
The validated value

Raises:
ValidationError: If validation fails
"""
...


class Input(TextInputHandler, Element):
label: Optional[str] = None

Expand All @@ -27,7 +60,7 @@ def __init__(
inline: bool = False,
name: Optional[str] = None,
style: Optional[BaseStyle] = None,
validator: Optional[TypeAdapter] = None,
validator: Optional[Validator] = None,
**metadata: Any,
):
self.name = name
Expand All @@ -43,7 +76,7 @@ def __init__(
self.valid = None
self.required_message = required_message
self._validation_message: Optional[str] = None
self._validator: Optional[TypeAdapter] = validator
self._validator: Optional[Validator] = validator

Element.__init__(self, style=style, metadata=metadata)
super().__init__()
Expand Down Expand Up @@ -99,7 +132,8 @@ def on_validate(self):
except ValidationError as e:
self.valid = False

self._validation_message = e.errors()[0]["ctx"]["reason"]
# Extract error message from Pydantic ValidationError
self._validation_message = e.errors()[0].get("msg", "Validation failed")

return

Expand Down
48 changes: 48 additions & 0 deletions tests/test_input_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from typing import Any

from rich_toolkit.input import Input

try:
from pydantic import TypeAdapter

PYDANTIC_V2 = True

# Use TypeAdapter for Pydantic v2
int_validator = TypeAdapter(int)

except ImportError:
from pydantic import parse_obj_as

PYDANTIC_V2 = False

# Create a wrapper for Pydantic v1
class V1IntValidator:
def __init__(self, type_):
self.type_ = type_

def validate_python(self, value: Any) -> Any:
return parse_obj_as(self.type_, value)

int_validator = V1IntValidator(int)


def test_validator_with_valid_input():
"""Test that validation works with valid input (Pydantic v1 and v2 compatible)."""
input_field = Input(validator=int_validator) # type: ignore
input_field.text = "123"

input_field.on_validate()

assert input_field.valid is True
assert input_field._validation_message is None


def test_validator_with_invalid_input():
"""Test that validation fails with invalid input (Pydantic v1 and v2 compatible)."""
input_field = Input(validator=int_validator) # type: ignore
input_field.text = "not a number"

input_field.on_validate()

assert input_field.valid is False
assert input_field._validation_message is not None
Loading