Skip to content

sample use case: Type object with particular constructor signature #26

Open

Description

@ippeiukai

Annotating 'Type object with particular constructor signature' requires intersection.

Suppose we have a

# returns cached instance of SomeClass or creates a new instance
def get_cached_or_create(klass):
  if _is_cached(klass):
    return _get_cached(klass)
  else:
    print(f"creating instance for {klass}")
    instance = klass()
    _set_cached(klass, instance)
    return instance

Now, how should we type get_cached_or_create?

Naive typing would be to accept type[T]:

T = TypeVar('T')

def get_cached_or_create(klass: type[T]) -> T:
  ...

However, it does not express the fact T's constructor must be callable without argument.
Above signature suggests get_cached_or_create can be called with type object with any constructor signature.

The desired typing of get_cached_or_create in fact is:

T = TypeVar('T')

def get_cached_or_create(klass: type[T] & Callable[[], T]) -> T:
  ...

Activity

  1. junkmd commented on Sep 10, 2023

    @junkmd
    Collaborator

    Hi!

    it does not express the fact T's constructor must be callable without argument.

    I think it's better to bind the Protocol to a TypeVar rather than using Intersection for this.

    For example...

    from typing import Protocol, TypeVar
    
    class HasConstructorNoArgsRequired(Protocol):
        def __init__(self) -> None: ...
    
    T = TypeVar('T', bound=HasConstructorNoArgsRequired)
    
    def spam(a: type[T]) -> T: ...
    
    class Foo:
        def __init__(self) -> None: ...
    
    class Bar:
        def __init__(self, a1: int) -> None: ...
    
    
    spam(Foo)  # Valid
    spam(Bar)  # Invalid, Pylance(reportGeneralTypeIssues)

    type[T] & Callable[[], T] can be seen as expressing constraints on the constructor of the T class, but it also appears to express complex metaclass behavior.

  2. ippeiukai commented on Sep 10, 2023

    @ippeiukai
    Author

    Specifying __init__ signature is not enough because the class can override __new__ or its metaclass's __call__ (I think mypy has limitations with them, but pyright handles them somewhat better).

  3. junkmd commented on Sep 10, 2023

    @junkmd
    Collaborator

    the class can override __new__

    I think we can do this to specify __new__ in Protocol.

    or its metaclass's __call__

    This would certainly be difficult to express with type annotations.
    However, it's challenging to express or constrain metaclass behavior through static typing.
    Therefore, I think specifying __init__ or __new__ signature in a Protocol is sufficient.

    mypy has limitations with them

    Certainly, I tried this on the mypy playground, it didn't result in an error.
    Maybe this should be reported to the mypy developers.

  4. ippeiukai commented on Sep 10, 2023

    @ippeiukai
    Author

    it's challenging to express or constrain metaclass behavior through static typing.

    Well, that's my point exactly. It will get much easier with intersections. Whatever additional behaviour you expect a type object to have, you will be able to just intersect a Callable or Protocol with the type type.

  5. junkmd commented on Sep 10, 2023

    @junkmd
    Collaborator

    I understand. Indeed, your approach is necessary to cover those cases.

    Providing examples of what is valid and invalid would make it more clear.

    from collections.abc import Callable
    from typing import Self, TypeVar
    
    T = TypeVar('T')
    
    class A:
        def __init__(self) -> None: ...
    
    class B:
        def __new__(cls) -> Self: ...
    
    class C:
        def __init__(self, num: int) -> None: ...
    
    class D:
        def __new__(cls, num: int) -> Self: ...
    
    class _Meta1(type):
        def __new__(cls, name, bases, attributes) -> Self: ...
        def __call__(self) -> 'E': ...
    
    class E(metaclass=_Meta1): ...
    
    class _Meta2(type):
        def __new__(cls, name, bases, attributes) -> Self: ...
        def __call__(self, num: int) -> 'F': ...
    
    class F(metaclass=_Meta2): ...
    
    def f1(klass: type[T] & Callable[[], T]) -> T: ...
    
    f1(A)  # Valid
    f1(B)  # Valid
    f1(C)  # Invalid
    f1(D)  # Invalid
    f1(E)  # Valid
    f1(F)  # Invalid  # Well, is this available for any static type checkers?
    
    def f2(klass: type[T] & Callable[..., T]) -> T: ...
    
    f2(A)  # Valid
    f2(B)  # Valid
    f2(C)  # Valid
    f2(D)  # Valid
    f2(E)  # Valid
    f2(F)  # Valid
    
    def f3(klass: type[T]) -> T: ...
    
    f3(A)  # Valid
    f3(B)  # Valid
    f3(C)  # Valid
    f3(D)  # Valid
    f3(E)  # Valid
    f3(F)  # Valid

    Is my example OK?

  6. ippeiukai commented on Sep 10, 2023

    @ippeiukai
    Author

    Looks good. Exactly what I was thinking.
    Thank you for clarifying!

  7. erictraut commented on Sep 10, 2023

    @erictraut
    Collaborator

    Why do you need an intersection here? Isn't it sufficient to use Callable[[], T] as the input type? In your example, you've intersected this type with type[T], but it's not clear to me what value that provides from a typing standpoint.

  8. ippeiukai commented on Sep 10, 2023

    @ippeiukai
    Author

    Isn't it sufficient to use Callable[[], T] as the input type?

    Probably I have over simplified the example.

    My actual case required an object with following behaviours at the least:

    • is callable with no argument and returns its instance
    • has __name__ attribute (used for error messages)
    • Hashable (used as cache key) to identify T
    • can be passed to isinstance (for an assertion, which is not strictly necessary)
    • can be passed to issubclass (once, there was a plan to return an instance of subclass if already cached)

    The gist is, though, that the doc comment for the param states "class object with void constructor", and there is no type annotation that exactly matches that without intersection.

  9. mikeshardmind commented on Sep 12, 2023

    @mikeshardmind
    Collaborator

    Keep in mind that prior decisions from type checkers not to enforce something for "pragmatic" reasons is shining a light on why this might be needed. Unlike many of the prior ones, I find the intersection-based solution if intersections are added sufficient to resolve this. (even though I think that this shouldn't be allowed, and that if a subclass needs alternate incompatible construction, it probably shouldn't claim to be a drop-in replacement and shouldn't be a subclass)

  10. ippeiukai commented on Sep 13, 2023

    @ippeiukai
    Author

    So, continuing from example given in #26 (comment):

    class C:
        def __init__(self, num: int) -> None: ...
    
    class CSub(C):
        # in practice, signature of __init__ method may be incompatible with parent class
        def __init__(self) -> None: ...
    
    v1: type[C] = C  # Valid
    v2: type[C] & Callable[..., C] = C  # Valid
    v3: type[C] & Callable[[], C] = C  # Invalid
    v4: type[C] & Callable[[int], C] = C  # Valid
    
    v1_sub: type[C] = CSub  # Valid
    v2_sub: type[C] & Callable[..., C] = CSub  # Valid
    v3_sub: type[C] & Callable[[], C] = CSub  # Valid
    v4_sub: type[C] & Callable[[int], C] = CSub  # Invalid
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    sample use casehere to make sure none of these get lost

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions