Repository: pydantic/pydantic

Issue:
Remove requirement that callables have `__qualname__` attribute



Discussion:
please can you check this is fixed, see #5126 for some context.
I don't believe this is currently resolved, but I will just point out that in most places where `.__qualname__` is accessed directly, it seems to me that either `pydantic._internal._core_utils.get_type_ref` or something very similar could be used instead.
I'll point out that despite the test in the original issue `paritalmethod` doesn't work at all

Resolving patch:
diff --git a/pydantic/_internal/_decorators.py b/pydantic/_internal/_decorators.py
--- a/pydantic/_internal/_decorators.py
+++ b/pydantic/_internal/_decorators.py
@@ -4,6 +4,7 @@
 from __future__ import annotations as _annotations
 
 import warnings
+from functools import partial, partialmethod
 from inspect import Parameter, Signature, signature
 from typing import (
     TYPE_CHECKING,
@@ -293,7 +294,7 @@ def __init__(self) -> None:
         self.model_serializer = {}
 
 
-def gather_decorator_functions(cls: type[Any]) -> DecoratorInfos:
+def gather_decorator_functions(cls: type[Any]) -> DecoratorInfos:  # noqa: C901
     """
     We want to collect all DecFunc instances that exist as
     attributes in the namespace of the class (a BaseModel or dataclass)
@@ -321,8 +322,15 @@ def gather_decorator_functions(cls: type[Any]) -> DecoratorInfos:
 
     for var_name, var_value in vars(cls).items():
         if isinstance(var_value, PydanticDecoratorMarker):
-            func = var_value.wrapped.__get__(None, cls)
             cls_ref = get_type_ref(cls)
+            try:
+                func = var_value.wrapped.__get__(None, cls)
+            except AttributeError:
+                if isinstance(var_value.wrapped, partial):
+                    # don't bind the class
+                    func = var_value.wrapped
+                else:
+                    raise
             shimmed_func = var_value.shim(func) if var_value.shim is not None else func
             info = var_value.decorator_info
             if isinstance(info, ValidatorDecoratorInfo):
@@ -358,42 +366,65 @@ def gather_decorator_functions(cls: type[Any]) -> DecoratorInfos:
 _FUNCS: set[str] = set()
 
 
-def prepare_serializer_decorator(
-    function: Callable[..., Any] | classmethod[Any] | staticmethod[Any], allow_reuse: bool
-) -> Callable[..., Any] | classmethod[Any]:
-    """
-    Warn about validators/serializers with duplicated names since without this, they can be overwritten silently
-    which generally isn't the intended behaviour, don't run in ipython (see #312) or if `allow_reuse` is True.
-    """
-    if isinstance(function, staticmethod):
-        function = function.__func__
-    if not allow_reuse and not in_ipython():
-        ref = f'{function.__module__}::{function.__qualname__}'
-        if ref in _FUNCS:
-            warnings.warn(f'duplicate validator function "{ref}"; if this is intended, set `allow_reuse=True`')
-        _FUNCS.add(ref)
-    return function
+AnyDecoratorCallable: TypeAlias = 'Union[classmethod[Any], staticmethod[Any], partialmethod[Any], Callable[..., Any]]'
 
 
-def unwrap_unbound_methods(function: Callable[..., Any] | classmethod[Any] | staticmethod[Any]) -> Callable[..., Any]:
+def unwrap_wrapped_function(
+    func: Any,
+    *,
+    unwrap_class_static_method: bool = True,
+) -> Any:
     """
-    Unwrap unbound classmethods and staticmethods
+    Recursively unwraps a wrapped function until the underlying function is reached.
+    This handles functools.partial, functools.partialmethod, staticmethod and classmethod.
+
+    Args:
+        func: The function to unwrap.
+        unwrap_class_static_method: If True (default), also unwrap classmethod and staticmethod
+            decorators. If False, only unwrap partial and partialmethod decorators.
+
+    Returns:
+        The underlying function of the wrapped function.
     """
-    if isinstance(function, (classmethod, staticmethod)):
-        return function.__func__
-    return function
+    all: tuple[Any, ...]
+    if unwrap_class_static_method:
+        all = (
+            staticmethod,
+            classmethod,
+            partial,
+            partialmethod,
+        )
+    else:
+        all = partial, partialmethod
+
+    while isinstance(func, all):
+        if unwrap_class_static_method and isinstance(func, (classmethod, staticmethod)):
+            func = func.__func__
+        elif isinstance(func, (partial, partialmethod)):
+            func = func.func
+
+    return func
+
+
+def get_function_ref(func: Any) -> str:
+    func = unwrap_wrapped_function(func)
+    return (
+        getattr(func, '__module__', '<No __module__>')
+        + '.'
+        + getattr(func, '__qualname__', f'<No __qualname__: id:{id(func)}>')
+    )
 
 
-def is_classmethod_from_sig(function: Callable[..., Any] | classmethod[Any] | staticmethod[Any]) -> bool:
-    sig = signature(unwrap_unbound_methods(function))
+def is_classmethod_from_sig(function: AnyDecoratorCallable) -> bool:
+    sig = signature(unwrap_wrapped_function(function))
     first = next(iter(sig.parameters.values()), None)
     if first and first.name == 'cls':
         return True
     return False
 
 
-def is_instance_method_from_sig(function: Callable[..., Any] | classmethod[Any] | staticmethod[Any]) -> bool:
-    sig = signature(unwrap_unbound_methods(function))
+def is_instance_method_from_sig(function: AnyDecoratorCallable) -> bool:
+    sig = signature(unwrap_wrapped_function(function))
     first = next(iter(sig.parameters.values()), None)
     if first and first.name == 'self':
         return True
@@ -401,25 +432,26 @@ def is_instance_method_from_sig(function: Callable[..., Any] | classmethod[Any]
 
 
 def ensure_classmethod_based_on_signature(
-    function: Callable[..., Any] | classmethod[Any] | staticmethod[Any],
-) -> classmethod[Any] | staticmethod[Any] | Callable[..., Any]:
-    if not isinstance(function, classmethod) and is_classmethod_from_sig(function):
-        return classmethod(function)
+    function: AnyDecoratorCallable,
+) -> Any:
+    if not isinstance(
+        unwrap_wrapped_function(function, unwrap_class_static_method=False), classmethod
+    ) and is_classmethod_from_sig(function):
+        return classmethod(function)  # type: ignore[arg-type]
     return function
 
 
-def check_for_duplicate_validator(
-    function: Callable[..., Any] | classmethod[Any] | staticmethod[Any], allow_reuse: bool
+def check_for_duplicate_decorator_function(
+    function: AnyDecoratorCallable, allow_reuse: bool, type: Literal['validator', 'serialzier']
 ) -> None:
     """
     Warn about validators with duplicated names since without this, they can be overwritten silently
     which generally isn't the intended behaviour, don't run in ipython (see #312) or if `allow_reuse` is True.
     """
     if not allow_reuse and not in_ipython():
-        function = unwrap_unbound_methods(function)
-        ref = f'{function.__module__}::{function.__qualname__}'
+        ref = get_function_ref(function)
         if ref in _FUNCS:
-            warnings.warn(f'duplicate validator function "{ref}"; if this is intended, set `allow_reuse=True`')
+            warnings.warn(f'duplicate {type} function "{ref}"; if this is intended, set `allow_reuse=True`')
         _FUNCS.add(ref)
 
 
@@ -469,6 +501,14 @@ def __call__(self, __value: Any, values: dict[str, Any], **kwargs: Any) -> Any:
 ]
 
 
+def can_be_positional(param: Parameter) -> bool:
+    return param.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
+
+
+def can_be_keyword(param: Parameter) -> bool:
+    return param.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
+
+
 V1_VALIDATOR_VALID_SIGNATURES = """\
 def f1(value: Any) -> Any: ...
 def f2(value: Any, values: Dict[str, Any]) -> Any: ...
@@ -498,12 +538,12 @@ def val_x4(value: Any, values: Dict[str, Any]) -> Any: ...
 
 
 def make_generic_v1_field_validator(validator: V1Validator) -> FieldValidatorFunction:
-    sig = signature(unwrap_unbound_methods(validator))
-    positional_params: list[str] = []
-    keyword_only_params: list[str] = []
-    accepts_kwargs = False
-    for param_name, parameter in sig.parameters.items():
-        if param_name in ('field', 'config'):
+    sig = signature(validator)
+
+    needs_values_kw = False
+
+    for param_num, (param_name, parameter) in enumerate(sig.parameters.items()):
+        if can_be_keyword(parameter) and param_name in ('field', 'config'):
             raise TypeError(
                 'The `field` and `config` parameters are not available in Pydantic V2.'
                 ' Please use the `info` parameter instead.'
@@ -511,46 +551,38 @@ def make_generic_v1_field_validator(validator: V1Validator) -> FieldValidatorFun
                 ' but it is a dictionary instead of an object like it was in Pydantic V1.'
                 ' The `field` argument is no longer available.'
             )
-        if parameter.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD):
-            positional_params.append(param_name)
-        elif parameter.kind is Parameter.KEYWORD_ONLY:
-            keyword_only_params.append(param_name)
-        else:
-            assert parameter.kind is Parameter.VAR_KEYWORD
-            accepts_kwargs = True
-
-    accepts_values_kw = (keyword_only_params == ['values'] and len(positional_params) == 1) or (
-        len(positional_params) == 2 and positional_params[1] == 'values'
-    )
+        if parameter.kind is Parameter.VAR_KEYWORD:
+            needs_values_kw = True
+        elif can_be_keyword(parameter) and param_name == 'values':
+            needs_values_kw = True
+        elif can_be_positional(parameter) and param_num == 0:
+            # value
+            continue
+        elif parameter.default is Parameter.empty:  # ignore params with defaults e.g. bound by functools.partial
+            raise TypeError(
+                f'Unsupported signature for V1 style validator {validator}: {sig} is not supported.'
+                f' Valid signatures are:\n{V1_VALIDATOR_VALID_SIGNATURES}'
+            )
 
-    if accepts_kwargs and len(positional_params) == 1:
-        # has (v, **kwargs) or (v, values, **kwargs)
-        val1 = cast(Union[V1ValidatorWithKwargs, V1ValidatorWithValuesAndKwargs], validator)
+    if needs_values_kw:
+        # (v, **kwargs), (v, values, **kwargs), (v, *, values, **kwargs) or (v, *, values)
+        val1 = cast(V1ValidatorWithValues, validator)
 
         def wrapper1(value: Any, info: FieldValidationInfo) -> Any:
             return val1(value, values=info.data)
 
         return wrapper1
-    if len(positional_params) == 1 and keyword_only_params == []:
-        # (v) -> Any
+    else:
         val2 = cast(OnlyValueValidator, validator)
 
-        def wrapper2(value: Any, _: ValidationInfo) -> Any:
+        def wrapper2(value: Any, _: FieldValidationInfo) -> Any:
             return val2(value)
 
         return wrapper2
-    elif len(positional_params) in (1, 2) and accepts_values_kw:
-        # (v, values) -> Any or (v, *, values) -> Any
-        val3 = cast(V1ValidatorWithValues, validator)
 
-        def wrapper3(value: Any, info: FieldValidationInfo) -> Any:
-            return val3(value, values=info.data)
 
-        return wrapper3
-    raise TypeError(
-        f'Unsupported signature for V1 style validator {validator}: {sig} is not supported.'
-        f' Valid signatures are:\n{V1_VALIDATOR_VALID_SIGNATURES}'
-    )
+def remove_params_with_defaults(sig: Signature) -> Signature:
+    return Signature([p for p in sig.parameters.values() if p.default is Parameter.empty])
 
 
 @overload
@@ -575,7 +607,8 @@ def make_generic_v2_field_validator(
     we introspect the function signature and wrap it in a parent function that has a signature
     compatible with pydantic_core
     """
-    if mode in ('before', 'after', 'plain') and len(signature(validator).parameters) == 1:
+    sig = remove_params_with_defaults(signature(validator))
+    if mode in ('before', 'after', 'plain') and len(sig.parameters) == 1:
         val1 = cast(OnlyValueValidator, validator)
 
         # allow the (v) -> Any signature as a convenience
@@ -696,7 +729,7 @@ def make_generic_field_serializer(
     """
     Wrap serializers to allow ignoring the `info` argument as a convenience.
     """
-    sig = signature(serializer)
+    sig = remove_params_with_defaults(signature(serializer))
     if is_instance_method_from_sig(serializer):
         # for the errors below to exclude self
         sig = Signature(parameters=list(sig.parameters.values())[1:])
diff --git a/pydantic/decorators.py b/pydantic/decorators.py
--- a/pydantic/decorators.py
+++ b/pydantic/decorators.py
@@ -7,13 +7,13 @@
 
 from __future__ import annotations as _annotations
 
-from functools import partial
+from functools import partial, partialmethod
 from types import FunctionType
 from typing import Any, Callable, TypeVar, Union, overload
 from warnings import warn
 
 from pydantic_core import core_schema as _core_schema
-from typing_extensions import Literal, Protocol
+from typing_extensions import Literal, Protocol, TypeAlias
 
 from ._internal import _decorators
 
@@ -94,26 +94,21 @@ def __call__(self, __cls: Any, __values: _decorators.RootValidatorValues) -> _de
     _decorators.V1RootValidatorFunction,
 ]
 
+_PartialClsOrStaticMethod: TypeAlias = 'Union[classmethod[Any], staticmethod[Any], partialmethod[Any]]'
+
 
 # Allow both a V1 (assumed pre=False) or V2 (assumed mode='after') validator
 # We lie to type checkers and say we return the same thing we get
 # but in reality we return a proxy object that _mostly_ behaves like the wrapped thing
-_V1ValidatorType = TypeVar('_V1ValidatorType', bound=Union[V1Validator, 'classmethod[Any]', 'staticmethod[Any]'])
+_V1ValidatorType = TypeVar('_V1ValidatorType', bound=Union[V1Validator, _PartialClsOrStaticMethod])
 _V2BeforeAfterOrPlainValidatorType = TypeVar(
     '_V2BeforeAfterOrPlainValidatorType',
-    bound=Union[V2Validator, 'classmethod[Any]', 'staticmethod[Any]'],
-)
-_V2WrapValidatorType = TypeVar(
-    '_V2WrapValidatorType', bound=Union[V2WrapValidator, 'classmethod[Any]', 'staticmethod[Any]']
+    bound=Union[V2Validator, _PartialClsOrStaticMethod],
 )
+_V2WrapValidatorType = TypeVar('_V2WrapValidatorType', bound=Union[V2WrapValidator, _PartialClsOrStaticMethod])
 _V1RootValidatorFunctionType = TypeVar(
     '_V1RootValidatorFunctionType',
-    bound=Union[
-        _decorators.V1RootValidatorFunction,
-        _V1RootValidatorClsMethod,
-        'classmethod[Any]',
-        'staticmethod[Any]',
-    ],
+    bound=Union[_decorators.V1RootValidatorFunction, _V1RootValidatorClsMethod, _PartialClsOrStaticMethod],
 )
 
 
@@ -163,7 +158,7 @@ def validator(
     def dec(f: Any) -> _decorators.PydanticDecoratorMarker[Any]:
         if _decorators.is_instance_method_from_sig(f):
             raise TypeError('`@validator` cannot be applied to instance methods')
-        _decorators.check_for_duplicate_validator(f, allow_reuse=allow_reuse)
+        _decorators.check_for_duplicate_decorator_function(f, allow_reuse=allow_reuse, type='validator')
         # auto apply the @classmethod decorator
         f = _decorators.ensure_classmethod_based_on_signature(f)
         wrap = _decorators.make_generic_v1_field_validator
@@ -236,7 +231,7 @@ def field_validator(
     def dec(f: Callable[..., Any] | staticmethod[Any] | classmethod[Any]) -> _decorators.PydanticDecoratorMarker[Any]:
         if _decorators.is_instance_method_from_sig(f):
             raise TypeError('`@field_validator` cannot be applied to instance methods')
-        _decorators.check_for_duplicate_validator(f, allow_reuse=allow_reuse)
+        _decorators.check_for_duplicate_decorator_function(f, allow_reuse=allow_reuse, type='validator')
         # auto apply the @classmethod decorator and warn users if we had to do so
         f = _decorators.ensure_classmethod_based_on_signature(f)
 
@@ -311,7 +306,7 @@ def root_validator(
     def dec(f: Callable[..., Any] | classmethod[Any] | staticmethod[Any]) -> Any:
         if _decorators.is_instance_method_from_sig(f):
             raise TypeError('`@root_validator` cannot be applied to instance methods')
-        _decorators.check_for_duplicate_validator(f, allow_reuse=allow_reuse)
+        _decorators.check_for_duplicate_decorator_function(f, allow_reuse=allow_reuse, type='validator')
         # auto apply the @classmethod decorator and warn users if we had to do so
         res = _decorators.ensure_classmethod_based_on_signature(f)
         validator_wrapper_info = _decorators.RootValidatorDecoratorInfo(mode=mode)
@@ -325,6 +320,7 @@ def dec(f: Callable[..., Any] | classmethod[Any] | staticmethod[Any]) -> Any:
     _core_schema.FieldPlainSerializerFunction,
     _decorators.GenericPlainSerializerFunctionWithoutInfo,
     _decorators.FieldPlainSerializerFunctionWithoutInfo,
+    _PartialClsOrStaticMethod,
 ]
 
 
@@ -333,6 +329,7 @@ def dec(f: Callable[..., Any] | classmethod[Any] | staticmethod[Any]) -> Any:
     _core_schema.FieldWrapSerializerFunction,
     _decorators.GeneralWrapSerializerFunctionWithoutInfo,
     _decorators.FieldWrapSerializerFunctionWithoutInfo,
+    _PartialClsOrStaticMethod,
 ]
 
 
@@ -409,7 +406,7 @@ def field_serializer(
     """
 
     def dec(f: Callable[..., Any] | staticmethod[Any] | classmethod[Any]) -> _decorators.PydanticDecoratorMarker[Any]:
-        res = _decorators.prepare_serializer_decorator(f, allow_reuse)
+        _decorators.check_for_duplicate_decorator_function(f, allow_reuse, type='serialzier')
         type_: Literal['field', 'general'] = 'field' if _decorators.is_instance_method_from_sig(f) else 'general'
 
         dec_info = _decorators.FieldSerializerDecoratorInfo(
@@ -422,7 +419,7 @@ def dec(f: Callable[..., Any] | staticmethod[Any] | classmethod[Any]) -> _decora
             check_fields=check_fields,
         )
         return _decorators.PydanticDecoratorMarker(
-            res, dec_info, shim=partial(_decorators.make_generic_field_serializer, mode=mode, type=type_)
+            f, dec_info, shim=partial(_decorators.make_generic_field_serializer, mode=mode, type=type_)
         )
 
     return dec
@@ -450,14 +447,14 @@ def dec(f: Callable[..., Any]) -> _decorators.PydanticDecoratorMarker[Any]:
         if isinstance(f, (staticmethod, classmethod)) or not _decorators.is_instance_method_from_sig(f):
             raise TypeError('`@model_serializer` must be applied to instance methods')
 
-        res = _decorators.prepare_serializer_decorator(f, allow_reuse)
+        _decorators.check_for_duplicate_decorator_function(f, allow_reuse, type='serialzier')
 
         dec_info = _decorators.ModelSerializerDecoratorInfo(
             mode=mode,
             json_return_type=json_return_type,
         )
         return _decorators.PydanticDecoratorMarker(
-            res, dec_info, shim=partial(_decorators.make_generic_model_serializer, mode=mode)
+            f, dec_info, shim=partial(_decorators.make_generic_model_serializer, mode=mode)
         )
 
     if __f is None:


Test patch:
diff --git a/tests/test_serialize.py b/tests/test_serialize.py
--- a/tests/test_serialize.py
+++ b/tests/test_serialize.py
@@ -1,6 +1,7 @@
 """
 New tests for v2 of serialization logic.
 """
+from functools import partial, partialmethod
 from typing import Any, Optional
 
 import pytest
@@ -18,6 +19,16 @@
 )
 
 
+@pytest.fixture
+def reset_tracked_serializer():
+    from pydantic._internal._decorators import _FUNCS
+
+    original_tracked_serializers = set(_FUNCS)
+    yield
+    _FUNCS.clear()
+    _FUNCS.update(original_tracked_serializers)
+
+
 def test_serialize_decorator_always():
     class MyModel(BaseModel):
         x: Optional[int]
@@ -428,3 +439,83 @@ def serializer2(v, _info):
 
     assert MyModel(x=1234).model_dump() == {'x': '1,234'}
     assert MySubModel(x=1234).model_dump() == {'x': '1234'}
+
+
+def int_ser_func_without_info1(v: int, expected: int) -> str:
+    return f'{v:,}'
+
+
+def int_ser_func_without_info2(v: int, *, expected: int) -> str:
+    return f'{v:,}'
+
+
+def int_ser_func_with_info1(v: int, info: FieldSerializationInfo, expected: int) -> str:
+    return f'{v:,}'
+
+
+def int_ser_func_with_info2(v: int, info: FieldSerializationInfo, *, expected: int) -> str:
+    return f'{v:,}'
+
+
+def int_ser_instance_method_without_info1(self: Any, v: int, *, expected: int) -> str:
+    assert self.x == v
+    return f'{v:,}'
+
+
+def int_ser_instance_method_without_info2(self: Any, v: int, expected: int) -> str:
+    assert self.x == v
+    return f'{v:,}'
+
+
+def int_ser_instance_method_with_info1(self: Any, v: int, info: FieldSerializationInfo, expected: int) -> str:
+    assert self.x == v
+    return f'{v:,}'
+
+
+def int_ser_instance_method_with_info2(self: Any, v: int, info: FieldSerializationInfo, *, expected: int) -> str:
+    assert self.x == v
+    return f'{v:,}'
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        int_ser_func_with_info1,
+        int_ser_func_with_info2,
+        int_ser_func_without_info1,
+        int_ser_func_without_info2,
+        int_ser_instance_method_with_info1,
+        int_ser_instance_method_with_info2,
+        int_ser_instance_method_without_info1,
+        int_ser_instance_method_without_info2,
+    ],
+)
+def test_serialize_partial(func: Any, reset_tracked_serializer: Any):
+    class MyModel(BaseModel):
+        x: int
+
+        ser = field_serializer('x', json_return_type='str')(partial(func, expected=1234))
+
+    assert MyModel(x=1234).model_dump() == {'x': '1,234'}
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        int_ser_func_with_info1,
+        int_ser_func_with_info2,
+        int_ser_func_without_info1,
+        int_ser_func_without_info2,
+        int_ser_instance_method_with_info1,
+        int_ser_instance_method_with_info2,
+        int_ser_instance_method_without_info1,
+        int_ser_instance_method_without_info2,
+    ],
+)
+def test_serialize_partialmethod(func: Any, reset_tracked_serializer: Any):
+    class MyModel(BaseModel):
+        x: int
+
+        ser = field_serializer('x', json_return_type='str')(partialmethod(func, expected=1234))
+
+    assert MyModel(x=1234).model_dump() == {'x': '1,234'}
diff --git a/tests/test_validators.py b/tests/test_validators.py
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -2,8 +2,9 @@
 from collections import deque
 from datetime import datetime
 from enum import Enum
+from functools import partial, partialmethod
 from itertools import product
-from typing import Any, Deque, Dict, FrozenSet, List, Optional, Tuple, Type, Union
+from typing import Any, Callable, Deque, Dict, FrozenSet, List, Optional, Tuple, Type, Union
 from unittest.mock import MagicMock
 
 import pytest
@@ -368,7 +369,7 @@ def check_a(cls, v: Any):
 
 
 def test_duplicates():
-    msg = r'duplicate validator function \"tests.test_validators::test_duplicates.<locals>.Model.duplicate_name\";'
+    msg = r'duplicate validator function \"tests.test_validators.test_duplicates.<locals>.Model.duplicate_name\";'
     with pytest.warns(UserWarning, match=msg):
 
         class Model(BaseModel):
@@ -1929,3 +1930,271 @@ class ValidatingModel(Model):
             'type': 'assertion_error',
         }
     ]
+
+
+def partial_val_func1(
+    value: int,
+    allowed: int,
+) -> int:
+    assert value == allowed
+    return value
+
+
+def partial_val_func2(
+    value: int,
+    *,
+    allowed: int,
+) -> int:
+    assert value == allowed
+    return value
+
+
+def partial_values_val_func1(
+    value: int,
+    values: Dict[str, Any],
+    *,
+    allowed: int,
+) -> int:
+    assert isinstance(values, dict)
+    assert value == allowed
+    return value
+
+
+def partial_values_val_func2(
+    value: int,
+    *,
+    values: Dict[str, Any],
+    allowed: int,
+) -> int:
+    assert isinstance(values, dict)
+    assert value == allowed
+    return value
+
+
+def partial_info_val_func(
+    value: int,
+    info: FieldValidationInfo,
+    *,
+    allowed: int,
+) -> int:
+    assert isinstance(info.data, dict)
+    assert value == allowed
+    return value
+
+
+def partial_cls_val_func1(
+    cls: Any,
+    value: int,
+    allowed: int,
+    expected_cls: Any,
+) -> int:
+    assert cls.__name__ == expected_cls
+    assert value == allowed
+    return value
+
+
+def partial_cls_val_func2(
+    cls: Any,
+    value: int,
+    *,
+    allowed: int,
+    expected_cls: Any,
+) -> int:
+    assert cls.__name__ == expected_cls
+    assert value == allowed
+    return value
+
+
+def partial_cls_values_val_func1(
+    cls: Any,
+    value: int,
+    values: Dict[str, Any],
+    *,
+    allowed: int,
+    expected_cls: Any,
+) -> int:
+    assert cls.__name__ == expected_cls
+    assert isinstance(values, dict)
+    assert value == allowed
+    return value
+
+
+def partial_cls_values_val_func2(
+    cls: Any,
+    value: int,
+    *,
+    values: Dict[str, Any],
+    allowed: int,
+    expected_cls: Any,
+) -> int:
+    assert cls.__name__ == expected_cls
+    assert isinstance(values, dict)
+    assert value == allowed
+    return value
+
+
+def partial_cls_info_val_func(
+    cls: Any,
+    value: int,
+    info: FieldValidationInfo,
+    *,
+    allowed: int,
+    expected_cls: Any,
+) -> int:
+    assert cls.__name__ == expected_cls
+    assert isinstance(info.data, dict)
+    assert value == allowed
+    return value
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        partial_val_func1,
+        partial_val_func2,
+        partial_info_val_func,
+    ],
+)
+def test_functools_partial_validator_v2(
+    reset_tracked_validators: Any,
+    func: Callable[..., Any],
+) -> None:
+    class Model(BaseModel):
+        x: int
+
+        val = field_validator('x')(partial(func, allowed=42))
+
+    Model(x=42)
+
+    with pytest.raises(ValidationError):
+        Model(x=123)
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        partial_val_func1,
+        partial_val_func2,
+        partial_info_val_func,
+    ],
+)
+def test_functools_partialmethod_validator_v2(
+    reset_tracked_validators: Any,
+    func: Callable[..., Any],
+) -> None:
+    class Model(BaseModel):
+        x: int
+
+        val = field_validator('x')(partialmethod(func, allowed=42))
+
+    Model(x=42)
+
+    with pytest.raises(ValidationError):
+        Model(x=123)
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        partial_cls_val_func1,
+        partial_cls_val_func2,
+        partial_cls_info_val_func,
+    ],
+)
+def test_functools_partialmethod_validator_v2_cls_method(
+    reset_tracked_validators: Any,
+    func: Callable[..., Any],
+) -> None:
+    class Model(BaseModel):
+        x: int
+
+        # note that you _have_ to wrap your function with classmethod
+        # it's partialmethod not us that requires it
+        # otherwise it creates a bound instance method
+        val = field_validator('x')(partialmethod(classmethod(func), allowed=42, expected_cls='Model'))
+
+    Model(x=42)
+
+    with pytest.raises(ValidationError):
+        Model(x=123)
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        partial_val_func1,
+        partial_val_func2,
+        partial_values_val_func1,
+        partial_values_val_func2,
+    ],
+)
+def test_functools_partial_validator_v1(
+    reset_tracked_validators: Any,
+    func: Callable[..., Any],
+) -> None:
+    with pytest.warns(DeprecationWarning, match=V1_VALIDATOR_DEPRECATION_MATCH):
+
+        class Model(BaseModel):
+            x: int
+
+            val = validator('x')(partial(func, allowed=42))
+
+    Model(x=42)
+
+    with pytest.raises(ValidationError):
+        Model(x=123)
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        partial_val_func1,
+        partial_val_func2,
+        partial_values_val_func1,
+        partial_values_val_func2,
+    ],
+)
+def test_functools_partialmethod_validator_v1(
+    reset_tracked_validators: Any,
+    func: Callable[..., Any],
+) -> None:
+    with pytest.warns(DeprecationWarning, match=V1_VALIDATOR_DEPRECATION_MATCH):
+
+        class Model(BaseModel):
+            x: int
+
+            val = validator('x')(partialmethod(func, allowed=42))
+
+        Model(x=42)
+
+        with pytest.raises(ValidationError):
+            Model(x=123)
+
+
+@pytest.mark.parametrize(
+    'func',
+    [
+        partial_cls_val_func1,
+        partial_cls_val_func2,
+        partial_cls_values_val_func1,
+        partial_cls_values_val_func2,
+    ],
+)
+def test_functools_partialmethod_validator_v1_cls_method(
+    reset_tracked_validators: Any,
+    func: Callable[..., Any],
+) -> None:
+    with pytest.warns(DeprecationWarning, match=V1_VALIDATOR_DEPRECATION_MATCH):
+
+        class Model(BaseModel):
+            x: int
+
+            # note that you _have_ to wrap your function with classmethod
+            # it's partialmethod not us that requires it
+            # otherwise it creates a bound instance method
+            val = validator('x')(partialmethod(classmethod(func), allowed=42, expected_cls='Model'))
+
+    Model(x=42)
+
+    with pytest.raises(ValidationError):
+        Model(x=123)