fastmcp/tests/utilities/test_callable_utils.py
William Easton e3f02374c7 Support functools.partial and centralize callable utilities
functools.partial objects failed at registration (@mcp.tool didn't
recognize them) and at call time (update_wrapper set __wrapped__
causing Pydantic to ignore bound arguments).

Introduces centralized utilities replacing scattered patterns across
11 files:

callable_utils.py:
- is_callable_object(): TypeGuard replacing inspect.isroutine() in
  7 decorator entry points — recognizes partials as callables
- get_callable_name(): Extracts useful names from any callable type,
  including partials without update_wrapper
- prepare_callable(): Strips __wrapped__, unwraps callable classes
  and staticmethod — replaces 4 duplicated blocks

decorators.py:
- set_fastmcp_meta(): Attaches __fastmcp__ metadata through __func__
  for bound methods — replaces 5 identical 2-line blocks

TaskConfig:
- normalize(): Converts bool|TaskConfig|None to TaskConfig — replaces
  4 identical 6-line if/elif/else blocks

No behavior changes beyond the bug fix: existing lambda rejection,
validation, and error handling remain per-module policy.

Closes #3266

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 22:46:13 -05:00

121 lines
2.9 KiB
Python

"""Tests for callable utility functions."""
import functools
from fastmcp.utilities.callable_utils import (
get_callable_name,
is_callable_object,
prepare_callable,
)
class TestIsCallableObject:
def test_function(self):
def fn():
pass
assert is_callable_object(fn) is True
def test_async_function(self):
async def fn():
pass
assert is_callable_object(fn) is True
def test_partial(self):
def fn(x, y):
return x + y
assert is_callable_object(functools.partial(fn, y=1)) is True
def test_callable_class(self):
class MyCallable:
def __call__(self):
pass
assert is_callable_object(MyCallable()) is False
def test_string(self):
assert is_callable_object("not a callable") is False
def test_none(self):
assert is_callable_object(None) is False
class TestGetCallableName:
def test_function(self):
def my_function():
pass
assert get_callable_name(my_function) == "my_function"
def test_lambda(self):
assert get_callable_name(lambda: None) == "<lambda>"
def test_partial_with_update_wrapper(self):
def add(x, y):
return x + y
p = functools.partial(add, y=10)
functools.update_wrapper(p, add)
assert get_callable_name(p) == "add"
def test_partial_without_update_wrapper(self):
def add(x, y):
return x + y
p = functools.partial(add, y=10)
assert get_callable_name(p) == "add"
def test_callable_class(self):
class MyTool:
def __call__(self):
pass
assert get_callable_name(MyTool()) == "MyTool"
class TestPrepareCallable:
def test_regular_function_unchanged(self):
def fn(x):
return x
assert prepare_callable(fn) is fn
def test_strips_wrapped_from_partial(self):
def add(x, y):
return x + y
p = functools.partial(add, y=10)
functools.update_wrapper(p, add)
assert hasattr(p, "__wrapped__")
prepared = prepare_callable(p)
assert isinstance(prepared, functools.partial)
assert not hasattr(prepared, "__wrapped__")
assert prepared.keywords == {"y": 10}
def test_partial_without_wrapper_unchanged(self):
def add(x, y):
return x + y
p = functools.partial(add, y=10)
prepared = prepare_callable(p)
assert isinstance(prepared, functools.partial)
assert prepared.func is add
def test_callable_class_unwrapped(self):
class MyCallable:
def __call__(self, x):
return x
obj = MyCallable()
prepared = prepare_callable(obj)
assert prepared == obj.__call__
def test_staticmethod_unwrapped(self):
def fn(x):
return x
sm = staticmethod(fn)
assert prepare_callable(sm) is fn