Repository: pydantic/pydantic
Issue category: Feature Request

Support more callables in `PlainSerializer`
### Initial Checks

- [X] I have searched Google & GitHub for similar requests and couldn't find anything
- [X] I have read and followed [the docs](https://docs.pydantic.dev) and still think this feature is missing

### Description

As [noticed](https://github.com/pydantic/pydantic/pull/9450#issuecomment-2120916182) in https://github.com/pydantic/pydantic/pull/9450, the callable passed into `PlainSerializer`/`WrapSerializer` can optionally take a `serializer` and `info` argument. However, callables with arguments with default values will not be accepted, even though it could work fine:

```python
from typing import Annotated

from pydantic import BaseModel, PlainSerializer

def my_callable(value, unrelated_arg = None, other_unrelated_arg = 1, even_more = "a"): ...

class Model(BaseModel):
    foo: Annotated[int, PlainSerializer(my_callable)]
```

This currently fails as `my_callable` has `4` positional arguments, and Pydantic expects at most 3 (for the wrap serializer). One real use case is with `str`, having the signature `<Signature (object=<no value>, encoding=<no value>, errors=<no value>)>`.

I think it is reasonable to assume that any argument with default values should not be counted by `count_positional_params`. After all, if you need the `info` argument there's no reason to have a default value. I'll try to come up with a solution which will hopefully not break existing use cases.

### Affected Components

- [ ] [Compatibility between releases](https://docs.pydantic.dev/changelog/)
- [ ] [Data validation/parsing](https://docs.pydantic.dev/concepts/models/#basic-model-usage)
- [ ] [Data serialization](https://docs.pydantic.dev/concepts/serialization/) - `.model_dump()` and `.model_dump_json()`
- [ ] [JSON Schema](https://docs.pydantic.dev/concepts/json_schema/)
- [ ] [Dataclasses](https://docs.pydantic.dev/concepts/dataclasses/)
- [ ] [Model Config](https://docs.pydantic.dev/concepts/config/)
- [ ] [Field Types](https://docs.pydantic.dev/api/types/) - adding or changing a particular data type
- [ ] [Function validation decorator](https://docs.pydantic.dev/concepts/validation_decorator/)
- [ ] [Generic Models](https://docs.pydantic.dev/concepts/models/#generic-models)
- [X] [Other Model behaviour](https://docs.pydantic.dev/concepts/models/) - `model_construct()`, pickling, private attributes, ORM mode
- [ ] [Plugins](https://docs.pydantic.dev/) and integration with other tools - mypy, FastAPI, python-devtools, Hypothesis, VS Code, PyCharm, etc.


Functions to edit:
pydantic/_internal/_decorators.py:inspect_validator
pydantic/_internal/_decorators.py:inspect_field_serializer
pydantic/_internal/_decorators.py:inspect_annotated_serializer
pydantic/_internal/_decorators.py:inspect_model_serializer
pydantic/_internal/_decorators.py:count_positional_params
Functions added: pydantic/_internal/_decorators.py:count_positional_required_params

Patch:
diff --git a/pydantic/_internal/_decorators.py b/pydantic/_internal/_decorators.py
index 78414cbe33..65ea8380de 100644
--- a/pydantic/_internal/_decorators.py
+++ b/pydantic/_internal/_decorators.py
@@ -514,12 +514,11 @@ def inspect_validator(validator: Callable[..., Any], mode: FieldValidatorModes)
     """
     try:
         sig = signature(validator)
-    except ValueError:
-        # builtins and some C extensions don't have signatures
-        # assume that they don't take an info argument and only take a single argument
-        # e.g. `str.strip` or `datetime.datetime`
+    except (ValueError, TypeError):
+        # `inspect.signature` might not be able to infer a signature, e.g. with C objects.
+        # In this case, we assume no info argument is present:
         return False
-    n_positional = count_positional_params(sig)
+    n_positional = count_positional_required_params(sig)
     if mode == 'wrap':
         if n_positional == 3:
             return True
@@ -555,12 +554,17 @@ def inspect_field_serializer(
     Returns:
         Tuple of (is_field_serializer, info_arg).
     """
-    sig = signature(serializer)
+    try:
+        sig = signature(serializer)
+    except (ValueError, TypeError):
+        # `inspect.signature` might not be able to infer a signature, e.g. with C objects.
+        # In this case, we assume no info argument is present and this is not a method:
+        return (False, False)
 
     first = next(iter(sig.parameters.values()), None)
     is_field_serializer = first is not None and first.name == 'self'
 
-    n_positional = count_positional_params(sig)
+    n_positional = count_positional_required_params(sig)
     if is_field_serializer:
         # -1 to correct for self parameter
         info_arg = _serializer_info_arg(mode, n_positional - 1)
@@ -599,7 +603,7 @@ def inspect_annotated_serializer(serializer: Callable[..., Any], mode: Literal['
         # `inspect.signature` might not be able to infer a signature, e.g. with C objects.
         # In this case, we assume no info argument is present:
         return False
-    info_arg = _serializer_info_arg(mode, count_positional_params(sig))
+    info_arg = _serializer_info_arg(mode, count_positional_required_params(sig))
     if info_arg is None:
         raise PydanticUserError(
             f'Unrecognized field_serializer function signature for {serializer} with `mode={mode}`:{sig}',
@@ -627,7 +631,7 @@ def inspect_model_serializer(serializer: Callable[..., Any], mode: Literal['plai
         )
 
     sig = signature(serializer)
-    info_arg = _serializer_info_arg(mode, count_positional_params(sig))
+    info_arg = _serializer_info_arg(mode, count_positional_required_params(sig))
     if info_arg is None:
         raise PydanticUserError(
             f'Unrecognized model_serializer function signature for {serializer} with `mode={mode}`:{sig}',
@@ -773,8 +777,26 @@ def get_function_return_type(
         return explicit_return_type
 
 
-def count_positional_params(sig: Signature) -> int:
-    return sum(1 for param in sig.parameters.values() if can_be_positional(param))
+def count_positional_required_params(sig: Signature) -> int:
+    """Get the number of positional (required) arguments of a signature.
+
+    This function should only be used to inspect signatures of validation and serialization functions.
+    The first argument (the value being serialized or validated) is counted as a required argument
+    even if a default value exists.
+
+    Returns:
+        The number of positional arguments of a signature.
+    """
+    parameters = list(sig.parameters.values())
+    return sum(
+        1
+        for param in parameters
+        if can_be_positional(param)
+        # First argument is the value being validated/serialized, and can have a default value
+        # (e.g. `float`, which has signature `(x=0, /)`). We assume other parameters (the info arg
+        # for instance) should be required, and thus without any default value.
+        and (param.default is Parameter.empty or param == parameters[0])
+    )
 
 
 def can_be_positional(param: Parameter) -> bool:

Test patch: diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index abc588362b..b28cf159b7 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -1,11 +1,49 @@
-import platform
-
 import pytest
 
 from pydantic import PydanticUserError
 from pydantic._internal._decorators import inspect_annotated_serializer, inspect_validator
 
 
+def _two_pos_required_args(a, b):
+    pass
+
+
+def _two_pos_required_args_extra_optional(a, b, c=1, d=2, *, e=3):
+    pass
+
+
+def _three_pos_required_args(a, b, c):
+    pass
+
+
+def _one_pos_required_arg_one_optional(a, b=1):
+    pass
+
+
+@pytest.mark.parametrize(
+    [
+        'obj',
+        'mode',
+        'expected',
+    ],
+    [
+        (str, 'plain', False),
+        (float, 'plain', False),
+        (int, 'plain', False),
+        (lambda a: str(a), 'plain', False),
+        (lambda a='': str(a), 'plain', False),
+        (_two_pos_required_args, 'plain', True),
+        (_two_pos_required_args, 'wrap', False),
+        (_two_pos_required_args_extra_optional, 'plain', True),
+        (_two_pos_required_args_extra_optional, 'wrap', False),
+        (_three_pos_required_args, 'wrap', True),
+        (_one_pos_required_arg_one_optional, 'plain', False),
+    ],
+)
+def test_inspect_validator(obj, mode, expected):
+    assert inspect_validator(obj, mode=mode) == expected
+
+
 def test_inspect_validator_error_wrap():
     def validator1(arg1):
         pass
@@ -43,18 +81,6 @@ def validator3(arg1, arg2, arg3):
     assert e.value.code == 'validator-signature'
 
 
-def _accepts_info_arg_plain(a, b):
-    pass
-
-
-def _accepts_info_arg_wrap(a, b, c):
-    pass
-
-
-@pytest.mark.skipif(
-    platform.python_implementation() == 'PyPy',
-    reason='`inspect.signature works differently on PyPy`.',
-)
 @pytest.mark.parametrize(
     [
         'obj',
@@ -66,9 +92,13 @@ def _accepts_info_arg_wrap(a, b, c):
         (float, 'plain', False),
         (int, 'plain', False),
         (lambda a: str(a), 'plain', False),
-        (_accepts_info_arg_plain, 'plain', True),
-        (_accepts_info_arg_plain, 'wrap', False),
-        (_accepts_info_arg_wrap, 'wrap', True),
+        (lambda a='': str(a), 'plain', False),
+        (_two_pos_required_args, 'plain', True),
+        (_two_pos_required_args, 'wrap', False),
+        (_two_pos_required_args_extra_optional, 'plain', True),
+        (_two_pos_required_args_extra_optional, 'wrap', False),
+        (_three_pos_required_args, 'wrap', True),
+        (_one_pos_required_arg_one_optional, 'plain', False),
     ],
 )
 def test_inspect_annotated_serializer(obj, mode, expected):
diff --git a/tests/test_serialize.py b/tests/test_serialize.py
index c28debcbb6..d1ce9818fe 100644
--- a/tests/test_serialize.py
+++ b/tests/test_serialize.py
@@ -225,6 +225,10 @@ def test_serialize_valid_signatures():
     def ser_plain(v: Any, info: SerializationInfo) -> Any:
         return f'{v:,}'
 
+    def ser_plain_no_info(v: Any, unrelated_arg: int = 1, other_unrelated_arg: int = 2) -> Any:
+        # Arguments with default values are not treated as info arg.
+        return f'{v:,}'
+
     def ser_wrap(v: Any, nxt: SerializerFunctionWrapHandler, info: SerializationInfo) -> Any:
         return f'{nxt(v):,}'
 
@@ -233,6 +237,7 @@ class MyModel(BaseModel):
         f2: int
         f3: int
         f4: int
+        f5: int
 
         @field_serializer('f1')
         def ser_f1(self, v: Any, info: FieldSerializationInfo) -> Any:
@@ -249,7 +254,8 @@ def ser_f2(self, v: Any, nxt: SerializerFunctionWrapHandler, info: FieldSerializ
             return f'{nxt(v):,}'
 
         ser_f3 = field_serializer('f3')(ser_plain)
-        ser_f4 = field_serializer('f4', mode='wrap')(ser_wrap)
+        ser_f4 = field_serializer('f4')(ser_plain_no_info)
+        ser_f5 = field_serializer('f5', mode='wrap')(ser_wrap)
 
     m = MyModel(**{f'f{x}': x * 1_000 for x in range(1, 9)})
 
@@ -258,8 +264,9 @@ def ser_f2(self, v: Any, nxt: SerializerFunctionWrapHandler, info: FieldSerializ
         'f2': '2,000',
         'f3': '3,000',
         'f4': '4,000',
+        'f5': '5,000',
     }
-    assert m.model_dump_json() == '{"f1":"1,000","f2":"2,000","f3":"3,000","f4":"4,000"}'
+    assert m.model_dump_json() == '{"f1":"1,000","f2":"2,000","f3":"3,000","f4":"4,000","f5":"5,000"}'
 
 
 def test_invalid_signature_no_params() -> None:
@@ -906,7 +913,7 @@ class Model(TypedDict):
         y: float
 
         @model_serializer(mode='wrap')
-        def _serialize(self, handler, info: Optional[SerializationInfo] = None):
+        def _serialize(self, handler, info: SerializationInfo):
             data = handler(self)
             if info.context and info.context.get('mode') == 'x-only':
                 data.pop('y')
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 52a8bda88f..d2518cb77b 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -1,6 +1,5 @@
 import contextlib
 import re
-import sys
 from collections import deque
 from dataclasses import dataclass
 from datetime import date, datetime
@@ -64,7 +63,6 @@ class Model(BaseModel):
     assert Model(x='1.0').x == 1.0
 
 
-@pytest.mark.xfail(sys.version_info >= (3, 9) and sys.implementation.name == 'pypy', reason='PyPy 3.9+ bug')
 def test_annotated_validator_builtin() -> None:
     """https://github.com/pydantic/pydantic/issues/6752"""
     TruncatedFloat = Annotated[float, BeforeValidator(int)]
@@ -2886,3 +2884,17 @@ class UnsupportedClass:
     model = type_adapter.validate_python('abcdefg')
     assert isinstance(model, UnsupportedClass)
     assert isinstance(type_adapter.dump_python(model), UnsupportedClass)
+
+
+def test_validator_with_default_values() -> None:
+    def validate_x(v: int, unrelated_arg: int = 1, other_unrelated_arg: int = 2) -> int:
+        assert v != -1
+        return v
+
+    class Model(BaseModel):
+        x: int
+
+        val_x = field_validator('x')(validate_x)
+
+    with pytest.raises(ValidationError):
+        Model(x=-1)