Source code for pydocs_mcp.application.module_inspector
"""ModuleInspector — live importlib + inspect (spec §5.1).
Post-#5a-2: depends only on a ``uow_factory: Callable[[], UnitOfWork]``.
Reads the indexed-package row through ``uow.packages.get(...)`` inside
``async with self.uow_factory() as uow:`` — the UoW Protocol guarantees
``packages`` is valid inside the context (spec §14.2 of #5b spec).
"""
from __future__ import annotations
import asyncio
import importlib
import inspect
import pkgutil
import re
from collections.abc import Callable
from dataclasses import dataclass
from pydocs_mcp.constants import LIVE_DOC_MAX, LIVE_SIGNATURE_MAX
from pydocs_mcp.deps import normalize_package_name
from pydocs_mcp.storage.protocols import UnitOfWork
# Use \A...\Z (not ^...$) — Python's ``re`` treats ``$`` as matching before a
# trailing ``\n`` by default, which lets ``"foo\n"`` slip past a naive anchor
# and reach ``importlib.import_module`` downstream.
_SUBMODULE_RE = re.compile(r"\A([A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*)?\Z")
# Match the pre-PR server.py handler: cap inspection at 50 public members so
# inspecting giant modules (e.g. ``numpy``) can't bloat the MCP response.
_MAX_MEMBERS: int = 50
# Single source of truth for the narrowed-from-broad ``except`` clause used
# by both the ``inspect.getmembers`` site and the ``pkgutil.iter_modules``
# fallback site below. Originally widened to ``except Exception`` for
# byte-parity with the pre-PR server.py handler; the narrow tuple lets
# real bugs (ValueError, KeyError, TypeError, …) propagate so they surface
# in tests instead of being silently swallowed.
_BENIGN_INSPECT_EXCEPTIONS = (
AttributeError,
ImportError,
OSError,
RuntimeError,
)
def _validate_submodule(submodule: str) -> bool:
"""Return True if submodule is a safe dotted identifier (or empty)."""
return bool(_SUBMODULE_RE.match(submodule))
[docs]
@dataclass(frozen=True, slots=True)
class ModuleInspector:
"""Live-import a package/submodule and render its public API.
Depends only on ``uow_factory`` — opens a UoW per ``inspect`` call to
check the package is indexed before crossing the import boundary.
"""
uow_factory: Callable[[], UnitOfWork]
async def inspect(self, package: str, submodule: str = "") -> str:
pkg_name = normalize_package_name(package)
async with self.uow_factory() as uow:
pkg = await uow.packages.get(pkg_name)
if pkg is None:
return f"'{package}' is not indexed. Use lookup(target='') to see available packages."
if submodule and not _validate_submodule(submodule):
return (
f"Invalid submodule '{submodule}'. Use only letters, digits, underscores, and dots."
)
target = pkg_name + (f".{submodule}" if submodule else "")
# Offload to a worker thread: importlib + inspect may block on disk
# I/O (first-time imports) and on user code side effects.
return await asyncio.to_thread(self._inspect_target, target)
def _inspect_target(self, target: str) -> str:
try:
mod = importlib.import_module(target)
except ImportError:
return f"Cannot import '{target}'."
items = []
try:
for name, obj in inspect.getmembers(mod):
if name.startswith("_"):
continue
if not (inspect.isfunction(obj) or inspect.isclass(obj)):
continue
try:
sig = str(inspect.signature(obj))[:LIVE_SIGNATURE_MAX]
except (ValueError, TypeError):
sig = "(...)"
doc = (inspect.getdoc(obj) or "").split("\n")[0][:LIVE_DOC_MAX]
kind = "class" if inspect.isclass(obj) else "def"
items.append(f"{kind} {name}{sig}\n {doc}")
if len(items) >= _MAX_MEMBERS:
break
except _BENIGN_INSPECT_EXCEPTIONS:
# Custom-DSL libs raise these from ``inspect.getmembers``
# (broken __getattr__, lazy import shims, FS-backed
# descriptors, RuntimeError property guards).
pass
if not items and hasattr(mod, "__path__"):
try:
subs = [
s for _, s, _ in pkgutil.iter_modules(mod.__path__) if not s.startswith("_")
]
return f"# {target}\nSubmodules: {', '.join(subs)}"
except _BENIGN_INSPECT_EXCEPTIONS:
# Same rationale as the getmembers site above — broken /
# lazy / FS-backed packages raise these.
pass
return (f"# {target}\n\n" + "\n\n".join(items)) if items else f"No API in '{target}'."