Skip to content

Commit 686b04b

Browse files
fix: correctness/quality nits roundup + lint (#541)
Fixes #541. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 333ff33 commit 686b04b

9 files changed

Lines changed: 144 additions & 14 deletions

File tree

src/sktime_mcp/data/adapters/pandas_adapter.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,16 @@ def validate(self, data: pd.DataFrame) -> tuple[bool, dict[str, Any]]:
199199
target_col = self.config.get("target_column")
200200
if target_col is None and len(data.columns) > 0:
201201
target_col = data.columns[0]
202-
if target_col is not None and target_col in data.columns:
203-
if not pd.api.types.is_numeric_dtype(data[target_col]):
204-
warnings.append(
205-
f"Target column '{target_col}' has non-numeric dtype "
206-
f"'{data[target_col].dtype}'. Forecasting requires numeric values; "
207-
"convert the column before fitting."
208-
)
202+
if (
203+
target_col is not None
204+
and target_col in data.columns
205+
and not pd.api.types.is_numeric_dtype(data[target_col])
206+
):
207+
warnings.append(
208+
f"Target column '{target_col}' has non-numeric dtype "
209+
f"'{data[target_col].dtype}'. Forecasting requires numeric values; "
210+
"convert the column before fitting."
211+
)
209212

210213
# Check for constant values
211214
for col in data.columns:

src/sktime_mcp/runtime/executor.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -638,9 +638,13 @@ def predict(
638638

639639
out = {
640640
"success": True,
641-
"horizon": len(fh) if hasattr(fh, "__len__") else fh,
642641
"mode": mode,
643642
}
643+
# horizon is only meaningful for forecasters; echoing it for
644+
# classifiers/regressors/transformers implied a truncation that
645+
# didn't happen (N-01).
646+
if not (is_classifier_or_regressor or is_transformer):
647+
out["horizon"] = len(fh) if hasattr(fh, "__len__") else fh
644648
if mode == "predict":
645649
out["predictions"] = result
646650
elif mode == "predict_interval":
@@ -1392,7 +1396,9 @@ def format_data_handle(
13921396
X = X[~X.index.duplicated(keep="first")]
13931397
changes_made["duplicates_removed"] = n_duplicates
13941398

1395-
# 2. Sort by index
1399+
# 2. Sort by index (report it, like the other repairs — NB-08)
1400+
if not y.index.is_monotonic_increasing:
1401+
changes_made["sorted"] = True
13961402
y = y.sort_index()
13971403
if X is not None:
13981404
X = X.sort_index()

src/sktime_mcp/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,7 @@ async def list_tools() -> list[Tool]:
801801
"type": "integer",
802802
"description": "Resolution in dots per inch (default: 150).",
803803
"default": 150,
804+
"minimum": 1,
804805
},
805806
"markers": {
806807
"description": "Marker style(s) for data points (e.g., 'o', ['.', 'x']).",

src/sktime_mcp/tools/inspect_data.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,16 @@ def inspect_data_tool(data_handle: str) -> dict[str, Any]:
8787
if X is not None and isinstance(X, pd.DataFrame):
8888
columns = columns + [f"X:{c}" for c in X.columns]
8989

90-
# --- dtypes ---
90+
# --- dtypes (include exogenous columns, which `columns` already lists — N-14) ---
9191
if isinstance(y, pd.DataFrame):
9292
dtypes = {str(col): str(dtype) for col, dtype in y.dtypes.items()}
9393
elif isinstance(y, pd.Series):
9494
dtypes = {y.name if y.name else "target": str(y.dtype)}
9595
else:
9696
dtypes = {}
97+
if X is not None and isinstance(X, pd.DataFrame):
98+
for col, dtype in X.dtypes.items():
99+
dtypes[f"X:{col}"] = str(dtype)
97100

98101
# --- index names ---
99102
if hasattr(y, "index") and hasattr(y.index, "names"):

src/sktime_mcp/tools/instantiate.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@ def release_handle_tool(handle: str) -> dict[str, Any]:
5656
"""
5757
handle_manager = get_handle_manager()
5858
released = handle_manager.release_handle(handle)
59+
if released:
60+
return {"success": True, "handle": handle, "message": "Handle released"}
61+
# Failure carries an "error" key like every other tool (NB-02).
5962
return {
60-
"success": released,
63+
"success": False,
6164
"handle": handle,
62-
"message": "Handle released" if released else handle_manager.describe_missing(handle),
65+
"error": handle_manager.describe_missing(handle),
6366
}
6467

6568

src/sktime_mcp/tools/plotting.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ def plot_series_tool(
166166
"error": "data_handles must contain at least one data handle ID.",
167167
}
168168

169+
# dpi=0 previously slipped through (falsy -> default 150) while dpi=-100
170+
# errored — reject all non-positive dpi consistently (N-20).
171+
if dpi is not None and dpi <= 0:
172+
return {
173+
"success": False,
174+
"error": f"dpi must be a positive integer, got {dpi}.",
175+
}
176+
169177
executor = get_executor()
170178

171179
# --- resolve data handles -----------------------------------------------

src/sktime_mcp/tools/save_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
Saves estimator instances via sktime's MLflow integration.
55
"""
66

7-
import os
87
from collections.abc import Callable
8+
from pathlib import Path
99
from typing import Any
1010

1111
from sktime_mcp.runtime.handles import get_handle_manager
@@ -24,7 +24,7 @@ def resolve_model_path(path: str) -> str:
2424
"""
2525
if path.startswith(_MLFLOW_URI_PREFIXES) or "://" in path:
2626
return path
27-
return os.path.abspath(os.path.expanduser(path))
27+
return str(Path(path).expanduser().resolve())
2828

2929

3030
def _get_mlflow_save_model() -> Callable[..., Any]:

src/sktime_mcp/tools/transform_data.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ def _action_format(
132132
changes_applied: list[str] = []
133133
changes = result.get("changes_made", {})
134134

135+
if changes.get("sorted"):
136+
changes_applied.append("Sorted rows by time index")
135137
if changes.get("duplicates_removed", 0) > 0:
136138
changes_applied.append(f"Removed {changes['duplicates_removed']} duplicate timestamps")
137139
if changes.get("frequency_set"):

tests/test_nits_roundup.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Correctness/quality nits roundup (#541)."""
2+
3+
import contextlib
4+
5+
import pandas as pd
6+
import pytest
7+
8+
from sktime_mcp.runtime.executor import get_executor
9+
from sktime_mcp.runtime.handles import get_handle_manager
10+
from sktime_mcp.tools.fit_predict import fit_tool, predict_tool
11+
from sktime_mcp.tools.inspect_data import inspect_data_tool
12+
from sktime_mcp.tools.instantiate import instantiate_tool, release_handle_tool
13+
from sktime_mcp.tools.plotting import plot_series_tool
14+
15+
16+
def _release(h):
17+
with contextlib.suppress(KeyError):
18+
get_handle_manager().release_handle(h)
19+
20+
21+
def test_release_handle_failure_has_error_key():
22+
# NB-02: failure must carry an "error" key like other tools
23+
res = release_handle_tool("est_never_existed")
24+
assert res["success"] is False
25+
assert "error" in res
26+
assert "not found" in res["error"].lower()
27+
28+
29+
def test_classifier_predict_omits_horizon():
30+
# N-01: horizon is meaningless for classifiers
31+
h = instantiate_tool(spec="KNeighborsTimeSeriesClassifier()")["handle"]
32+
try:
33+
fit_tool(estimator_handle=h, X_dataset="arrow_head", y_dataset="arrow_head")
34+
res = predict_tool(estimator_handle=h, X_dataset="arrow_head")
35+
assert res["success"], res
36+
assert "horizon" not in res
37+
finally:
38+
_release(h)
39+
40+
41+
def test_forecaster_predict_keeps_horizon():
42+
h = instantiate_tool(spec="NaiveForecaster(sp=12)")["handle"]
43+
try:
44+
fit_tool(estimator_handle=h, y_dataset="airline")
45+
res = predict_tool(estimator_handle=h, horizon=6)
46+
assert res["horizon"] == 6
47+
finally:
48+
_release(h)
49+
50+
51+
def test_format_reports_sorting():
52+
# NB-08: sorting is surfaced in changes
53+
ex = get_executor()
54+
ex._data_handles["nit_unsorted"] = {
55+
"y": pd.Series(
56+
[3.0, 1.0, 2.0],
57+
index=pd.to_datetime(["2024-03-01", "2024-01-01", "2024-02-01"]),
58+
),
59+
"X": None,
60+
"metadata": {"frequency": None},
61+
"validation": {},
62+
"config": {},
63+
}
64+
try:
65+
res = ex.format_data_handle("nit_unsorted", release_original=False)
66+
assert res["success"]
67+
assert res["changes_made"].get("sorted") is True
68+
finally:
69+
for h in list(ex._data_handles):
70+
if h == "nit_unsorted" or h == res.get("data_handle"):
71+
ex._data_handles.pop(h, None)
72+
73+
74+
def test_inspect_includes_exog_dtypes():
75+
# N-14: dtypes must include exog columns that `columns` lists
76+
ex = get_executor()
77+
idx = pd.period_range("2020-01", periods=12, freq="M")
78+
ex._data_handles["nit_exog"] = {
79+
"y": pd.Series(range(12), index=idx, name="target", dtype=float),
80+
"X": pd.DataFrame({"temp": range(12)}, index=idx),
81+
"metadata": {},
82+
"validation": {},
83+
"config": {},
84+
}
85+
try:
86+
res = inspect_data_tool(data_handle="nit_exog")
87+
assert res["success"], res
88+
assert "X:temp" in res["dtypes"]
89+
assert "X:temp" in res["columns"]
90+
finally:
91+
ex._data_handles.pop("nit_exog", None)
92+
93+
94+
def test_plot_rejects_nonpositive_dpi():
95+
# N-20: dpi=0 must be rejected, not silently defaulted
96+
ex = get_executor()
97+
idx = pd.period_range("2020-01", periods=12, freq="M")
98+
ex._data_handles["nit_plot"] = {"y": pd.Series(range(12), index=idx)}
99+
try:
100+
res = plot_series_tool(data_handles=["nit_plot"], dpi=0)
101+
assert not res["success"]
102+
assert "dpi" in res["error"].lower()
103+
finally:
104+
ex._data_handles.pop("nit_plot", None)

0 commit comments

Comments
 (0)