Skip to content

full typing support for validate_call as well as easy access to raw calls #9883

Closed

Description

@RonnyPfannschmidt

Initial Checks

  • I have searched Google & GitHub for similar requests and couldn't find anything
  • I have read and followed the docs and still think this feature is missing

Description

while working with openapi-codegen generated apis, i foudn that validate_call decorated apis are quite a pain when wanting to be trypesafe and/or invoking them

i want to propose using a fully typed callable protocol to typecheck them and including a way to correctly invoke the raw function even for bound methods

this would require to

  1. turn ValidateCallWrapper into a generic callable
  2. introducing a ValidateMethodWrapperproviding bound method behaviour for it
  3. providing both of those wrappers a function to do a unchecked call that shares the underlying signatures but works correctly

that way something like the following would be possible:

from pydantic import validate_call

class MyApi:
   "utterly trimmed down example"
   @validate_call
   def get_username(self, userid: int) -> str:
      return str(userid)



def test_what_i_want():
   api = MyApi()
   reveal_type(api.get_username.unchecked("bad_data_test")
  

Affected Components

Activity

  1. Viicos commented on Jul 11, 2024

    @Viicos
    Member

    while working with openapi-codegen generated apis, i foudn that validate_call decorated apis are quite a pain when wanting to be trypesafe and/or invoking them

    Could you give an example where it isn't typesafe?

    validate_call is currently typed as (omitting the first overload):

    @overload
    def validate_call(func: AnyCallableT, /) -> AnyCallableT: ...

    So from a type checking perspective, it returns the same decorated callable untouched.


    Regarding the .unchecked call, you can already do it this way:

    from pydantic import validate_call
    
    @validate_call
    def test(a: int) -> None:
        pass
    
    test.raw_function(a="a")  # OK at runtime

    However, type checkers can't know about raw_function. As you suggested, this would require exposing ValidateCallWrapper (or any other new class) in some way, and make it generic over a ParamSpec and a TypeVar for the return type:

    P = ParamSpec("P")
    T = TypeVar("T")
    
    class ValidateCallWrapper(Generic[P, T]):
        ...
    
        def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
    
        def raw_function(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
    
    
    def validate_call(func: Callable[P, T]) -> ValidateCallWrapper[P, T]: ...
    
    @validate_call
    def test(a: int) -> None:
        pass
    
    test(a=1)  # type checks
    test.raw_function(a=1)  # type checks as well

    However, you will hit issues when decorating methods, as type checkers aren't going to understand when the decorated method is bound or not.

    The same issue can be found for functools.cache in typeshed, and there's currently no ideal solution for this, see:

  2. RonnyPfannschmidt commented on Jul 11, 2024

    @RonnyPfannschmidt
    Author

    @Viicos in my example i was using a decorated method, where the raw function does not automatically get bound

    additional the raw function is not typed and missing from the type signature

  3. Viicos commented on Jul 11, 2024

    @Viicos
    Member

    additional the raw function is not typed and missing from the type signature

    As I described, there's no way to currently type check raw_function. We'll have to wait for the semantics of ParamSpec to be updated so that it can support both methods and function (or unbound methods). Additional discussion can be found here, and doesn't seem trivial.

  4. RonnyPfannschmidt commented on Jul 11, 2024

    @RonnyPfannschmidt
    Author

    i worked it out in python/mypy#16200 (comment) already as i needed a similar helper with extra properties and technically speaking its incorrect to pretend a callable protocol object is/can be the same as a plain function (there are numerous callable objects that do not bind, and sometimes intentionally/rightfully so)

  5. Viicos commented on Jul 11, 2024

    @Viicos
    Member

    Well you can still experiment with it and see if it works, considering the following should type check:

    @validate_call
    def test(a: int = 1) -> None: ...
    
    class A:
        @validate_call
        def test(self, a: int = 1) -> None: ...
    
    test(a=1) 
    test()
    test.raw_function(a=1)
    test.raw_function()
    
    A().test(a=1)
    A().test()
    A().test.raw_function(a=1)
    A().test.raw_function()
    
    A.test(A(), a=1)
    A.test(A())
    A.test.raw_function(A(), a=1)
    A.test.raw_function(A())

    But I'm pretty certain you will face the same issues as functools.cache. In particular, I experimented with here python/typeshed#11767 (one of the issue is ParamSpec doesn't know about default values in a function signature).

  6. Viicos commented on Aug 6, 2024

    @Viicos
    Member

    Closing for now as we can't fully express the functionality with the current typing constructs. We decided to type validate_call as:

    def validate_call(func: AnyCallableT, /) -> AnyCallableT: ...

    So that we preserve type checking of arguments, at the expense of not having the .raw_function exposed.

    functools.cache is doing it the other way around, exposing methods (such as cache_clear) on the wrapper but dropping the arguments type checking. It's an unfortunate tradeoff but considering the negligible usage of .raw_function I believe we are doing the right choice here.

    Happy to reopen once we get the necessary typing constructs to support both use cases.

  7. RonnyPfannschmidt commented on Aug 6, 2024

    @RonnyPfannschmidt
    Author

    as per python/mypy#16200 (comment) the necessary constructs exist

  8. Viicos commented on Aug 6, 2024

    @Viicos
    Member

    I'm afraid it will not be sufficient. If you think this is possible, please provide an example implementation that would type check the code snippet I gave here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions