Repository: pydantic/pydantic
Pull request: Fix annotation application issue with non numeric constraints
Fix https://github.com/pydantic/pydantic/issues/10459

Sort of reverting my refactor from https://github.com/pydantic/pydantic/pull/9999. I think the smaller functions (though redundant) make this much more readable.

Problem statement:
TypeError when using BeforeValidator
### Initial Checks

- [X] I confirm that I'm using Pydantic V2

### Description

Since pydantic 2.9.0 (this did _not_ happen in 2.8.2), using a `BeforeValidator()` _before_ constraints from `annotated_types` raises a `TypeError`. I tested this with 2.8.2, 2.9.0 and 2.9.2, and the issue was introduced in 2.9.0 and is still present in 2.9.2.

A minimal example is shown in the example code section. My actual usecase is creating a timedelta type where int is interpreted as days, and then to add constraints to it. This example raises the same `TypeError` as the `BeforeValidatorBeforeLe` below:

```python
TimedeltaAsDays = Annotated[timedelta, BeforeValidator(validator)]

class MyModel(BaseModel):
    v: Annotated[TimedeltaAsDays, Le(timedelta(days=365))]

try:
    print(3, MyModel(v=366))  # raises TypeError in pydantic>=2.9.0
except ValueError as ex:  # ValueError (as expected) in 2.8.2
    print(3, "ValueError:", ex)
except TypeError as ex:  # UNEXPECTED TypeError in pydantic>=2.9.0
    print(3, ex)
```

Of course... maybe I'm using it wrong?

### Example Code

```Python
from datetime import timedelta
from typing import Annotated

from annotated_types import Le
from pydantic import BaseModel, BeforeValidator


def validator(v):
    if isinstance(v, (int, float)):
        return timedelta(days=v)
    return v


class BeforeValidatorAfterLe(BaseModel):
    v: Annotated[timedelta, Le(timedelta(days=365)), BeforeValidator(validator)]


class BeforeValidatorBeforeLe(BaseModel):
    v: Annotated[timedelta, BeforeValidator(validator), Le(timedelta(days=365))]


try:
    BeforeValidatorAfterLe(v=366)  # raises ValueError, as expected
except ValueError as ex:
    print(1, ex)

try:
    BeforeValidatorBeforeLe(v=366)  # raises TypeError in pydantic>=2.9.0
except ValueError as ex:  # ValueError (as expected) in 2.8.2
    print(2, "ValueError:", ex)
except TypeError as ex:  # UNEXPECTED TypeError in pydantic>=2.9.0
    print(2, "TypeError:", ex)
```


### Python, Pydantic & OS Version

```Text
pydantic version: 2.9.2
        pydantic-core version: 2.23.4
          pydantic-core build: profile=release pgo=false
                 install path: .../lib/python3.12/site-packages/pydantic
               python version: 3.12.1 (main, Jan  1 2024, 15:08:51) [GCC 11.4.0]
                     platform: Linux-5.15.0-122-generic-x86_64-with-glibc2.35
             related packages: typing_extensions-4.12.2 mypy-1.11.2 pydantic-settings-2.2.1
                       commit: unknown
```

Hints from issue discussion:
Looks like an issue with annotation application, will look into this today!

Commit message: revert and fix

Patch to review:
diff --git a/pydantic/_internal/_known_annotated_metadata.py b/pydantic/_internal/_known_annotated_metadata.py
index b34ece10d96..73a9d2e729d 100644
--- a/pydantic/_internal/_known_annotated_metadata.py
+++ b/pydantic/_internal/_known_annotated_metadata.py
@@ -279,7 +279,7 @@ def _apply_constraint_with_incompatibility_info(
                     json_schema_constraint = 'minLength' if constraint == 'min_length' else 'maxLength'
 
             schema = cs.no_info_after_validator_function(
-                partial(get_constraint_validator(constraint), **{'constraint_value': value}), schema
+                partial(get_constraint_validator(constraint), **{constraint: value}), schema
             )
             add_js_update_schema(schema, lambda: {json_schema_constraint: as_jsonable_value(value)})  # noqa: B023
         elif constraint == 'allow_inf_nan' and value is False:
diff --git a/pydantic/_internal/_validators.py b/pydantic/_internal/_validators.py
index 6380fe83968..6bf80e2c6cf 100644
--- a/pydantic/_internal/_validators.py
+++ b/pydantic/_internal/_validators.py
@@ -14,7 +14,6 @@
 
 from pydantic_core import PydanticCustomError, core_schema
 from pydantic_core._pydantic_core import PydanticKnownError
-from pydantic_core.core_schema import ErrorType
 
 
 def sequence_validator(
@@ -253,57 +252,92 @@ def forbid_inf_nan_check(x: Any) -> Any:
     return x
 
 
-_InputType = typing.TypeVar('_InputType')
+def _safe_repr(v: Any) -> int | float | str:
+    """The context argument for `PydanticKnownError` requires a number or str type, so we do a simple repr() coercion for types like timedelta.
 
+    See tests/test_types.py::test_annotated_metadata_any_order for some context.
+    """
+    if isinstance(v, (int, float)):
+        return v
+    return repr(v)
 
-def create_constraint_validator(
-    constraint_id: str,
-    predicate: Callable[[_InputType, Any], bool],
-    error_type: ErrorType,
-    context_gen: Callable[[Any, Any], dict[str, Any]] | None = None,
-) -> Callable[[_InputType, Any], _InputType]:
-    """Create a validator function for a given constraint.
 
-    Args:
-        constraint_id: The constraint identifier, used to identify the constraint in error messages, ex 'gt'.
-        predicate: The predicate function to apply to the input value, ex `lambda x, gt: x > gt`.
-        error_type: The error type to raise if the predicate fails.
-        context_gen: A function to generate the error context from the constraint value and the input value.
-    """
+def greater_than_validator(x: Any, gt: Any) -> Any:
+    try:
+        if not (x > gt):
+            raise PydanticKnownError('greater_than', {'gt': _safe_repr(gt)})
+        return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'gt' to supplied value {x}")
 
-    def validator(x: _InputType, constraint_value: Any) -> _InputType:
-        try:
-            if not predicate(x, constraint_value):
-                raise PydanticKnownError(
-                    error_type, context_gen(constraint_value, x) if context_gen else {constraint_id: constraint_value}
-                )
-        except TypeError:
-            raise TypeError(f"Unable to apply constraint '{constraint_id}' to supplied value {x}")
+
+def greater_than_or_equal_validator(x: Any, ge: Any) -> Any:
+    try:
+        if not (x >= ge):
+            raise PydanticKnownError('greater_than_equal', {'ge': _safe_repr(ge)})
+        return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'ge' to supplied value {x}")
+
+
+def less_than_validator(x: Any, lt: Any) -> Any:
+    try:
+        if not (x < lt):
+            raise PydanticKnownError('less_than', {'lt': _safe_repr(lt)})
+        return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'lt' to supplied value {x}")
+
+
+def less_than_or_equal_validator(x: Any, le: Any) -> Any:
+    try:
+        if not (x <= le):
+            raise PydanticKnownError('less_than_equal', {'le': _safe_repr(le)})
         return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'le' to supplied value {x}")
+
+
+def multiple_of_validator(x: Any, multiple_of: Any) -> Any:
+    try:
+        if x % multiple_of:
+            raise PydanticKnownError('multiple_of', {'multiple_of': _safe_repr(multiple_of)})
+        return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'multiple_of' to supplied value {x}")
+
 
-    return validator
+def min_length_validator(x: Any, min_length: Any) -> Any:
+    try:
+        if not (len(x) >= min_length):
+            raise PydanticKnownError(
+                'too_short', {'field_type': 'Value', 'min_length': min_length, 'actual_length': len(x)}
+            )
+        return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'min_length' to supplied value {x}")
+
+
+def max_length_validator(x: Any, max_length: Any) -> Any:
+    try:
+        if len(x) > max_length:
+            raise PydanticKnownError(
+                'too_long',
+                {'field_type': 'Value', 'max_length': max_length, 'actual_length': len(x)},
+            )
+        return x
+    except TypeError:
+        raise TypeError(f"Unable to apply constraint 'max_length' to supplied value {x}")
 
 
 _CONSTRAINT_TO_VALIDATOR_LOOKUP: dict[str, Callable] = {
-    'gt': create_constraint_validator('gt', lambda x, gt: x > gt, 'greater_than'),
-    'ge': create_constraint_validator('ge', lambda x, ge: x >= ge, 'greater_than_equal'),
-    'lt': create_constraint_validator('lt', lambda x, lt: x < lt, 'less_than'),
-    'le': create_constraint_validator('le', lambda x, le: x <= le, 'less_than_equal'),
-    'multiple_of': create_constraint_validator(
-        'multiple_of', lambda x, multiple_of: x % multiple_of == 0, 'multiple_of'
-    ),
-    'min_length': create_constraint_validator(
-        'min_length',
-        lambda x, min_length: len(x) >= min_length,
-        'too_short',
-        lambda c_val, x: {'field_type': 'Value', 'min_length': c_val, 'actual_length': len(x)},
-    ),
-    'max_length': create_constraint_validator(
-        'max_length',
-        lambda x, max_length: len(x) <= max_length,
-        'too_long',
-        lambda c_val, x: {'field_type': 'Value', 'max_length': c_val, 'actual_length': len(x)},
-    ),
+    'gt': greater_than_validator,
+    'ge': greater_than_or_equal_validator,
+    'lt': less_than_validator,
+    'le': less_than_or_equal_validator,
+    'multiple_of': multiple_of_validator,
+    'min_length': min_length_validator,
+    'max_length': max_length_validator,
 }
 
 
diff --git a/tests/test_types.py b/tests/test_types.py
index 1514e055334..541e36d4588 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -63,6 +63,7 @@
     Base64UrlBytes,
     Base64UrlStr,
     BaseModel,
+    BeforeValidator,
     ByteSize,
     ConfigDict,
     DirectoryPath,
@@ -6993,3 +6994,30 @@ def test_fraction_serialization() -> None:
 def test_fraction_json_schema() -> None:
     ta = TypeAdapter(Fraction)
     assert ta.json_schema() == {'type': 'string', 'format': 'fraction'}
+
+
+def test_annotated_metadata_any_order() -> None:
+    def validator(v):
+        if isinstance(v, (int, float)):
+            return timedelta(days=v)
+        return v
+
+    class BeforeValidatorAfterLe(BaseModel):
+        v: Annotated[timedelta, annotated_types.Le(timedelta(days=365)), BeforeValidator(validator)]
+
+    class BeforeValidatorBeforeLe(BaseModel):
+        v: Annotated[timedelta, BeforeValidator(validator), annotated_types.Le(timedelta(days=365))]
+
+    try:
+        BeforeValidatorAfterLe(v=366)
+    except ValueError as ex:
+        assert '365 days' in str(ex)
+
+    # in this case, the Le constraint comes after the BeforeValidator, so we use functional validators
+    # from pydantic._internal._validators.py (in this case, less_than_or_equal_validator)
+    # which doesn't have access to fancy pydantic-core formatting for timedelta, so we get
+    # the raw timedelta repr in the error `datetime.timedelta(days=365`` vs the above `365 days`
+    try:
+        BeforeValidatorBeforeLe(v=366)
+    except ValueError as ex:
+        assert 'datetime.timedelta(days=365)' in str(ex)


Code review comments:
diff_hunk: @@ -253,57 +252,92 @@ def forbid_inf_nan_check(x: Any) -> Any:
     return x
 
 
-_InputType = typing.TypeVar('_InputType')
+def _safe_repr(v: Any) -> int | float | str:
+    """The context argument for `PydanticKnownError` requires a number or str type, so we do a simple repr() coercion for types like timedelta.
 
+    See tests/test_types.py::test_annotated_metadata_any_order for some context.
+    """
+    if isinstance(v, (int, float)):
path: pydantic/_internal/_validators.py
text: @user1:
```suggestion
    if isinstance(v, (int, float, str)):
```

?