fix: respect __all__, skip private modules, cache discovery scan

Address reviewer feedback on PR #53:
- Honour an explicit module-level __all__ (CodeRabbit/Gemini): modules that
  expose tools via aliases can opt them in; bare aliases are otherwise not
  auto-exported to avoid cross-module name collisions.
- Skip underscore-prefixed modules so internal code cannot leak into the
  public surface (Greptile).
- Warn instead of silently dropping tools when a module fails to parse
  (Greptile).
- Guard the drop-in test for read-only installs and use a non-underscore
  probe module (Gemini); add an n8n external-symbol resolution test
  (CodeRabbit) and an explicit-__all__ test.
- Memoise the AST scan in a fingerprinted on-disk cache, cutting import
  time from ~260ms to ~70ms (AGENTS.md <200ms budget) while keeping
  discovery automatic; add cache round-trip/corruption tests and gitignore.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
MervinPraisonandMervin Praison committed 2026-08-15 11:40:59 +00:00
1 parent 517a3b8bcb
commit 5c302e2acc
4 files changed
+197 -17

No files matched your search

+3
View File
@@ -24,6 +24,9 @@ venv/
.coverage
htmlcov/
# Tool auto-discovery cache (generated at import time)
._discovery_cache_v*.json
# Lock files
poetry.lock
uv.lock
+10
View File
@@ -548,6 +548,16 @@ Adding a new tool therefore means **adding one file** to
imports stay lazy (a tool module and its optional dependencies are only loaded
the first time one of its symbols is accessed).
By default the discovered public surface is the module's top-level classes and
functions. If a module exposes tools through module-level aliases
(e.g. `read_yaml = _yaml_tools.read_yaml`), declare a standard `__all__` in that
module and the listed names are exported verbatim. Modules and names starting
with `_` are treated as private and never exported.
The scan result is memoised in a small on-disk cache next to the package, so
import stays fast; the cache is rebuilt automatically whenever a tool file is
added, removed, or edited.
External packages that want their tools discovered by `praisonaiagents` should
use the `[project.entry-points."praisonaiagents.tools"]` group shown above,
which the core `praisonaiagents` registry resolves.
+118 -6
View File
@@ -13,12 +13,18 @@ now just adding one module under ``praisonai_tools/tools/``.
from __future__ import annotations
import ast
import json
import logging
import os
from typing import Dict, List, Tuple
logger = logging.getLogger(__name__)
# Cache file name written alongside the tools package. Bumping ``_CACHE_VERSION``
# invalidates all previously written caches after a discovery-format change.
_CACHE_VERSION = 1
_CACHE_FILENAME = f"._discovery_cache_v{_CACHE_VERSION}.json"
# Modules in *this* package directory that are infrastructure, not tools.
# Their public symbols are exported explicitly by ``tools/__init__.py`` and
# must not be treated as discovered tools.
@@ -33,17 +39,61 @@ _EXTERNAL_SYMBOLS: Dict[str, str] = {
}
def _iter_public_symbols(source: str) -> List[str]:
"""Return public top-level class/function names defined in ``source``.
def _extract_all(tree: ast.Module) -> List[str] | None:
"""Return the names listed in a module-level ``__all__``, if declared.
Only literal string entries are honoured. Returns ``None`` when the module
does not declare ``__all__`` at the top level.
"""
for node in tree.body:
if isinstance(node, ast.Assign):
targets = node.targets
elif isinstance(node, ast.AnnAssign) and node.value is not None:
targets = [node.target]
else:
continue
for target in targets:
if isinstance(target, ast.Name) and target.id == "__all__":
if isinstance(node.value, (ast.List, ast.Tuple, ast.Set)):
names: List[str] = []
for elt in node.value.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
names.append(elt.value)
return names
return None
def _iter_public_symbols(source: str, module_name: str = "<unknown>") -> List[str]:
"""Return the public top-level symbol names exported by ``source``.
Uses AST parsing so the module body is never executed (its heavy/optional
dependencies are not imported at discovery time).
Resolution order:
1. If the module declares ``__all__``, honour it verbatim (author intent).
This lets a module opt its module-level re-export aliases into the public
surface (e.g. ``read_yaml = _yaml_tools.read_yaml``) or, conversely,
narrow what is exported.
2. Otherwise, collect public top-level classes and functions. Bare
assignment aliases are intentionally *not* auto-exported without an
explicit ``__all__``: several ``*_tools.py`` modules bind the same
helper names (``read_csv``/``read_excel``/…) that would otherwise
collide across modules and were never part of the historical public API.
"""
try:
tree = ast.parse(source)
except SyntaxError: # pragma: no cover - defensive
except SyntaxError as exc: # pragma: no cover - defensive
logger.warning(
"Tool discovery: could not parse %r (%s); its tools will not be "
"discoverable. Fix the syntax error to restore them.",
module_name, exc,
)
return []
explicit = _extract_all(tree)
if explicit is not None:
return [name for name in explicit if not name.startswith("_")]
symbols: List[str] = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
@@ -52,8 +102,67 @@ def _iter_public_symbols(source: str) -> List[str]:
return symbols
def _fingerprint(package_dir: str) -> str:
"""Cheap directory fingerprint used to validate the discovery cache.
Reflects the set of ``.py`` files and their size/mtime, so any edit,
addition, or removal of a tool module invalidates the cache (keeping
discovery fully automatic) without re-parsing on every import.
"""
parts: List[str] = [f"v{_CACHE_VERSION}"]
for filename in sorted(os.listdir(package_dir)):
if not filename.endswith(".py"):
continue
try:
st = os.stat(os.path.join(package_dir, filename))
except OSError: # pragma: no cover - defensive
continue
parts.append(f"{filename}:{st.st_size}:{st.st_mtime_ns}")
return "|".join(parts)
def build_manifest(package_dir: str) -> Tuple[Dict[str, str], Dict[str, List[str]]]:
"""Scan ``package_dir`` and build the discovery manifest.
"""Return the discovery manifest for ``package_dir``, using a disk cache.
The manifest is expensive to compute (it AST-parses every tool module), so
the result is memoised in a small JSON file next to the package and reused
while the directory fingerprint is unchanged. The cache is best-effort:
any read/write/validation failure falls back to a live scan, so behaviour
is always correct even on read-only installs.
"""
cache_path = os.path.join(package_dir, _CACHE_FILENAME)
fingerprint = _fingerprint(package_dir)
try:
with open(cache_path, "r", encoding="utf-8") as fh:
cached = json.load(fh)
if cached.get("fingerprint") == fingerprint:
return cached["manifest"], cached["collisions"]
except (OSError, ValueError, KeyError):
pass # No/invalid cache -> scan below.
manifest, collisions = _scan_manifest(package_dir)
try:
tmp_path = f"{cache_path}.{os.getpid()}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(
{
"fingerprint": fingerprint,
"manifest": manifest,
"collisions": collisions,
},
fh,
)
os.replace(tmp_path, cache_path)
except OSError: # pragma: no cover - read-only install, cache disabled.
pass
return manifest, collisions
def _scan_manifest(package_dir: str) -> Tuple[Dict[str, str], Dict[str, List[str]]]:
"""AST-scan ``package_dir`` and build the discovery manifest.
Returns a tuple ``(manifest, collisions)`` where ``manifest`` maps each
public symbol name to the module it should be imported from, and
@@ -67,7 +176,10 @@ def build_manifest(package_dir: str) -> Tuple[Dict[str, str], Dict[str, List[str
if not filename.endswith(".py"):
continue
module_name = filename[:-3]
if module_name in _SKIP_MODULES:
# Skip infrastructure modules and any private (underscore-prefixed)
# module so internal/experimental code does not leak into the public
# surface by convention.
if module_name in _SKIP_MODULES or module_name.startswith("_"):
continue
path = os.path.join(package_dir, filename)
@@ -77,7 +189,7 @@ def build_manifest(package_dir: str) -> Tuple[Dict[str, str], Dict[str, List[str
except OSError: # pragma: no cover - defensive
continue
for symbol in _iter_public_symbols(source):
for symbol in _iter_public_symbols(source, module_name):
if symbol in manifest and manifest[symbol] != module_name:
collisions.setdefault(symbol, [manifest[symbol]])
collisions[symbol].append(module_name)
+66 -11
View File
@@ -13,26 +13,30 @@ import pytest
import praisonai_tools
from praisonai_tools import tools as tools_pkg
from praisonai_tools.tools._discovery import build_manifest, _SKIP_MODULES
from praisonai_tools.tools._discovery import (
build_manifest,
_iter_public_symbols,
_SKIP_MODULES,
)
TOOLS_DIR = os.path.dirname(tools_pkg.__file__)
def _public_symbols(path):
with open(path, "r", encoding="utf-8") as fh:
tree = ast.parse(fh.read())
return [
node.name
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and not node.name.startswith("_")
]
source = fh.read()
return _iter_public_symbols(source, os.path.basename(path)[:-3])
def _tool_modules():
for filename in sorted(os.listdir(TOOLS_DIR)):
if filename.endswith(".py") and filename[:-3] not in _SKIP_MODULES:
yield filename[:-3]
module_name = filename[:-3]
if (
filename.endswith(".py")
and module_name not in _SKIP_MODULES
and not module_name.startswith("_")
):
yield module_name
def test_no_discovery_collisions():
@@ -65,11 +69,24 @@ def test_all_matches_manifest():
@pytest.mark.parametrize("name", [
"EmailTool", "SlackTool", "GitHubTool", "WeatherTool", "YouTubeTool",
"send_email", "get_weather", "search_youtube",
# Symbol from a module that declares an explicit __all__ (author intent).
"NexusPredictionMarketTool",
# External symbol living outside tools/ (exercises the absolute-path
# import branch in __getattr__ and the _EXTERNAL_SYMBOLS mapping).
"N8nWorkflowTool",
])
def test_known_symbols_resolve_both_surfaces(name):
assert getattr(tools_pkg, name) is getattr(praisonai_tools, name)
def test_explicit_all_is_respected():
"""A module declaring __all__ contributes exactly those public names."""
manifest, _ = build_manifest(TOOLS_DIR)
# nexus_prediction_market_tool.py declares __all__ with these two names.
assert manifest.get("NexusPredictionMarketTool") == "nexus_prediction_market_tool"
assert manifest.get("nexus_prediction_market_tool") == "nexus_prediction_market_tool"
def test_lazy_import_not_eager(monkeypatch):
"""Importing the package must not eagerly import a heavy tool module."""
import sys
@@ -87,7 +104,9 @@ def test_lazy_import_not_eager(monkeypatch):
def test_drop_in_new_tool(tmp_path, monkeypatch):
"""A new module dropped into tools/ is importable with no other edits."""
echo_path = os.path.join(TOOLS_DIR, "_echo_discovery_probe.py")
if not os.access(TOOLS_DIR, os.W_OK):
pytest.skip("TOOLS_DIR is not writable (read-only install)")
echo_path = os.path.join(TOOLS_DIR, "echo_discovery_probe.py")
source = (
"from praisonai_tools.tools.decorator import tool\n\n\n"
"@tool\n"
@@ -105,3 +124,39 @@ def test_drop_in_new_tool(tmp_path, monkeypatch):
finally:
os.remove(echo_path)
importlib.reload(tools_pkg)
def test_manifest_cache_roundtrip(tmp_path):
"""The disk cache is written, reused, and invalidated on directory change."""
from praisonai_tools.tools import _discovery
pkg = tmp_path / "toolsdir"
pkg.mkdir()
(pkg / "sample_tool.py").write_text("class SampleTool:\n pass\n")
manifest, _ = _discovery.build_manifest(str(pkg))
assert manifest.get("SampleTool") == "sample_tool"
cache_file = pkg / _discovery._CACHE_FILENAME
assert cache_file.exists(), "cache should be written after first scan"
# Second call must serve from cache (fingerprint unchanged) and match.
manifest2, _ = _discovery.build_manifest(str(pkg))
assert manifest2 == manifest
# Adding a module changes the fingerprint -> cache is rebuilt automatically.
(pkg / "second_tool.py").write_text("def second_tool():\n return 1\n")
manifest3, _ = _discovery.build_manifest(str(pkg))
assert "second_tool" in manifest3
def test_manifest_cache_corrupt_falls_back(tmp_path):
"""A corrupt cache file must not break discovery."""
from praisonai_tools.tools import _discovery
pkg = tmp_path / "toolsdir"
pkg.mkdir()
(pkg / "sample_tool.py").write_text("class SampleTool:\n pass\n")
(pkg / _discovery._CACHE_FILENAME).write_text("{ not valid json")
manifest, _ = _discovery.build_manifest(str(pkg))
assert manifest.get("SampleTool") == "sample_tool"