Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions malariagen_data/anoph/describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pandas as pd
from numpydoc_decorator import doc # type: ignore

from ..util import _check_types
from .base import AnophelesBase


Expand Down Expand Up @@ -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."""
Expand Down
78 changes: 78 additions & 0 deletions tests/anoph/test_describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]