Skip to content

Don't leak TypeError from Coerce when the type is a non-class callable#549

Open
chuenchen309 wants to merge 1 commit into
alecthomas:masterfrom
chuenchen309:fix/coerce-callable-issubclass-leak
Open

Don't leak TypeError from Coerce when the type is a non-class callable#549
chuenchen309 wants to merge 1 commit into
alecthomas:masterfrom
chuenchen309:fix/coerce-callable-issubclass-leak

Conversation

@chuenchen309

Copy link
Copy Markdown

Problem

Coerce accepts any callable (type: Union[type, Callable]), and its docstring says a ValueError/TypeError from the constructor marks the value Invalid. But when the callable is a plain function (not a class), the failure path raises a raw, uncaught TypeError instead:

from voluptuous import Schema, Coerce
import datetime

Schema(Coerce(datetime.datetime.fromisoformat))('not-a-date')
# TypeError: issubclass() arg 1 must be a class    <-- escapes; not an Invalid

def parse_int(v): return int(v)
Schema(Coerce(parse_int))('foo')
# TypeError: issubclass() arg 1 must be a class

Coerce(int)('foo') (a class) correctly yields MultipleInvalid: expected int. The only difference is class-vs-function, so the two are analogous — and functions are explicitly supported by the signature.

Cause

In Coerce.__call__, the except handler calls issubclass(self.type, Enum) to optionally list Enum values in the message. issubclass() requires a class as its first argument, so when self.type is a function it raises TypeError. That call is outside the try (which only wraps self.type(v)), so it propagates raw, bypassing CoerceInvalid, error paths, and humanize.

Note Coerce(func, msg="...") didn't hit this, because not self.msg short-circuits before issubclass — only the default-message path was affected.

Fix

Guard the Enum check with isinstance(self.type, type):

if not self.msg and Enum and isinstance(self.type, type) and issubclass(self.type, Enum):

The class path (including the Enum value-listing message, e.g. expected Color or one of 1, 2) is unchanged; non-class callables now produce MultipleInvalid: expected <name> as documented.

Testing

Added test_coerce_callable_marks_invalid_without_msg (red before, green after). Full suite: 182 passed. black, isort, flake8, mypy all clean.


Disclosure: authored by an AI coding agent (Claude Code) running on this account — it found the bug, ran the repro, wrote the test and this description. I review every change and am accountable for it; the verification above is real and re-runnable from the diff. If this isn't a change you want, say so and I'll close it.

Coerce accepts any callable (`type: Union[type, Callable]`), and its
docstring promises that a ValueError/TypeError from the constructor marks
the value Invalid. But the failure handler called
`issubclass(self.type, Enum)`, which raises `TypeError: issubclass() arg 1
must be a class` when `self.type` is a function -- and that call is outside
the try, so it escaped as a raw TypeError instead of a CoerceInvalid.
So `Schema(Coerce(datetime.fromisoformat))('x')` or any
`Coerce(some_parse_func)` crashed on the invalid-input path rather than
producing MultipleInvalid, bypassing error paths and `humanize`.

Guard the Enum check with `isinstance(self.type, type)`; the class path
(including the Enum value listing) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: chuenchen309 <48723787+chuenchen309@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant