Skip to content

Extend PossibleCallMatcher to recognize built-in exception classes #11

@pomponchik

Description

@pomponchik

sigmatch documents that some callables cannot be introspected — that is expected behaviour, not a bug. This issue is a request to widen one specific corner of that limitation: built-in exception classes (ValueError, RuntimeError, Exception, BaseException, KeyError, TypeError, …).

Today PossibleCallMatcher(...).match(cls, raise_exception=False) returns False for every built-in exception class regardless of the requested arity (raise_exception=True raises SignatureNotFoundError), because inspect.signature cannot introspect C-extension types and BaseException's family lives in C. As a result, callers that use sigmatch to validate "the user passed a callable I will call with N positional arguments" cannot accept built-in exceptions — even though calling ValueError('x'), RuntimeError('x', 'y'), and so on is well-defined Python.

This bites at least one downstream library (we hit it ourselves) when accepting user-supplied exception classes via configuration: the matcher rejects the built-ins that users would naturally reach for first. Working around it requires duplicating sigmatch's logic in a wrapper that special-cases SignatureNotFoundError for exception classes.

It would be a nice, well-scoped improvement to teach the matcher about this one specific shape.

What it looks like today

from sigmatch import PossibleCallMatcher

PossibleCallMatcher('.').match(ValueError, raise_exception=False)   # → False
PossibleCallMatcher('..').match(ValueError, raise_exception=False)  # → False

# Meanwhile, all of these are valid Python calls:
ValueError()
ValueError('a')
ValueError('a', 'b')

Suggested behaviour with tests

If the feature is added, these tests describe what we would expect from the new behaviour. They also double as a starting point for the test suite.

import pytest

from sigmatch import PossibleCallMatcher


@pytest.mark.parametrize(
    'exception_class',
    [
        BaseException,
        Exception,
        ValueError,
        RuntimeError,
        TypeError,
        KeyError,
        StopIteration,
    ],
)
@pytest.mark.parametrize('arity', ['', '.', '..', '...'])
def test_match_accepts_every_arity_for_builtin_exception_classes(exception_class, arity):
    """Built-in exception classes accept any number of positional arguments at
    the C level (`BaseException(*args)`), so `PossibleCallMatcher` would ideally
    accept them for every arity check the user might request."""
    assert PossibleCallMatcher(arity).match(exception_class, raise_exception=False) is True


@pytest.mark.parametrize(
    ('exception_class', 'arity'),
    [
        # A custom subclass without __init__ inherits BaseException's variadic signature,
        # so it should behave the same as the built-ins above.
        (type('NoOverride', (BaseException,), {}), '.'),
        (type('NoOverride', (BaseException,), {}), '..'),
        # A custom subclass with a single positional argument matches only '.'.
        (type('OneArg', (BaseException,), {'__init__': lambda self, message: BaseException.__init__(self, message)}), '.'),
    ],
)
def test_match_handles_user_defined_exception_classes(exception_class, arity):
    """User-defined exception classes should behave consistently with the
    built-in ones: introspectable signatures are honoured, non-introspectable
    ones (e.g. inherited from BaseException without an override) are treated
    as variadic. Pairs with the built-in test above to cover exception
    classes as a complete category of inputs."""
    assert PossibleCallMatcher(arity).match(exception_class, raise_exception=False) is True


def test_match_rejects_user_defined_exception_with_clearly_incompatible_signature():
    """An override that requires more positional arguments than the matcher
    is asked about must still be rejected for user-defined exceptions, so the
    new behaviour does not become permissive enough to mask real mistakes."""

    class TwoRequired(BaseException):
        def __init__(self, code, message):
            super().__init__(message)

    assert PossibleCallMatcher('.').match(TwoRequired, raise_exception=False) is False

Possible directions (not prescriptive)

A few sketches that came to mind. None of them is a recommendation — the right choice depends on the library's design and how the maintainer wants to scope the change.

  1. Detect built-in exception classes specifically. Inside match(), if inspect.signature(target) raises and isinstance(target, type) and issubclass(target, BaseException), treat it as variadic and accept any arity. Narrow, easy to reason about, and provably safe because every built-in BaseException subclass accepts *args at the C level (BaseException(), BaseException('a'), BaseException('a', 'b', …) are all valid).

  2. Treat any non-introspectable C-extension type as variadic. If inspect.signature raises ValueError: no signature found for builtin type, accept the call as matching. Broader — covers exceptions plus other builtin classes — but possibly too permissive for callables whose true signature happens to be narrow.

  3. Expose an explicit "trust this target" knob. Keep current behaviour by default but let callers opt into "treat this target as variadic". Trades the silent-correctness improvement for an explicit API surface.

Note on test coverage

When adding the feature, it would be worth covering both built-in exception classes and user-defined BaseException subclasses (with and without an __init__ override). Together they cover the full category "exception classes as callables" in sigmatch's input space, and the matcher's behaviour for the two groups should be consistent: introspectable subclasses respect their declared signature; non-introspectable ones (built-ins, and custom subclasses that simply inherit from BaseException without overriding __init__) match every arity.


Environment

  • sigmatch: 0.0.10
  • Python: 3.14.0
  • OS: macOS (Darwin 24.0.0)

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions