From 54f22c218bee1703cefa6601f5091c063342f152 Mon Sep 17 00:00:00 2001 From: chuenchen309 <48723787+chuenchen309@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:07:49 +0800 Subject: [PATCH] Don't leak TypeError from Coerce when the type is a non-class callable 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) Signed-off-by: chuenchen309 <48723787+chuenchen309@users.noreply.github.com> --- voluptuous/tests/tests.py | 14 ++++++++++++++ voluptuous/validators.py | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 0608548..b3e6f17 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -1745,6 +1745,20 @@ def test_coerce_in_set_is_applied(): assert Schema({int})({1, 2}) == {1, 2} +def test_coerce_callable_marks_invalid_without_msg(): + # Coerce accepts any callable (not just a class). When such a callable + # raises on bad input, the value must be marked Invalid, as the docstring + # promises -- the Enum-message branch used to call issubclass() on the + # callable and leak a raw TypeError. See the class twin Coerce(int), which + # already behaves this way. + def parse_int(v): + return int(v) + + validate = Schema(Coerce(parse_int)) + with raises(MultipleInvalid, 'expected parse_int'): + validate('foo') + + def test_lower_util_handles_various_inputs(): assert Lower(3) == "3" assert Lower(u"3") == u"3" diff --git a/voluptuous/validators.py b/voluptuous/validators.py index a69bb8a..510ab34 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -145,7 +145,12 @@ def __call__(self, v): return self.type(v) except (ValueError, TypeError, InvalidOperation): msg = self.msg or ('expected %s' % self.type_name) - if not self.msg and Enum and issubclass(self.type, Enum): + if ( + not self.msg + and Enum + and isinstance(self.type, type) + and issubclass(self.type, Enum) + ): msg += " or one of %s" % str([e.value for e in self.type])[1:-1] raise CoerceInvalid(msg)