Python Typing for a Callable Class

Viewed 438

I have something like

class Class1(Class2):
    def __init__(
        self,
        use_class = Class3
        ...
    ):
         do something

So Class1 inherits from Class2 and needs a parameter use_class which is set to Class3 by default. Class3 accepts floats and/or strings as input. But there can be other classes set to use instead of Class3. They also accept floats and/or strings as input. However, they all have to inherit from the same Class4. Furthermore, each of those classes that are valid inputs for use_class doesn't return anything. So my guess for typing would be

class Class1(Class2):
    def __init__(
        self,
        use_class: Callable[[Union[float, str]], None] = Class3
        ...
    ):
         do something

But this doesn't incorporate the fact that the class given as use_class has to inherit from Class4 in order for the code to run. Am I missing something here? I hope this question makes sense, any help or pointers are greatly appreciated.

1 Answers

We can do either of these, but not both.

To require that it be a subclass of Class4, we'd use Type for the standard nominal typing:

IsClass4 = Type[Class4]

And requiring that the value itself is a callable accepting certain inputs can be handled by structural typing via the Callable you suggested, or simply Protocol:

class MostlyWorks(Protocol):
    def __call__(self, value: float | str) -> Class4:
        ...

Ideally, we'd like to do both via an intersection type:

WhatWeWant = IsClass4 & AcceptsFloatsStrings

They're working on a PEP, but it's going to be some time.

Two thoughts on this: Python type checkers are often quite loose or even buggy, and there's not an absolutely canonical meaning for annotations. They were designed to catch dumb mistakes in existing codebases, and to make code a bit more self-documenting.

This is why I'd advise against going too deep down the annotations rabbit hole.

Second thought: imagine the type checker is a rather dim colleague such that if you can explain it to the type checker without using advanced techniques, your code will be clear and readable.

Here you're mixing nominal typing, structural typing, and you're requiring a Type[...] instance. That's tricky stuff.

For instance, suppose you want use_class to be a subclass of Class4 so you can call a class method on it.

What if we don't need this to be a class? What if it's just a Plain Old Python Object that just looks like one:

class FakeClass(Protocol):
    def __call__(self, value: float | str) -> Class4:
        ...

    def works_like_classmethod(self, woah: Class77) -> str:
        ...

This gets us back to a fairly standard pattern, and one that explicitly lays out what your code will do with this thing.

Related