From 617c0c59a27747204d4b17a52eafbfd3a262ce12 Mon Sep 17 00:00:00 2001 From: anushkaxg Date: Tue, 31 Mar 2026 23:59:38 +0530 Subject: [PATCH] Add describe_method for API parameter introspection --- malariagen_data/anoph/describe.py | 141 +++++++++++++++++++++++++++++- tests/anoph/test_describe.py | 38 +++++++- 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/malariagen_data/anoph/describe.py b/malariagen_data/anoph/describe.py index b26d6860b..0c23c7c52 100644 --- a/malariagen_data/anoph/describe.py +++ b/malariagen_data/anoph/describe.py @@ -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.""" @@ -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.""" @@ -112,4 +251,4 @@ def _categorize_method(name: str) -> str: ) if name.startswith(data_prefixes): return "data" - return "analysis" + return "analysis" \ No newline at end of file diff --git a/tests/anoph/test_describe.py b/tests/anoph/test_describe.py index e82951c7e..a2162ebfc 100644 --- a/tests/anoph/test_describe.py +++ b/tests/anoph/test_describe.py @@ -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" @@ -124,4 +160,4 @@ def no_doc_func(): pass summary = AnophelesDescribe._extract_summary(no_doc_func) - assert summary == "" + assert summary == "" \ No newline at end of file