"""Tests for docstring-to-schema parameter description extraction.""" from typing import Annotated from pydantic import Field from fastmcp.tools.function_parsing import ParsedFunction from fastmcp.utilities.docstring_parsing import parse_docstring class TestGoogleStyle: """Google-style docstrings (Args:/Arguments:).""" def test_basic(self): def fn(a: float, b: float) -> float: """Add two numbers. Args: a: The first number. b: The second number. """ return a + b parsed = parse_docstring(fn) assert parsed.description == "Add two numbers." assert parsed.parameters == { "a": "The first number.", "b": "The second number.", } def test_with_inline_types(self): def fn(a: float, b: str) -> float: """Do something. Args: a (float): The number. b (str, optional): The string. """ return a parsed = parse_docstring(fn) assert parsed.description == "Do something." assert parsed.parameters == {"a": "The number.", "b": "The string."} def test_returns_section_excluded(self): def fn(a: float) -> float: """Summary. Args: a: The input. Returns: The output. """ return a parsed = parse_docstring(fn) assert parsed.description == "Summary." assert parsed.parameters == {"a": "The input."} def test_raises_section_excluded(self): def fn(a: float) -> float: """Summary. Args: a: The input. Raises: ValueError: If negative. """ return a parsed = parse_docstring(fn) assert parsed.description == "Summary." assert parsed.parameters == {"a": "The input."} def test_example_section_excluded(self): def fn(a: str) -> str: """Run some code. Example: >>> fn("hello") 'hello' Args: a: The input. """ return a parsed = parse_docstring(fn) assert parsed.description == "Run some code." assert parsed.parameters == {"a": "The input."} def test_multiline_param_description(self): def fn(a: float) -> float: """Summary. Args: a: A description that spans multiple lines. """ return a parsed = parse_docstring(fn) assert parsed.description == "Summary." assert "spans multiple lines" in parsed.parameters["a"] class TestNumpyStyle: """NumPy-style docstrings (Parameters\\n----------).""" def test_basic(self): def fn(x: int, y: int) -> int: """Multiply. Parameters ---------- x The first integer. y The second integer. """ return x * y parsed = parse_docstring(fn) assert parsed.description == "Multiply." assert parsed.parameters == { "x": "The first integer.", "y": "The second integer.", } def test_with_types(self): def fn(a: float, b: str) -> float: """Do something. Parameters ---------- a : float The number. b : str The string. """ return a parsed = parse_docstring(fn) assert parsed.description == "Do something." assert parsed.parameters == {"a": "The number.", "b": "The string."} class TestSphinxStyle: """Sphinx-style docstrings (:param name:).""" def test_basic(self): def fn(name: str, age: int) -> str: """Format a greeting. :param name: The person's name. :param age: The person's age. """ return f"{name} is {age}" parsed = parse_docstring(fn) assert parsed.description == "Format a greeting." assert parsed.parameters == { "name": "The person's name.", "age": "The person's age.", } def test_with_type_directive(self): def fn(a: float, b: str) -> float: """Summary. :param a: The number. :type a: float :param b: The string. :type b: str """ return a parsed = parse_docstring(fn) assert parsed.description == "Summary." assert parsed.parameters == {"a": "The number.", "b": "The string."} class TestEdgeCases: """Unusual, malformed, or partially-correct docstrings.""" def test_no_docstring(self): def fn(a: int) -> int: return a parsed = parse_docstring(fn) assert parsed.description is None assert parsed.parameters == {} def test_summary_only(self): def fn(a: int) -> int: """Just a summary.""" return a parsed = parse_docstring(fn) assert parsed.description == "Just a summary." assert parsed.parameters == {} def test_multi_paragraph_description(self): def fn(a: float) -> float: """Summary line. More detailed explanation here spanning multiple lines. Another paragraph. Args: a: The number. """ return a parsed = parse_docstring(fn) # Full description (summary + body) should be preserved assert parsed.description is not None assert "Summary line." in parsed.description assert "More detailed explanation" in parsed.description assert "Another paragraph." in parsed.description # Args section should not bleed into description assert "The number" not in parsed.description assert parsed.parameters == {"a": "The number."} def test_multiline_summary(self): def fn(a: float) -> float: """Multi-line summary continues on next line. Args: a: The number. """ return a parsed = parse_docstring(fn) assert parsed.description is not None assert "Multi-line summary" in parsed.description assert "continues on next line" in parsed.description assert parsed.parameters == {"a": "The number."} def test_missing_colon_after_args_keyword(self): """Malformed: 'Args' without colon is not a valid section.""" def fn(a: float) -> float: """Summary. Args a: Maybe the number? """ return a parsed = parse_docstring(fn) # Parser shouldn't pick this up as an Args section assert parsed.parameters == {} def test_empty_args_section(self): def fn(a: float) -> float: """Summary. Args: """ return a parsed = parse_docstring(fn) assert parsed.parameters == {} def test_param_name_not_in_function_signature(self): """Docstring documents a param that doesn't exist on the function.""" def fn(a: float) -> float: """Summary. Args: nonexistent: Wrong param name. """ return a parsed = parse_docstring(fn) # parse_docstring returns whatever the docstring says — # filtering happens at the schema injection level assert parsed.parameters == {"nonexistent": "Wrong param name."} def test_async_function(self): async def fn(a: float) -> float: """Async summary. Args: a: The number. """ return a parsed = parse_docstring(fn) assert parsed.description == "Async summary." assert parsed.parameters == {"a": "The number."} class TestParsedFunctionIntegration: """Tests for docstring flowing through ParsedFunction.from_function.""" def test_description_is_summary_only(self): def fn(a: float) -> float: """The summary line. Args: a: Some param. Returns: Something. """ return a p = ParsedFunction.from_function(fn) assert p.description == "The summary line." def test_param_descriptions_in_schema(self): def fn(a: float, b: str) -> str: """Do something. Args: a: The number. b: The string. """ return str(a) + b p = ParsedFunction.from_function(fn) assert p.input_schema["properties"]["a"]["description"] == "The number." assert p.input_schema["properties"]["b"]["description"] == "The string." def test_numpy_style_integration(self): def fn(a: float, b: str) -> str: """Summary. Parameters ---------- a : float The number. b : str The string. """ return str(a) + b p = ParsedFunction.from_function(fn) assert p.description == "Summary." assert p.input_schema["properties"]["a"]["description"] == "The number." assert p.input_schema["properties"]["b"]["description"] == "The string." def test_sphinx_style_integration(self): def fn(a: float, b: str) -> str: """Summary. :param a: The number. :param b: The string. """ return str(a) + b p = ParsedFunction.from_function(fn) assert p.description == "Summary." assert p.input_schema["properties"]["a"]["description"] == "The number." assert p.input_schema["properties"]["b"]["description"] == "The string." def test_field_description_takes_precedence(self): def fn( a: Annotated[float, Field(description="From Field")], b: float, ) -> float: """Add. Args: a: From docstring. b: Also from docstring. """ return a + b p = ParsedFunction.from_function(fn) assert p.input_schema["properties"]["a"]["description"] == "From Field" assert ( p.input_schema["properties"]["b"]["description"] == "Also from docstring." ) def test_annotated_string_takes_precedence(self): def fn( a: Annotated[float, "From annotation"], b: float, ) -> float: """Add. Args: a: From docstring. b: Also from docstring. """ return a + b p = ParsedFunction.from_function(fn) assert p.input_schema["properties"]["a"]["description"] == "From annotation" assert ( p.input_schema["properties"]["b"]["description"] == "Also from docstring." ) def test_no_docstring_no_descriptions(self): def fn(a: float) -> float: return a p = ParsedFunction.from_function(fn) assert p.description is None assert "description" not in p.input_schema["properties"]["a"] def test_docstring_without_args_section(self): def fn(a: float) -> float: """Just a summary.""" return a p = ParsedFunction.from_function(fn) assert p.description == "Just a summary." assert "description" not in p.input_schema["properties"]["a"] def test_partial_params_documented(self): """Only some params documented — others remain undescribed.""" def fn(a: float, b: float, c: float) -> float: """Add numbers. Args: a: Documented. """ return a + b + c p = ParsedFunction.from_function(fn) assert p.input_schema["properties"]["a"]["description"] == "Documented." assert "description" not in p.input_schema["properties"]["b"] assert "description" not in p.input_schema["properties"]["c"] def test_nonexistent_param_in_docstring_ignored(self): """Docstring mentions a param that doesn't exist — silently skipped.""" def fn(a: float) -> float: """Summary. Args: a: The real one. ghost: Doesn't exist. """ return a p = ParsedFunction.from_function(fn) assert p.input_schema["properties"]["a"]["description"] == "The real one." # No crash, no ghost in properties assert "ghost" not in p.input_schema["properties"] def test_types_in_docstring_dont_override_schema_types(self): """A '(str)' in the docstring must not change the schema's type.""" def fn(a: float) -> float: """Summary. Args: a (str): A description, but the type is wrong. """ return a p = ParsedFunction.from_function(fn) # Schema type comes from the annotation, not the docstring assert p.input_schema["properties"]["a"]["type"] == "number" assert ( p.input_schema["properties"]["a"]["description"] == "A description, but the type is wrong." ) def test_multi_paragraph_description_preserved(self): def fn(a: float) -> float: """Short summary. A longer explanation that provides additional context. Args: a: The number. """ return a p = ParsedFunction.from_function(fn) assert p.description is not None assert "Short summary" in p.description assert "longer explanation" in p.description assert "The number" not in p.description def test_async_function_integration(self): async def fn(a: float) -> float: """Async summary. Args: a: The number. """ return a p = ParsedFunction.from_function(fn) assert p.description == "Async summary." assert p.input_schema["properties"]["a"]["description"] == "The number." def test_callable_class_sources_description_from_class(self): """Class docstring drives the tool description (it describes what the tool IS), while __call__'s Args section drives parameter descriptions (its params are what the schema actually exposes).""" class MyTool: """Class-level description.""" def __call__(self, x: int) -> int: """Internal call doc. Args: x: From call. """ return x p = ParsedFunction.from_function(MyTool()) # Class docstring wins for the description assert p.description == "Class-level description." # __call__'s Args wins for the parameter description assert p.input_schema["properties"]["x"]["description"] == "From call." def test_callable_class_does_not_inherit_class_param_descriptions(self): """The class docstring's Args section typically describes __init__. Even when param names overlap with __call__, those descriptions must not leak into __call__'s parameter schema.""" class MyTool: """Describes what the tool does. Args: x: Constructor argument (should NOT appear on __call__'s x). """ def __init__(self, x: str) -> None: self.x = x def __call__(self, x: int) -> int: return x p = ParsedFunction.from_function(MyTool("config")) assert p.description == "Describes what the tool does." # x's description does NOT come from the class's constructor-focused Args assert "description" not in p.input_schema["properties"]["x"] def test_callable_class_falls_back_to_call_description(self): """If the class has no docstring, fall back to __call__'s description.""" class MyTool: def __call__(self, x: int) -> int: """Call-level description. Args: x: From call. """ return x p = ParsedFunction.from_function(MyTool()) assert p.description == "Call-level description." assert p.input_schema["properties"]["x"]["description"] == "From call."