Allow matching `callable()` in match-case statements

Currently, there is no way to match an object in a match-case statement if it is callable. This leads to ugly remainder-cases:

match thing:
    case SomeValueType():
        ...
    case type() as constructor:
        ...
    case other:
        if callable(other):
            ...

This would be much nicer if we could match directly on callable (or at least typing.Callable):

match thing:
    case callable() as function:
        ...

This could theoretically also be extended to allow for more concise signature inspection via matching, but this is a much broader and more complex topic, so I will leave it out for now.

Have you tried a guard?

match thing:
    case SomeValueType():
        ...
    case type() as constructor:
        ...
    case func if callable(func):
        ...
    case other:
        ...
5 Likes

Thank you, I was not aware of this syntax. Nevertheless, something like this might be useful, which seems like it would first require adding callable to be matched directly:

match thing:
    case callable(a, b, *_, **_):
        ...

or even

match thing:
    case callable(num_args=2, kwargs=["a", "b"]):
        ...

Do you have some non-toy use cases for this?

2 Likes

I thought I’ve had success in the past using collections.abc.Callable

2 Likes

This is completely new syntax, incompatible with the current semantics of python pattern matching (since a and b can’t be bound to anything here). If you really want to pursue something like this [1], you are going to need a lot of evidence that this is actually useful and an at least somewhat common problem.


  1. Not this exact syntax, something slightly different. I don’t have good ideas tbh ↩︎

I’m aware that this exact syntax won’t work, I was simply hinting at exemplary usage. I am currently unsure of good examples for syntax, but on the surface, it seems achievable to me.

As for non-toy use cases, consider this example from a code base I am working on (now with the guard as suggested):

def find_distribution(distribution: str | type | callable, **kwargs):
    match distribution:
        case str() as name:
            match name.lower():
                case "normal":
                    from bayesflow.experimental.distributions import DiagonalNormal
                    distribution = DiagonalNormal(**kwargs)
                case other:
                    raise ValueError(f"Unsupported distribution name: '{other}'.")
        case type() as constructor:
            distribution = constructor(**kwargs)
        case builder_fn if callable(builder_fn):
            distribution = builder_fn(**kwargs)
        case other:
            raise TypeError(f"Cannot infer distribution from {other!r}.")

    return distribution

What you’re proposing is incompatible with the current semantics of pattern matching, where callable is treated as a class that thing has to be an instance of, and a and b have to be attributes that thing has to have.

To match the signature of a callable you can instead do something like:

from inspect import signature

def thing(a, b, *_, **__):
    ...

assert signature(thing) == signature(lambda a, b, *_, **__: ...)

That is something I would implement as

def find_distribution(distribution: str | Callable[..., Any], **kwargs):
    if isinstance(distribution, str):
        # I wouldn't even write this as a match-case if there were more things, it should be a dictionary.
        if distribuation.lower() == 'normal':
            from bayesflow.experimental.distributions import DiagonalNormal
            distribution = DiagonalNormal(**kwargs)
        else:
            raise ValueError(f"Unsupported distribution name: '{distribution}'")
    else:
        distribution  = distribution(**kwargs)
    return distrubtion

I disagree. Nested if-else statements are much harder to read and maintain than a single match.

1 Like

… You wrote a nested match-case, not me. And I didn’t suggest expanding the inner if statement, I suggested replacing it with a single dictonary lookup. I just copied your code. And that part is completely irrelevant to the point that this is a bad example of a situation where you would try to match a callable.

Sorry if my example is not up to your standards. However, I believe that simply saying “just don’t write things like this” is not a constructive response to a feature request.

No, but you were asked to provide a reasonable example of where this would be useful (especially the extra point of being able to match a signature). I am pointing out that you didn’t do that, because doing the same thing is easier in a different way.

In that case, my answer is that I do not have access to a better non-toy example. I have been playing around with signature if-elses before and remember that the resulting code was horrid (which is probably why I scrapped it to begin with). Perhaps such applications could arise in a more neat way if the syntax was there to begin with.

The two key things you’ll need to do if you want to continue arguing for this feature are:

  1. Propose a syntax that does work and is compatible with the existing syntax. No-one else has come up with a working syntax, so it’s likely to be down to you to do so.
  2. Come up with some more compelling examples of where this would be useful. You clearly think it would be worthwhile for you, but the examples you’ve given so far haven’t convinced others yet. So if you want support for your proposal you’re going to need to do some more work collecting better examples.
2 Likes

This looks like a good use case for functools.singledispatch:

from functools import singledispatch
from collections.abc import Callable

@singledispatch
def find_distribution(distribution, **kwargs):
    return f"Cannot infer distribution from {distribution!r}."

@find_distribution.register
def _(distribution: str, **kwargs):
    return {'name': distribution.lower(), **kwargs}

@find_distribution.register
def _(distribution: type, **kwargs):
    return distribution(name=f'type {distribution.__name__}', **kwargs)

@find_distribution.register
def _(distribution: Callable, **kwargs):
    return distribution(name=f'callable {distribution.__name__}', **kwargs)

print(find_distribution('Foo', a=1)) # {'name': 'foo', 'a': 1}
print(find_distribution(dict, a=1)) # {'name': 'type dict', 'a': 1}
print(find_distribution(lambda **_: _, a=1)) # {'name': 'callable <lambda>', 'a': 1}
print(find_distribution(1, a=1)) # Cannot infer distribution from 1.
3 Likes

Nice solution! I haven’t used singledispatch before.

@larskue Here’s the link to the Python Docs for Single Dispatch. Does this solve your use case?

As @Melendowski said, isn’t this exact need completely solved by just doing case collections.abc.Callable()? That seems better than the other methods suggested, and is similar to your originally requested new syntax.

That looks a lot less readable to me (although that could be because I am not used to using singledispatch).

2 Likes