Skip to content

Commit 59ce007

Browse files
authored
Add import_mapping tool (#26)
1 parent f64af82 commit 59ce007

File tree

8 files changed

+381
-19
lines changed

8 files changed

+381
-19
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Currently available:
1616
- Version metadata (MCP tool/library versions) via the `info` tool
1717
- Package info tarball data via the `package_insights` tool
1818
- Package search via the `package_search` tool
19+
- Import to package heuristic mapping via the `import_mapping` tool
1920
- CLI help (for conda) via the `cli_help` tool
2021

2122
Planned:

conda_meta_mcp/tools/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
from .cli_help import register_cli_help
2+
from .import_mapping import register_import_mapping
23
from .info import register_info
34
from .pkg_insights import register_package_insights
45
from .pkg_search import register_package_search
56

6-
TOOLS = [register_cli_help, register_info, register_package_insights, register_package_search]
7+
TOOLS = [
8+
register_cli_help,
9+
register_info,
10+
register_package_insights,
11+
register_package_search,
12+
register_import_mapping,
13+
]
714

815
__all__ = ["TOOLS"]
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
import_mapping tool
3+
4+
This tool is based on (and wraps) logic from:
5+
`conda_forge_metadata.autotick_bot.import_to_pkg`
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
from functools import lru_cache
12+
from typing import TYPE_CHECKING
13+
14+
from fastmcp.exceptions import ToolError
15+
16+
if TYPE_CHECKING:
17+
from fastmcp import FastMCP
18+
19+
20+
from conda_forge_metadata.autotick_bot.import_to_pkg import (
21+
get_pkgs_for_import,
22+
map_import_to_package,
23+
)
24+
25+
26+
@lru_cache(maxsize=1024)
27+
def _map_import(import_name: str) -> dict:
28+
if not import_name or not import_name.strip():
29+
raise ValueError("import_name must be a non-empty string")
30+
31+
query = import_name.strip()
32+
33+
# Underlying function truncates to top-level automatically.
34+
candidates, normalized = get_pkgs_for_import(query)
35+
36+
if candidates is None or len(candidates) == 0:
37+
# No mapping known; identity fallback.
38+
return {
39+
"query_import": query,
40+
"normalized_import": normalized,
41+
"best_package": normalized,
42+
"candidate_packages": [],
43+
"heuristic": "identity",
44+
}
45+
46+
best = map_import_to_package(query)
47+
48+
if best == normalized and best in candidates:
49+
heuristic = "identity_present"
50+
elif best in candidates:
51+
heuristic = "ranked_selection"
52+
else:
53+
heuristic = "fallback"
54+
55+
return {
56+
"query_import": query,
57+
"normalized_import": normalized,
58+
"best_package": best,
59+
"candidate_packages": sorted(candidates),
60+
"heuristic": heuristic,
61+
}
62+
63+
64+
def register_import_mapping(mcp: FastMCP) -> None:
65+
@mcp.tool
66+
async def import_mapping(import_name: str) -> dict:
67+
"""
68+
Map a (possibly dotted) Python import name to the most likely conda package
69+
and expose supporting context.
70+
71+
What this does:
72+
- Normalizes the import to its top-level module (e.g. "numpy.linalg" -> "numpy")
73+
- Retrieves an approximate candidate set of conda packages that may provide it
74+
- Applies a heuristic to pick a single "best" package
75+
- Returns a structured result with the decision rationale
76+
77+
Heuristic labels:
78+
- identity: No candidates known; fallback to normalized import
79+
- identity_present: Candidates exist AND the normalized import name is among them
80+
- ranked_selection: Best package chosen via ranked hubs authorities ordering
81+
- fallback: Best package not in candidates (unexpected edge case)
82+
83+
Returned dict schema:
84+
{
85+
"query_import": original query string supplied by caller
86+
"normalized_import": top-level portion used for lookup
87+
"best_package": chosen conda package name (may equal normalized_import)
88+
"candidate_packages": sorted list of possible supplying packages (may be empty)
89+
"heuristic": one of the heuristic labels above
90+
}
91+
92+
Args:
93+
import_name:
94+
Import string, e.g. "yaml", "matplotlib.pyplot", "sklearn.model_selection".
95+
"""
96+
try:
97+
return await asyncio.to_thread(_map_import, import_name)
98+
except ValueError as ve:
99+
raise ToolError(f"'import_mapping' invalid input: {ve}") from ve
100+
except Exception as e: # pragma: no cover - generic protection
101+
raise ToolError(f"'import_mapping' failed: {e}") from e

pixi.lock

Lines changed: 177 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ source = "vcs"
3838
[tool.pixi.dependencies]
3939
argparse-manpage = ">=4.7"
4040
conda = ">=25.7.0"
41+
conda-forge-metadata = "*"
4142
conda-package-streaming = ">=0.12.0"
4243
fastmcp = ">=2.11.3"
4344
libmambapy = ">=2.3.1"

server-info.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,33 @@
212212
"tags": []
213213
}
214214
}
215+
},
216+
{
217+
"name": "import_mapping",
218+
"title": null,
219+
"description": "Map a (possibly dotted) Python import name to the most likely conda package\nand expose supporting context.\n\nWhat this does:\n - Normalizes the import to its top-level module (e.g. \"numpy.linalg\" -> \"numpy\")\n - Retrieves an approximate candidate set of conda packages that may provide it\n - Applies a heuristic to pick a single \"best\" package\n - Returns a structured result with the decision rationale\n\nHeuristic labels:\n - identity: No candidates known; fallback to normalized import\n - identity_present: Candidates exist AND the normalized import name is among them\n - ranked_selection: Best package chosen via ranked hubs authorities ordering\n - fallback: Best package not in candidates (unexpected edge case)\n\nReturned dict schema:\n {\n \"query_import\": original query string supplied by caller\n \"normalized_import\": top-level portion used for lookup\n \"best_package\": chosen conda package name (may equal normalized_import)\n \"candidate_packages\": sorted list of possible supplying packages (may be empty)\n \"heuristic\": one of the heuristic labels above\n }\n\nArgs:\n import_name:\n Import string, e.g. \"yaml\", \"matplotlib.pyplot\", \"sklearn.model_selection\".",
220+
"inputSchema": {
221+
"properties": {
222+
"import_name": {
223+
"title": "Import Name",
224+
"type": "string"
225+
}
226+
},
227+
"required": [
228+
"import_name"
229+
],
230+
"type": "object"
231+
},
232+
"outputSchema": {
233+
"additionalProperties": true,
234+
"type": "object"
235+
},
236+
"annotations": null,
237+
"_meta": {
238+
"_fastmcp": {
239+
"tags": []
240+
}
241+
}
215242
}
216243
],
217244
"prompts": [],

tests/tools/test_import_mapping.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from fastmcp import Client
5+
from fastmcp.exceptions import ToolError
6+
7+
# Heuristic labels the tool may legitimately emit. Keeping a central set makes the
8+
# success test resilient to minor internal mapping changes upstream.
9+
VALID_HEURISTICS = {
10+
"identity",
11+
"identity_present",
12+
"ranked_selection",
13+
"fallback",
14+
}
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_import_mapping__success_basic(server):
19+
"""
20+
Basic happy-path test: provide a dotted import and validate the response schema
21+
and invariants. We intentionally do NOT assert an exact best_package value
22+
(to stay resilient to upstream mapping evolution) but we enforce structural
23+
correctness and heuristic membership.
24+
"""
25+
async with Client(server) as client:
26+
# Use a very common library import that should resolve deterministically.
27+
result = await client.call_tool(
28+
"import_mapping",
29+
{
30+
"import_name": "numpy.linalg",
31+
},
32+
)
33+
data = result.data
34+
# Schema keys
35+
assert sorted(data.keys()) == [
36+
"best_package",
37+
"candidate_packages",
38+
"heuristic",
39+
"normalized_import",
40+
"query_import",
41+
]
42+
# Field relationships
43+
assert data["query_import"] == "numpy.linalg"
44+
assert data["normalized_import"] == "numpy"
45+
assert isinstance(data["candidate_packages"], list)
46+
assert all(isinstance(x, str) for x in data["candidate_packages"])
47+
assert data["heuristic"] in VALID_HEURISTICS
48+
assert isinstance(data["best_package"], str)
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_import_mapping__error_on_empty_input(server):
53+
"""
54+
Passing an empty string should surface a ToolError (input validation branch).
55+
"""
56+
async with Client(server) as client:
57+
with pytest.raises(ToolError) as exc:
58+
await client.call_tool(
59+
"import_mapping",
60+
{
61+
"import_name": "",
62+
},
63+
)
64+
# Sanity check on error message clarity
65+
assert "invalid input" in str(exc.value).lower()

tests/tools/test_pkg_search.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ def _is_sorted_newest_first(records: list[dict]) -> bool:
1717

1818
def key(r):
1919
version = VersionOrder(r["version"])
20-
try:
21-
bn = int(r["build_number"])
22-
except Exception:
23-
bn = -1
20+
bn = int(r["build_number"])
2421
return (version, bn)
2522

2623
return all(key(prev) >= key(curr) for prev, curr in pairwise(records))

0 commit comments

Comments
 (0)