From 548b8d64e83b39ec561b9dc49d9de261fbe31509 Mon Sep 17 00:00:00 2001 From: Caio Rocha Date: Tue, 31 Mar 2026 17:18:20 +0200 Subject: [PATCH] Add describe_method() for structured parameter introspection --- malariagen_data/anoph/describe.py | 121 ++++++++++++++++++++++++++++++ tests/anoph/test_describe.py | 78 +++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/malariagen_data/anoph/describe.py b/malariagen_data/anoph/describe.py index b26d6860b..7614d3f88 100644 --- a/malariagen_data/anoph/describe.py +++ b/malariagen_data/anoph/describe.py @@ -4,6 +4,7 @@ import pandas as pd from numpydoc_decorator import doc # type: ignore +from ..util import _check_types from .base import AnophelesBase @@ -77,6 +78,126 @@ def describe_api( return df + @_check_types + @doc( + summary=""" + Get detailed information about a specific API method, including + its parameters and return type. + """, + parameters=dict( + method_name="Name of a public API method.", + ), + returns=""" + A dataframe with one row per parameter, containing the parameter + name, type, description, and default value. + """, + ) + def describe_method( + self, + method_name: str, + ) -> pd.DataFrame: + attr = getattr(self, method_name, None) + if attr is None or not callable(attr): + raise ValueError( + f"No public method named {method_name!r}. " + f"Use describe_api() to list available methods." + ) + + sig = inspect.signature(attr) + docstring = inspect.getdoc(attr) or "" + param_docs = self._parse_param_docs(docstring) + + params_info = [] + for pname, param in sig.parameters.items(): + ptype = "" + if param.annotation is not inspect.Parameter.empty: + ptype = inspect.formatannotation(param.annotation) + + default = "" + if param.default is not inspect.Parameter.empty: + default = repr(param.default) + + description = param_docs.get(pname, "") + + params_info.append( + { + "parameter": pname, + "type": ptype, + "default": default, + "description": description, + } + ) + + return pd.DataFrame(params_info) + + # Known numpy-style section headers (lowercase, no punctuation). + _SECTION_HEADERS = frozenset( + { + "parameters", + "returns", + "raises", + "notes", + "examples", + "see also", + "warnings", + "references", + "yields", + "receives", + "other parameters", + } + ) + + @classmethod + def _is_section_header(cls, stripped: str) -> bool: + """Check if a line is a numpy-style section header.""" + return stripped.rstrip(": ").lower() in cls._SECTION_HEADERS + + @staticmethod + def _parse_param_docs(docstring: str) -> dict: + """Parse parameter descriptions from a numpy-style docstring.""" + params = {} + lines = docstring.splitlines() + in_params = False + current_param = None + current_desc_lines: list[str] = [] + + for i, line in enumerate(lines): + stripped = line.strip() + + # Detect the Parameters section. + if not in_params and stripped.rstrip(": ").lower() == "parameters": + in_params = True + continue + if in_params and stripped.startswith("---"): + continue + + # Stop at the next section header. + if in_params and AnophelesDescribe._is_section_header(stripped): + if current_param: + params[current_param] = " ".join(current_desc_lines).strip() + in_params = False + continue + + if not in_params: + continue + + # Detect a parameter line (name : type). + if " : " in stripped and not line[0:1].isspace(): + # Save previous param. + if current_param: + params[current_param] = " ".join(current_desc_lines).strip() + # Strip leading * for *args/**kwargs so keys match signature names. + current_param = stripped.split(" : ")[0].strip().lstrip("*") + current_desc_lines = [] + elif current_param and stripped: + current_desc_lines.append(stripped) + + # Save the last parameter (when Parameters is the final section). + if current_param: + params[current_param] = " ".join(current_desc_lines).strip() + + return params + @staticmethod def _extract_summary(method) -> str: """Extract the first line of the docstring as a summary.""" diff --git a/tests/anoph/test_describe.py b/tests/anoph/test_describe.py index e82951c7e..289224bb8 100644 --- a/tests/anoph/test_describe.py +++ b/tests/anoph/test_describe.py @@ -125,3 +125,81 @@ def no_doc_func(): summary = AnophelesDescribe._extract_summary(no_doc_func) assert summary == "" + + +@parametrize_with_cases("fixture,api", cases=".") +def test_describe_method_returns_dataframe(fixture, api): + """Test that describe_method returns a DataFrame with expected columns.""" + df = api.describe_method("describe_api") + assert isinstance(df, pd.DataFrame) + assert "parameter" in df.columns + assert "type" in df.columns + assert "default" in df.columns + assert "description" in df.columns + + +@parametrize_with_cases("fixture,api", cases=".") +def test_describe_method_invalid_name(fixture, api): + """Test that describe_method raises ValueError for unknown methods.""" + with pytest.raises(ValueError, match="No public method"): + api.describe_method("nonexistent_method") + + +@parametrize_with_cases("fixture,api", cases=".") +def test_describe_method_known_method(fixture, api): + """Test that describe_method returns parameters for a known method.""" + df = api.describe_method("sample_sets") + assert isinstance(df, pd.DataFrame) + assert len(df) >= 0 # sample_sets may or may not have params + + +def test_parse_param_docs(): + """Test the static _parse_param_docs helper.""" + docstring = """Do something. + +Parameters +---------- +x : int + The first parameter. +y : str + The second parameter + with a multi-line description. + +Returns +------- +int + A result. +""" + params = AnophelesDescribe._parse_param_docs(docstring) + assert "x" in params + assert "first parameter" in params["x"] + assert "y" in params + assert "multi-line" in params["y"] + + +def test_parse_param_docs_empty(): + """Test _parse_param_docs with no Parameters section.""" + params = AnophelesDescribe._parse_param_docs("Just a summary.") + assert params == {} + + +def test_parse_param_docs_args_kwargs(): + """Test that *args and **kwargs are parsed with keys matching signature names.""" + docstring = """Summary. + +Parameters +---------- +*args : tuple + Positional arguments. +**kwargs : dict + Keyword arguments. + +Returns +------- +None +""" + params = AnophelesDescribe._parse_param_docs(docstring) + assert "args" in params + assert "kwargs" in params + assert "Positional" in params["args"] + assert "Keyword" in params["kwargs"]