arcade-mcp/arcade/arcade/core/toolkit.py
Sam Partee 35baaf0dc8
Pass ToolContext and CLI cleanup (#13)
Added
-  `arcade dev` - serves a simple fastapi actor
- `arcade config` - show/edit/change config in `~/.arcade`
- `arcade chat` - chat with LLM without toolcalls

Changed:
- `arcade show`, `arcade run` - can now use all installed toolkits

---------

Co-authored-by: Nate Barbettini <nate@arcade-ai.com>
2024-08-13 15:40:08 -07:00

140 lines
4.7 KiB
Python

import importlib.metadata
import importlib.util
import os
import types
from collections import defaultdict
from pathlib import Path
from pydantic import BaseModel, ConfigDict, field_validator
from arcade.core.parse import get_tools_from_file
class Toolkit(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
"""Name of the toolkit"""
package_name: str
"""Name of the package holding the toolkit"""
tools: dict[str, list[str]] = defaultdict(list)
"""Mapping of module names to tools"""
# Other python package metadata
version: str
description: str
author: list[str] = []
repository: str | None = None
homepage: str | None = None
@field_validator("name", mode="before")
def strip_arcade_prefix(cls, value: str) -> str:
"""
Validator to strip the 'arcade_' prefix from the name if it exists.
"""
if value.startswith("arcade_"):
return value[len("arcade_") :]
return value
@classmethod
def from_module(cls, module: types.ModuleType) -> "Toolkit":
"""
Load a toolkit from an imported python module
>>> import arcade_arithmetic
>>> toolkit = Toolkit.from_module(arcade_arithmetic)
"""
return cls.from_package(module.__name__)
@classmethod
def from_package(cls, package: str) -> "Toolkit":
"""
Load a Toolkit from a Python package
"""
try:
metadata = importlib.metadata.metadata(package)
name = metadata["Name"]
package_name = package
version = metadata["Version"]
description = metadata.get("Summary", "") # type: ignore[attr-defined]
author = metadata.get_all("Author-email")
homepage = metadata.get("Home-page", None) # type: ignore[attr-defined]
repo = metadata.get("Repository", None) # type: ignore[attr-defined]
except importlib.metadata.PackageNotFoundError as e:
raise ValueError(f"Package {package} not found.") from e
except KeyError as e:
raise ValueError(f"Metadata key error for package {package}.") from e
except Exception as e:
raise ValueError(f"Failed to load metadata for package {package}.") from e
# Get the package directory
try:
package_dir = Path(get_package_directory(package))
except AttributeError as e:
raise ValueError(f"Failed to locate package directory for {package}.") from e
# Get all python files in the package directory
try:
modules = [f for f in package_dir.glob("**/*.py") if f.is_file()]
except OSError as e:
raise ValueError(
f"Failed to locate Python files in package directory for {package}."
) from e
toolkit = cls(
name=name,
package_name=package_name,
version=version,
description=description,
author=author if author else [],
homepage=homepage,
repository=repo,
)
for module_path in modules:
relative_path = module_path.relative_to(package_dir)
import_path = ".".join(relative_path.with_suffix("").parts)
import_path = f"{package_name}.{import_path}"
toolkit.tools[import_path] = get_tools_from_file(str(module_path))
if not toolkit.tools:
raise ValueError(f"No tools found in package {package}")
return toolkit
@classmethod
def find_all_arcade_toolkits(cls) -> list["Toolkit"]:
"""
Find all installed packages prefixed with 'arcade_' and load them as Toolkits.
Returns:
List[Toolkit]: A list of Toolkit instances.
"""
arcade_packages = [
dist.metadata["Name"]
for dist in importlib.metadata.distributions()
if dist.metadata["Name"].startswith("arcade_")
]
return [cls.from_package(package) for package in arcade_packages]
def get_package_directory(package_name: str) -> str:
"""
Get the directory of a Python package
"""
spec = importlib.util.find_spec(package_name)
if spec is None:
raise ImportError(f"Cannot find package named {package_name}")
if spec.origin:
# If the package has an origin, return the directory of the origin
return os.path.dirname(spec.origin)
elif spec.submodule_search_locations:
# If the package is a namespace package, return the first search location
return spec.submodule_search_locations[0]
else:
raise ImportError(f"Package {package_name} does not have a file path associated with it")