mirror of
https://github.com/MervinPraison/PraisonAI-Tools.git
synced 2026-11-07 05:56:17 -10:00
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:
1 parent
517a3b8bcb
commit
5c302e2acc
4 files changed
+197
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user