"""Simplified tool registry for automatic tool discovery and registration."""
import importlib
import sys
import pkgutil
import os
import logging
import re
from pathlib import Path
from typing import Dict, Optional
# Initialize logger for this module
logger = logging.getLogger("ToolRegistry")
# Detect this module's package prefix (e.g. "tooluniverse" or "src.tooluniverse")
# so that lazy imports use the same namespace as the running code.
_PKG = __name__.rsplit(".", 1)[0]
# Global registries
_tool_registry = {}
_config_registry = {}
_list_config_registry: list = [] # Flat list of configs from sub-packages
_lazy_registry: Dict[str, str] = {} # Maps tool names to module names
_discovery_completed = False
_lazy_cache = {}
# Global error tracking
_TOOL_ERRORS = {}
# Tracks which entry-point plugins have already been fully processed so that
# _discover_entry_point_plugins() is idempotent even if called multiple times.
_discovered_plugin_names: set = set()
def _extract_missing_package(error_msg: str) -> Optional[str]:
"""Extract package name from ImportError."""
match = re.search(r"No module named ['\"]([^'\"]+)['\"]", error_msg)
if match:
return match.group(1).split(".")[0]
return None
[docs]
def register_config(tool_type_name, config):
"""Register a config for a tool type."""
# Add MCP annotations to config if it's a dict
if isinstance(config, dict):
from .tool_defaults import add_annotations_to_tool_config
# Ensure config has type field for annotation calculation
if "type" not in config:
config["type"] = tool_type_name
add_annotations_to_tool_config(config)
_config_registry[tool_type_name] = config
logger.info(f"Registered config for: {tool_type_name}")
[docs]
def get_config_registry():
"""Get a copy of the current config registry."""
return _config_registry.copy()
[docs]
def get_list_config_registry() -> list:
"""Return the flat list of configs registered by sub-packages."""
return _list_config_registry.copy()
[docs]
def clear_lazy_cache():
"""Clear the module-level lazy import cache.
Built-in tool modules (in ``src/tooluniverse/tools/``) are cached after
their first import. Call this function in development environments when
you have edited a built-in tool module and want the changes to take effect
without restarting the process. After calling this, the next access to the
tool will re-import its module from disk.
Note: this does NOT affect workspace user tool files; those are handled
separately via mtime tracking in ``_import_user_python_tools()``.
Example::
from tooluniverse.tool_registry import clear_lazy_cache
clear_lazy_cache()
tu.refresh_tools()
"""
_lazy_cache.clear()
logger.debug(
"Lazy import cache cleared; built-in tool modules will be re-imported on next access."
)
def _discover_from_ast():
"""
Discover tools by parsing AST of files in the package.
Returns: Dict[tool_name, module_name]
"""
import ast
import tooluniverse
mapping = {}
try:
package_path = tooluniverse.__path__[0]
except (ImportError, AttributeError):
logger.warning("Cannot import tooluniverse package for AST discovery")
return {}
logger.debug(f"AST scanning directory: {package_path}")
# Directories to exclude from scanning
EXCLUDED_DIRS = {
"tools",
"space",
"data",
"compose_scripts",
"cache",
"remote",
"scripts",
"__pycache__",
"tests",
"venv",
"build",
"dist",
".git",
".idea",
".vscode",
}
# Walk through the directory
for root, dirs, files in os.walk(package_path):
# Modify dirs in-place to skip excluded directories
dirs[:] = [d for d in dirs if d not in EXCLUDED_DIRS]
for file in files:
if not file.endswith(".py"):
continue
# Skip known non-tool files
if file in [
"__init__.py",
"main.py",
"generate_tools.py",
"conftest.py",
"setup.py",
]:
continue
# Determine if this is an explicit tool file (legacy naming convention)
is_explicit_tool_file = (
file.endswith("_tool.py")
or file.endswith("_tools.py")
or file in ["compose_tool.py", "agentic_tool.py"]
)
file_path = os.path.join(root, file)
# Determine module name relative to tooluniverse package
rel_path = os.path.relpath(file_path, package_path)
module_name = os.path.splitext(rel_path)[0].replace(os.sep, ".")
try:
with open(file_path, "r", encoding="utf-8") as f:
try:
node = ast.parse(f.read())
for n in node.body:
if isinstance(n, ast.ClassDef):
# Skip private classes
if n.name.startswith("_"):
continue
has_registered_alias = False
for decorator in n.decorator_list:
if not isinstance(decorator, ast.Call):
continue
func = decorator.func
is_register_tool = (
isinstance(func, ast.Name)
and func.id == "register_tool"
) or (
isinstance(func, ast.Attribute)
and func.attr == "register_tool"
)
if not is_register_tool:
continue
has_registered_alias = True
if decorator.args:
arg = decorator.args[0]
if isinstance(arg, ast.Constant) and isinstance(
arg.value, str
):
mapping[arg.value] = module_name
if has_registered_alias or is_explicit_tool_file:
mapping[n.name] = module_name
except SyntaxError:
logger.warning(f"Syntax error parsing {file_path}")
except Exception as e:
logger.warning(f"Error reading {file_path}: {e}")
return mapping
[docs]
def build_lazy_registry(package_name=None):
"""
Build a mapping of tool names to module names.
Prioritizes pre-computed static registry (for bundles/frozen envs).
Falls back to AST analysis if static registry is missing.
"""
global _lazy_registry # noqa: PLW0603
if package_name is None:
package_name = "tooluniverse"
# 1. Try to load pre-computed static registry (for frozen environments)
try:
from tooluniverse._lazy_registry_static import STATIC_LAZY_REGISTRY
logger.debug(
f"Loaded static lazy registry with {len(STATIC_LAZY_REGISTRY)} classes."
)
_lazy_registry.update(STATIC_LAZY_REGISTRY)
# Supplement with AST discovery so newly-added tool files (not yet in the
# static registry) are automatically found without requiring a manual rebuild.
ast_mappings = _discover_from_ast()
new_from_ast = 0
for tool_name, module_name in ast_mappings.items():
if tool_name not in _lazy_registry:
_lazy_registry[tool_name] = module_name
new_from_ast += 1
if new_from_ast:
logger.debug(
f"AST discovery added {new_from_ast} tool(s) not in static registry."
)
# Still auto-import sub-packages so their __init__.py files can register
# configs even when the static registry is used (e.g. tooluniverse[circuit]).
_auto_import_subpackages(package_name)
# Discover entry-point plugins (new-style external packages).
_discover_entry_point_plugins()
return _lazy_registry.copy()
except ImportError:
logger.debug("No static lazy registry found. Proceeding with AST discovery.")
logger.debug(f"Building lazy registry using AST for package: {package_name}")
# 2. Use AST-based discovery as the primary source of truth (dev environment)
ast_mappings = _discover_from_ast()
for tool_name, module_name in ast_mappings.items():
_lazy_registry[tool_name] = module_name
# 3. Auto-import installed tooluniverse sub-packages so their __init__.py
# files can call register_tool_configs() and populate _list_config_registry.
# We import them here (after AST scan) to avoid circular imports during scan.
_auto_import_subpackages(package_name)
# 4. Discover entry-point plugins (new-style flat packages).
_discover_entry_point_plugins()
logger.info(
f"Built lazy registry: {len(_lazy_registry)} classes discovered via AST (no modules imported)"
)
return _lazy_registry.copy()
def _read_profile_yaml(directory, context: str = "") -> dict:
"""
Read ``profile.yaml`` from *directory* if it exists.
Logs the pack name/description at INFO level so users can see which
tool packs were loaded. Also logs a WARNING for any ``required_env``
variables that are missing from the environment — this is the earliest
point at which a user can be told "you need DIGIKEY_CLIENT_ID".
Returns the parsed config dict (empty dict if no file or parse error).
"""
profile_file = Path(directory) / "profile.yaml"
if not profile_file.exists():
return {}
try:
import yaml
with open(profile_file, "r", encoding="utf-8") as _f:
config = yaml.safe_load(_f) or {}
except Exception as exc:
logger.debug(f"{context}: could not read profile.yaml: {exc}")
return {}
name = config.get("name", "")
description = config.get("description", "").strip()
label = f"{name} — {description}" if description else name
if label:
logger.info(f"Tool pack loaded: {label} ({context})")
missing = [var for var in config.get("required_env", []) if not os.environ.get(var)]
if missing:
# Feature-23B-06: downgrade to DEBUG so the circuit/plugin env-var notice
# does not spam every `tu` invocation (including `tu --help`).
# The tools affected are already excluded from the loaded registry;
# a summary INFO-level message is emitted by execute_function.py.
logger.debug(f"{context} requires env var(s) not set: {', '.join(missing)}")
return config
[docs]
def reset_plugin_discovery():
"""Clear the set of already-discovered plugin names.
Call this before ``build_lazy_registry()`` (or ``refresh_tools()``) when a
new plugin package has been installed in the current process and you want
``_discover_entry_point_plugins()`` to pick it up without restarting.
"""
_discovered_plugin_names.clear()
logger.debug(
"Plugin discovery cache cleared; next scan will re-discover all plugins."
)
def _discover_entry_point_plugins(force: bool = False):
"""
Discover and eagerly load installed tooluniverse plugins registered via
the ``tooluniverse.plugins`` entry point group.
Plugin packages declare themselves in ``pyproject.toml``::
[project.entry-points."tooluniverse.plugins"]
my-tools = "my_tools_package"
The entry point value must be an importable Python package. When
discovered, every ``.py`` file in the package directory (excluding
``__init__.py``) is imported so that ``@register_tool`` decorators fire
and the tool classes land in ``_tool_registry``. JSON config files
inside ``data/`` and the package root are loaded into
``_list_config_registry``.
This allows external plugin packages to have exactly the same directory
layout as a local workspace (``data/``, tool ``.py`` files, optional
``profile.yaml``) — the only extra piece needed for a distributable
package is the ``pyproject.toml`` entry point declaration.
"""
import json as _json
try:
from importlib.metadata import entry_points
eps = entry_points(group="tooluniverse.plugins")
except Exception as exc:
logger.debug(f"Could not read tooluniverse.plugins entry points: {exc}")
return
for ep in eps:
# Skip plugins already processed in a previous call (idempotency guard).
# The guard is bypassed when force=True (e.g. after a new pip install).
if not force and ep.name in _discovered_plugin_names:
logger.debug(f"Plugin '{ep.name}': already loaded, skipping")
continue
# Remove from processed set so the plugin is fully re-scanned below.
_discovered_plugin_names.discard(ep.name)
try:
plugin_module = ep.load()
except Exception as exc:
logger.debug(f"Plugin '{ep.name}': failed to load '{ep.value}': {exc}")
continue
if not hasattr(plugin_module, "__file__") or plugin_module.__file__ is None:
logger.debug(f"Plugin '{ep.name}': no __file__, skipping")
continue
plugin_dir = Path(plugin_module.__file__).parent
pkg_name = getattr(plugin_module, "__name__", None)
if not pkg_name:
continue
# Read profile.yaml if present — log pack identity and check required_env
_read_profile_yaml(plugin_dir, context=f"plugin '{ep.name}'")
logger.debug(f"Plugin '{ep.name}' at {plugin_dir} (package={pkg_name})")
# Import .py tool files so @register_tool decorators fire
_SKIP = {"__init__.py", "setup.py", "conftest.py"}
imported = 0
for py_file in sorted(plugin_dir.glob("*.py")):
if py_file.name in _SKIP:
continue
mod_name = f"{pkg_name}.{py_file.stem}"
try:
importlib.import_module(mod_name)
imported += 1
logger.debug(f" Plugin '{ep.name}': imported {mod_name}")
except Exception as exc:
mark_tool_unavailable(py_file.stem, exc, mod_name)
logger.debug(
f" Plugin '{ep.name}': could not import {mod_name}: {exc}"
)
# Load JSON configs from data/ sub-directory and flat package root
configs = []
for search_dir in [plugin_dir / "data", plugin_dir]:
if not search_dir.is_dir():
continue
for json_file in sorted(search_dir.glob("*.json")):
try:
with open(json_file, "r", encoding="utf-8") as _f:
data = _json.load(_f)
if isinstance(data, list):
configs.extend(data)
elif isinstance(data, dict) and "name" in data:
configs.append(data)
except Exception as exc:
logger.debug(
f" Plugin '{ep.name}': could not load {json_file}: {exc}"
)
if configs:
from .tool_defaults import add_annotations_to_tool_config
n = 0
for cfg in configs:
if isinstance(cfg, dict) and "name" in cfg:
add_annotations_to_tool_config(cfg)
_list_config_registry.append(cfg)
n += 1
logger.info(
f"Plugin '{ep.name}': {n} tool configs loaded, "
f"{imported} modules imported from {plugin_dir}"
)
else:
if imported:
logger.debug(
f"Plugin '{ep.name}': {imported} modules imported, no JSON configs"
)
# Mark this plugin as fully processed so re-calls are no-ops
_discovered_plugin_names.add(ep.name)
def _auto_import_subpackages(package_name: str = "tooluniverse"):
"""
Import all installed sub-packages of ``package_name`` so their
``__init__.py`` files run and can self-register configs/tools.
Only packages that have their own ``__init__.py`` are imported; plain
directories (like ``data/``, ``cache/``) are skipped automatically.
Errors are logged but never propagated — a broken sub-package must not
prevent the main package from starting.
"""
try:
pkg = importlib.import_module(package_name)
except ImportError:
return
for pkg_dir in pkg.__path__:
try:
base = Path(pkg_dir)
except Exception:
continue
for subpkg in sorted(base.iterdir()):
if not subpkg.is_dir():
continue
if not (subpkg / "__init__.py").exists():
continue
# Skip the built-in sub-packages that are part of the main repo
# (they register themselves via the standard decorator path).
# We only want EXTERNALLY installed sub-packages.
# Heuristic: skip directories that are inside the main package dir
# that lives in the editable-install source tree.
full_mod = f"{package_name}.{subpkg.name}"
if full_mod in sys.modules:
continue
try:
importlib.import_module(full_mod)
logger.debug(f"Auto-imported sub-package: {full_mod}")
except Exception as exc:
logger.debug(f"Could not auto-import sub-package {full_mod}: {exc}")