Kwargs-only ParamSpec

Sometimes the need arises for a ParamSpec that only allows keyword arguments. Recently, I saw this PR on typeshed: Make `copy.replace` more strongly typed by decorator-factory · Pull Request #14819 · python/typeshed · GitHub where the problem is that we want copy.replace() to inherit the signature from .__replace__() but we have to make sure that copy.replace() only accepts keyword arguments (except for the first one, which is the object and is positionally-only). It therefore seems natural to define the protocol for __replace__() like this:

class _SupportsReplace[**P, T](Protocol):
    def __replace__(self, /, **kwargs: P.kwargs) -> T:
        ...

where we only used the P.kwargs part of the ParamSpec and not the P.args part, in order to signal that any implementation of this protocol should enforce keyword arguments, like this:

class A:
    def __init__(self, x: int):
        self.x = x
    def __replace__(self, /, **kwargs) -> Self:
        return A(x=kwargs.get("x", self.x))

Then, copy.replace() can be typed like this:

def replace[**P, T](obj: _SupportsReplace[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
    return obj.__replace__(*args, **kwargs)

Well, or like this:

def replace[**P, T](obj: _SupportsReplace[P, T], **kwargs: P.kwargs) -> T:
    return obj.__replace__(**kwargs)

but it shouldn’t matter which one.


So, are there any soundness issue with allowing only using P.kwargs?

9 Likes

This has been discussed before, although I curiously didn’t find any issue in the typing GitHub repository. If I remember correctly, there were never really any objections, but no one ever got around to formalizing this. I personally think this would be very useful, and we have a few spots in typeshed where we could use this.

2 Likes

A more general approach might be to allow Unpack-ing TypedDict-bound type variables, as requested in this GitHub issue.

You could then use it like this:

from typing import TypedDict, Unpack


class _SupportsReplace[P: TypedDict, T](Protocol):
    def __replace__(self, /, **kwargs: Unpack[P]) -> T:
        ...


def replace[P: TypedDict, T](obj: _SupportsReplace[P, T], **kwargs: Unpack[P]) -> T:
    return obj.__replace__(**kwargs)

That said, I like your proposal @tmk, but in my opinion it might be somewhat implicit.

I believe that here P should have a bound (maybe a new special symbol, **P: typing.KwOnlyParamSpec?), because a type like _SupportsReplace[[int], T] is syntactically correct but does not make any sense. If you meant to include an implicit bound, I don’t think we have them anywhere else in the type system, it might be interesting but I think less readable.

Disclaimer: I am only a typing enthusiast, not an expert… if I got anything wrong please correct me.

2 Likes