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
141 changes: 140 additions & 1 deletion malariagen_data/anoph/describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,76 @@ def describe_api(

return df

@doc(
summary="""
Describe the parameters of a public API method.
""",
returns="""
A dataframe with one row per parameter, containing the parameter
name, type, default value, and description.
""",
parameters=dict(
method="""
Name of the public API method to describe.
""",
),
)
def describe_method(self, method: str) -> pd.DataFrame:
method_name = method

# Reject private/dunder names.
if method_name.startswith("_"):
raise ValueError(f"Private method not allowed: {method_name!r}")

# Method must exist on this instance.
if not hasattr(self, method_name):
raise ValueError(f"Unknown method: {method_name!r}")

attr = getattr(type(self), method_name, None)
if attr is None:
raise ValueError(f"Unknown method: {method_name!r}")

# Must be callable and not a property.
if isinstance(attr, property) or not callable(attr):
raise ValueError(f"Method is not callable: {method_name!r}")

sig = inspect.signature(attr)
param_docs = self._extract_parameter_docs(attr)

rows = []
for param_name, param in sig.parameters.items():
if param_name == "self":
continue

annotation = (
None
if param.annotation is inspect.Signature.empty
else str(param.annotation)
)
default = (
None if param.default is inspect.Signature.empty else param.default
)

doc_info = param_docs.get(param_name, {})
doc_type = doc_info.get("type")
description = doc_info.get("description")

param_type = annotation if annotation is not None else doc_type

rows.append(
{
"parameter": param_name,
"type": param_type,
"default": default,
"description": description,
}
)

return pd.DataFrame(
rows,
columns=["parameter", "type", "default", "description"],
)

@staticmethod
def _extract_summary(method) -> str:
"""Extract the first line of the docstring as a summary."""
Expand All @@ -90,6 +160,75 @@ def _extract_summary(method) -> str:
return line
return ""

@staticmethod
def _extract_parameter_docs(method) -> dict:
"""Extract parameter types and descriptions from a NumPy-style docstring."""
docstring = inspect.getdoc(method)
if not docstring:
return {}

lines = docstring.splitlines()
params = {}

# Find "Parameters" section.
i = 0
while i < len(lines):
if lines[i].strip() == "Parameters":
i += 1
# Skip dashed separator line(s).
while i < len(lines) and set(lines[i].strip()) <= {"-"}:
i += 1
break
i += 1
else:
return {}

# Parse entries until next section header.
while i < len(lines):
line = lines[i]

# Stop at a likely next section header.
if (
line.strip()
and not line.startswith(" ")
and ":" not in line
and i + 1 < len(lines)
and set(lines[i + 1].strip()) <= {"-"}
and lines[i + 1].strip()
):
break

# Parameter line pattern: "name : type"
if line.strip() and not line.startswith(" ") and ":" in line:
name_part, type_part = line.split(":", 1)
param_name = name_part.strip()
param_type = type_part.strip()

i += 1
desc_lines = []
while i < len(lines):
next_line = lines[i]
if next_line.startswith(" ") or next_line.startswith("\t"):
desc_lines.append(next_line.strip())
i += 1
elif not next_line.strip():
# preserve paragraph spacing lightly
desc_lines.append("")
i += 1
else:
break

description = " ".join(x for x in desc_lines if x).strip()
params[param_name] = {
"type": param_type if param_type else None,
"description": description if description else None,
}
continue

i += 1

return params

@staticmethod
def _categorize_method(name: str) -> str:
"""Categorize a method based on its name."""
Expand All @@ -112,4 +251,4 @@ def _categorize_method(name: str) -> str:
)
if name.startswith(data_prefixes):
return "data"
return "analysis"
return "analysis"
38 changes: 37 additions & 1 deletion tests/anoph/test_describe.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,42 @@ def test_describe_api_summaries_not_empty(fixture, api):
assert len(non_empty) > 0, "Expected at least some methods to have summaries"


@parametrize_with_cases("fixture,api", cases=".")
def test_describe_method_returns_dataframe_with_expected_columns(fixture, api):
"""Test that describe_method returns a DataFrame with expected columns."""
df = api.describe_method("describe_api")
assert isinstance(df, pd.DataFrame)
assert list(df.columns) == ["parameter", "type", "default", "description"]


@parametrize_with_cases("fixture,api", cases=".")
def test_describe_method_excludes_self(fixture, api):
"""Test that describe_method output does not include self parameter."""
df = api.describe_method("describe_api")
assert "self" not in set(df["parameter"])


@parametrize_with_cases("fixture,api", cases=".")
def test_describe_method_known_parameter_present(fixture, api):
"""Test that a known parameter is present for describe_api."""
df = api.describe_method("describe_api")
assert "category" in set(df["parameter"])


@parametrize_with_cases("fixture,api", cases=".")
def test_describe_method_unknown_method_raises(fixture, api):
"""Test unknown method raises ValueError."""
with pytest.raises(ValueError, match="Unknown method"):
api.describe_method("not_a_real_method")


@parametrize_with_cases("fixture,api", cases=".")
def test_describe_method_private_method_raises(fixture, api):
"""Test private method names are rejected."""
with pytest.raises(ValueError, match="Private method"):
api.describe_method("_categorize_method")


def test_categorize_method():
"""Test the static _categorize_method helper."""
assert AnophelesDescribe._categorize_method("plot_pca") == "plot"
Expand Down Expand Up @@ -124,4 +160,4 @@ def no_doc_func():
pass

summary = AnophelesDescribe._extract_summary(no_doc_func)
assert summary == ""
assert summary == ""