diff --git a/CLAUDE.md b/CLAUDE.md index 3018df8..6c799c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,44 +26,82 @@ uv run ruff check # Type checking uv run mypy src/ +# Pre-commit hooks +uv run pre-commit run --all-files + # Docker docker compose up --build ``` ## Architecture -HuMCP is a FastMCP server with a FastAPI adapter that exposes MCP tools as REST endpoints. +HuMCP exposes tools via both MCP (Model Context Protocol) at `/mcp` and REST at `/tools/*`. -### Core Components +### Core Library (`src/humcp/`) -**Entry Points:** -- `src/main.py` - FastAPI application that mounts MCP at `/mcp` and auto-generates REST endpoints -- `src/mcp_register.py` - Creates the FastMCP server, auto-discovers and registers tools from `src/tools/` +- **`server.py`** - `create_app()` creates FastAPI app, loads tool modules, registers with FastMCP +- **`decorator.py`** - `@tool(category="...")` marks functions for discovery. Category auto-detects from parent folder if not specified. Tool name = function name (used by FastMCP) +- **`registry.py`** - `RegisteredTool` NamedTuple wraps FastMCP's `FunctionTool` with category +- **`routes.py`** - Generates REST endpoints from registered tools using `FunctionTool.parameters` +- **`config.py`** - Tool filtering via `config/tools.yaml` (include/exclude with wildcard support) +- **`skills.py`** - Discovers `SKILL.md` files for category metadata +- **`schemas.py`** - Pydantic response models for API endpoints -**Adapter Layer (`src/adapter/`):** -- `fast_mcp_fast_api_adapter.py` - `FastMCPFastAPIAdapter` bridges FastMCP and FastAPI -- `routes.py` - `RouteGenerator` creates REST endpoints from MCP tool schemas -- `models.py` - Dynamically generates Pydantic models from MCP tool input schemas +### Tool Discovery Flow -**Tool Registration System:** -- Tools use `@tool(name, category)` decorator from `src/tools/__init__.py` -- Decorator adds tools to `TOOL_REGISTRY` for auto-discovery -- `src/mcp_register.py` walks `src/tools/` modules and registers all decorated functions with FastMCP +1. `create_app()` loads Python modules from `src/tools/` recursively +2. Functions with `@tool()` decorator are discovered via `_humcp_tool` attribute +3. Each tool is registered with FastMCP via `mcp.tool()(func)` which creates a `FunctionTool` +4. `RegisteredTool(tool=fn_tool, category=...)` pairs the FunctionTool with its category +5. REST routes are generated from `FunctionTool.parameters` (JSON Schema) ### Adding New Tools -1. Create a `.py` file anywhere under `src/tools/` (no `__init__.py` required) -2. Import and use the `@tool` decorator: +Create a `.py` file in `src/tools//`: + ```python -from src.tools import tool +from src.humcp.decorator import tool -@tool("my_tool_name") +@tool() # category auto-detected from folder name async def my_tool(param: str) -> dict: - """Tool description.""" - return {"success": True, "data": result} + """Tool description (used by FastMCP and Swagger).""" + return {"success": True, "data": {"result": param}} +``` + +The function name becomes the tool name. Tools are auto-discovered on server startup. + +### Response Pattern + +```python +# Success +return {"success": True, "data": {...}} + +# Error +return {"success": False, "error": "Error message"} ``` -3. Tools are auto-discovered on server startup via filesystem scan - no manual registration needed -### Tool Response Pattern +### Skills + +Add `SKILL.md` in tool category folders with YAML frontmatter: + +```markdown +--- +name: skill-name +description: When and how to use these tools +--- +# Content for AI assistants +``` + +### Tool Configuration + +`config/tools.yaml` controls which tools are exposed: + +```yaml +include: + categories: [local, data] + tools: [tavily_web_search] +exclude: + tools: [shell_*] # wildcards supported +``` -Tools return `{"success": True, "data": ...}` or `{"success": False, "error": "..."}`. +Empty config = load all tools. diff --git a/README.md b/README.md index db87848..b52731f 100644 --- a/README.md +++ b/README.md @@ -8,37 +8,10 @@ HuMCP is a simple server that exposes tools via both MCP (Model Context Protocol - Auto-generated REST endpoints at `/tools/*` - MCP server at `/mcp` for AI assistant integration - Auto-generated Swagger/OpenAPI documentation at `/docs` -- Tools organized by category with info endpoints +- Tools organized by category with skill metadata +- Configurable tool filtering via YAML config - Docker Compose setup for easy deployment -## Prerequisites - -- Python >= 3.13 -- [uv](https://github.com/astral-sh/uv) (recommended) -- Docker & Docker Compose (optional) - -## Project Layout - -``` -. -├── docker/ -│ └── Dockerfile -├── src/ -│ ├── humcp/ # Core library -│ │ ├── registry.py # Tool registry -│ │ ├── decorator.py # @tool decorator -│ │ ├── routes.py # REST route generation -│ │ └── server.py # create_app() function -│ ├── tools/ # Your tools go here -│ │ ├── local/ # Local utility tools -│ │ ├── data/ # Data manipulation tools -│ │ └── ... -│ └── main.py # App entry point -├── tests/ -├── docker-compose.yml -└── pyproject.toml -``` - ## Quick Start ```bash @@ -55,7 +28,23 @@ uv run uvicorn src.main:app --host 0.0.0.0 --port 8080 - REST API & Swagger UI: [http://localhost:8080/docs](http://localhost:8080/docs) - MCP endpoint: `http://localhost:8080/mcp` -## Adding New Tools +### Docker + +```bash +docker compose up --build +``` + +## Prerequisites + +- Python >= 3.13 +- [uv](https://github.com/astral-sh/uv) (recommended) +- Docker & Docker Compose (optional) + +--- + +# Creating Tools + +## Adding a New Tool 1. Create a `.py` file in `src/tools//` (e.g., `src/tools/local/my_tool.py`) 2. Use the `@tool` decorator: @@ -78,29 +67,28 @@ async def greet(name: str) -> dict: 3. Start the server - tools are auto-discovered! -### Documentation is Critical! 🚨 +## Tool Documentation -**Your docstring is not just a comment - it becomes the tool's user interface:** +**Your docstring becomes the tool's user interface:** - **REST API**: Appears in Swagger/OpenAPI docs at `/docs` - **MCP Protocol**: Sent to AI assistants to help them understand when and how to use your tool - **Type hints**: Combined with docstrings to generate input schemas -**Best Practices:** +### Best Practices ✅ **DO:** - Write clear, concise docstrings that explain what the tool does - Document all parameters with their purpose and expected format - Describe what the tool returns -- Include examples for complex tools - Use proper Python type hints ❌ **DON'T:** - Leave tools without docstrings -- Use vague descriptions like "does stuff" or "helper function" +- Use vague descriptions like "does stuff" - Forget to document parameters or return values -**Example of Good Documentation:** +### Example ```python @tool() @@ -116,20 +104,14 @@ async def search_files(pattern: str, directory: str = ".") -> dict: Returns: List of matching file paths with their sizes and modification times. - - Example: - {"pattern": "*.json", "directory": "/app/config"} """ ... ``` -### Tool Naming - -The `@tool` decorator supports flexible naming: +## Tool Naming ```python # Auto-generated name: "{category}_{function_name}" -# Category from parent folder, e.g., "local_greet" @tool() async def greet(name: str) -> dict: ... @@ -145,7 +127,7 @@ async def greet(name: str) -> dict: ... ``` -### Tool Response Pattern +## Response Pattern Tools should return a dictionary: @@ -157,87 +139,139 @@ return {"success": True, "data": {"result": value}} return {"success": False, "error": "Error message"} ``` -## API Endpoints +## Skills -### Info Endpoints +Skills provide metadata about tool categories for AI assistants. Create a `SKILL.md` file in your tool category folder: -| Method | Endpoint | Description | -|--------|----------|-------------| -| GET | `/` | Server info | -| GET | `/tools` | List all tools grouped by category | -| GET | `/tools/{category}` | List tools in a category | -| GET | `/tools/{category}/{tool_name}` | Get tool details and input schema | +```markdown +--- +name: managing-local-system +description: Manages local filesystem operations and runs shell commands. Use when working with local files or executing commands. +--- + +# Local System Tools + +## File Operations -### Tool Endpoints +\`\`\`python +result = await filesystem_write_file(content="Hello", filename="test.txt") +\`\`\` +``` + +### Skill Best Practices + +Follow the [Claude Code Skill Best Practices](https://docs.anthropic.com/en/docs/claude-code/skills#best-practices): + +1. **Naming**: Use gerund phrases (e.g., `managing-local-system`, `processing-data`) +2. **Description**: Write in third person, describe what the skill does and when to use it +3. **Content**: Include code examples, parameter tables, and usage guidance +4. **Progressive Disclosure**: Start with common operations, then advanced options + +## Tool Configuration + +Control which tools are exposed via `config/tools.yaml`. Empty config loads all tools. + +```yaml +# Include specific categories/tools +include: + categories: + - local + - data + tools: + - tavily_web_search + +# Exclude categories/tools (supports wildcards) +exclude: + tools: + - shell_* +``` + +**Rules:** +1. Empty config = load ALL tools +2. `include` filters to only specified categories/tools +3. `exclude` removes from the result (supports `*`, `?` wildcards) + +--- + +# API Reference + +## Endpoints | Method | Endpoint | Description | |--------|----------|-------------| +| GET | `/` | Server info | +| GET | `/docs` | Swagger UI | +| GET | `/tools` | List all tools with skill metadata | +| GET | `/tools/{category}` | Category details with full skill content | +| GET | `/tools/{category}/{tool_name}` | Tool details and input schema | | POST | `/tools/{tool_name}` | Execute a tool | +| - | `/mcp` | MCP server (SSE transport) | + +## Example -Example: ```bash curl -X POST http://localhost:8080/tools/local_greet \ -H "Content-Type: application/json" \ -d '{"name": "World"}' ``` -### MCP Endpoint - -| Endpoint | Description | -|----------|-------------| -| `/mcp` | MCP server (SSE transport) | - -## Docker Usage - -```bash -docker compose up --build -``` - ## Environment Variables | Variable | Description | |----------|-------------| -| `PORT` | Port to run the combined FastAPI + MCP server (default `8080`) | -| `MCP_PORT` | Legacy override for MCP port if `PORT` is not set | -| `MCP_SERVER_URL` | Optional display URL for the MCP server (defaults to `http://0.0.0.0:/mcp`) | -| `TAVILY_API_KEY` | API key for Tavily web search tools | -| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth 2.0 Client ID for Google Workspace tools | -| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth 2.0 Client Secret | +| `PORT` | Server port (default `8080`) | +| `MCP_SERVER_URL` | Display URL for MCP server | +| `TAVILY_API_KEY` | Tavily web search API key | +| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth Client ID | +| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth Client Secret | -## Available Tools +--- -### Local Tools (`src/tools/local/`) -- **Calculator** - Basic math operations -- **Shell** - Run shell commands -- **File System** - File operations +# Available Tools -### Data Tools (`src/tools/data/`) -- **CSV** - CSV file operations -- **Pandas** - DataFrame operations +> Tools are auto-discovered from `src/tools/`. This list may change. -### File Tools (`src/tools/files/`) -- **PDF to Markdown** - Convert PDFs to markdown +| Category | Tools | Description | +|----------|-------|-------------| +| `local` | Calculator, Shell, File System | Local system operations | +| `data` | CSV, Pandas | Data processing | +| `files` | PDF to Markdown | File conversion | +| `search` | Tavily | Web search | +| `google` | Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, Chat | Google Workspace | -### Search Tools (`src/tools/search/`) -- **Tavily** - Web search via Tavily API +> **[Google Workspace Setup Guide →](src/tools/google/README.md)** -### [Google Workspace Tools](src/tools/google/README.md) (`src/tools/google/`) -Gmail, Calendar, Drive, Tasks, Docs, Sheets, Slides, Forms, Chat +--- -> **[View Google Workspace Setup Guide →](src/tools/google/README.md)** +# Development -## Development - -**Run tests:** ```bash +# Run tests uv run pytest -``` -**Run linter:** -```bash +# Run linter uv run pre-commit run --all-files ``` +## Project Layout + +``` +. +├── config/tools.yaml # Tool filtering config +├── src/ +│ ├── humcp/ # Core library +│ │ ├── decorator.py # @tool decorator +│ │ ├── config.py # Config loader +│ │ ├── routes.py # REST routes +│ │ └── server.py # create_app() +│ ├── tools/ # Tool implementations +│ │ └── / +│ │ ├── SKILL.md # Category skill metadata +│ │ └── *.py # Tool files +│ └── main.py +└── tests/ +``` + ## License MIT License diff --git a/config/tools.yaml b/config/tools.yaml new file mode 100644 index 0000000..3a3f3aa --- /dev/null +++ b/config/tools.yaml @@ -0,0 +1,82 @@ +# HuMCP Tool Configuration +# +# This file controls which tools are exposed as API endpoints. +# +# RULES: +# 1. Empty file or missing config = load ALL tools (default) +# 2. If `include` is specified, ONLY those categories/tools are loaded +# 3. Then `exclude` removes any matching categories/tools from the result +# +# WILDCARDS: +# Tool names support glob patterns: *, ?, [seq], [!seq] +# Examples: +# - "shell_*" matches all shell tools +# - "calculator_*" matches all calculator tools +# - "google_sheets_*" matches all Google Sheets tools +# +# AVAILABLE CATEGORIES: +# - data (CSV and pandas operations) +# - files (PDF conversion) +# - google (Google Workspace: Sheets, Docs, Drive, Gmail, Calendar, etc.) +# - local (Calculator, filesystem, shell) +# - search (Web search via Tavily) +# +# EXAMPLES: +# +# 1. Load ALL tools (default - just leave file empty or comment everything): +# # (empty) +# +# 2. Load only specific categories: +# include: +# categories: +# - local +# - data +# +# 3. Load everything except Google tools: +# exclude: +# categories: +# - google +# +# 4. Load local category but exclude shell tools: +# include: +# categories: +# - local +# exclude: +# tools: +# - shell_* +# +# 5. Load only specific tools: +# include: +# tools: +# - calculator_add +# - calculator_subtract +# - filesystem_read_file +# +# 6. Complex filtering: +# include: +# categories: +# - local +# - data +# exclude: +# tools: +# - shell_* +# - query_csv_file +# +# ============================================================================ +# ACTIVE CONFIGURATION (uncomment and modify as needed) +# ============================================================================ + +# Load all tools by default +# include: +# categories: [] +# tools: [] + +# exclude: +# categories: [] +# tools: [] + + +# Example: exclude google tools +# exclude: +# categories: +# - google diff --git a/pyproject.toml b/pyproject.toml index ef221f7..a93ad44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,17 @@ dev = [ "pre-commit>=3.4.0", "grafi>=0.0.34", "mypy>=1.13.0", + "types-pyyaml>=6.0.12.20250915", ] +[[tool.mypy.overrides]] +module = "yaml.*" +ignore_missing_imports = true + [tool.pytest.ini_options] pythonpath = ["."] asyncio_mode = "auto" -testpaths = ["tests"] +testpaths = ["tests", "tests_integration"] addopts = "-v --tb=short" filterwarnings = [ "ignore::DeprecationWarning", diff --git a/src/humcp/__init__.py b/src/humcp/__init__.py index 31314c0..f1e5247 100644 --- a/src/humcp/__init__.py +++ b/src/humcp/__init__.py @@ -1,39 +1,12 @@ -"""HuMCP - Human-friendly MCP server with FastAPI adapter.""" +"""HuMCP - Human-friendly MCP server with FastAPI adapter. -from src.humcp.registry import TOOL_REGISTRY, ToolRegistration -from src.humcp.schemas import ( - CategorySummary, - GetCategoryResponse, - GetToolResponse, - InputSchema, - ListToolsResponse, - SkillFull, - SkillMetadata, - ToolSummary, -) -from src.humcp.skills import ( - Skill, - discover_skills, - get_skill_content, - get_skills_by_category, -) +Public API: + tool: Decorator to mark functions as MCP tools + create_app: Create FastAPI app with REST and MCP endpoints + RegisteredTool: Type for registered tool (for type hints) +""" -__all__ = [ - # Registry - "TOOL_REGISTRY", - "ToolRegistration", - # Skills - "Skill", - "discover_skills", - "get_skill_content", - "get_skills_by_category", - # Schemas - "CategorySummary", - "GetCategoryResponse", - "GetToolResponse", - "InputSchema", - "ListToolsResponse", - "SkillFull", - "SkillMetadata", - "ToolSummary", -] +from src.humcp.decorator import RegisteredTool, tool +from src.humcp.server import create_app + +__all__ = ["tool", "create_app", "RegisteredTool"] diff --git a/src/humcp/config.py b/src/humcp/config.py new file mode 100644 index 0000000..1f46866 --- /dev/null +++ b/src/humcp/config.py @@ -0,0 +1,268 @@ +"""Tool configuration loader with include/exclude filtering.""" + +import fnmatch +import logging +from pathlib import Path + +import yaml +from pydantic import BaseModel, Field, model_validator + +from src.humcp.decorator import RegisteredTool + +logger = logging.getLogger("humcp.config") + +# Default config path +DEFAULT_CONFIG_PATH = Path("config/tools.yaml") + + +class FilterConfig(BaseModel): + """Configuration for include or exclude filters.""" + + categories: list[str] = Field(default_factory=list) + tools: list[str] = Field(default_factory=list) + + def is_empty(self) -> bool: + """Check if filter has no entries.""" + return not self.categories and not self.tools + + +class ToolsConfig(BaseModel): + """Configuration for tool filtering.""" + + include: FilterConfig = Field(default_factory=FilterConfig) + exclude: FilterConfig = Field(default_factory=FilterConfig) + + @model_validator(mode="before") + @classmethod + def handle_none_values(cls, data: dict) -> dict: + """Handle None values in config (e.g., empty YAML sections).""" + if not isinstance(data, dict): + return data + if data.get("include") is None: + data["include"] = {} + if data.get("exclude") is None: + data["exclude"] = {} + # Handle None in nested dicts + for key in ["include", "exclude"]: + if isinstance(data.get(key), dict): + if data[key].get("categories") is None: + data[key]["categories"] = [] + if data[key].get("tools") is None: + data[key]["tools"] = [] + return data + + +class ConfigValidationResult(BaseModel): + """Result of config validation.""" + + valid: bool + warnings: list[str] = Field(default_factory=list) + errors: list[str] = Field(default_factory=list) + + +def load_config(config_path: Path | None = None) -> ToolsConfig: + """Load tool configuration from YAML file. + + Args: + config_path: Path to config file. If None, uses default path. + + Returns: + ToolsConfig object. Returns default (load all) if file doesn't exist. + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + if not config_path.exists(): + logger.debug("Config file not found at %s, loading all tools", config_path) + return ToolsConfig() + + try: + with open(config_path, encoding="utf-8") as f: + data = yaml.safe_load(f) + + if data is None: + # Empty YAML file + return ToolsConfig() + + config = ToolsConfig.model_validate(data) + logger.info("Loaded tool config from %s", config_path) + return config + + except yaml.YAMLError as e: + logger.error("Failed to parse YAML config at %s: %s", config_path, e) + raise ValueError(f"Invalid YAML in config file: {e}") from e + except Exception as e: + logger.error("Failed to load config from %s: %s", config_path, e) + raise + + +def validate_config( + config: ToolsConfig, + available_categories: set[str], + available_tools: set[str], +) -> ConfigValidationResult: + """Validate config against available categories and tools. + + Args: + config: The config to validate. + available_categories: Set of valid category names. + available_tools: Set of valid tool names. + + Returns: + ConfigValidationResult with warnings and errors. + """ + warnings: list[str] = [] + errors: list[str] = [] + + # Validate categories (exact match required) + for section_name, section in [ + ("include", config.include), + ("exclude", config.exclude), + ]: + for category in section.categories: + if category not in available_categories: + errors.append( + f"{section_name}.categories: Unknown category '{category}'. " + f"Available: {sorted(available_categories)}" + ) + + # Validate tools (support wildcards) + for section_name, section in [ + ("include", config.include), + ("exclude", config.exclude), + ]: + for tool_pattern in section.tools: + if _is_wildcard(tool_pattern): + # Check if pattern matches at least one tool + matches = [ + t for t in available_tools if fnmatch.fnmatch(t, tool_pattern) + ] + if not matches: + warnings.append( + f"{section_name}.tools: Pattern '{tool_pattern}' matches no tools" + ) + else: + logger.debug( + "Pattern '%s' matches %d tools: %s", + tool_pattern, + len(matches), + matches, + ) + else: + # Exact match required + if tool_pattern not in available_tools: + errors.append( + f"{section_name}.tools: Unknown tool '{tool_pattern}'" + ) + + return ConfigValidationResult( + valid=len(errors) == 0, + warnings=warnings, + errors=errors, + ) + + +def filter_tools( + config: ToolsConfig, + tools: list[RegisteredTool], + validate: bool = True, +) -> list[RegisteredTool]: + """Filter tools based on config include/exclude rules. + + Args: + config: The filtering configuration. + tools: List of tools to filter. + validate: Whether to validate config and raise on errors. + + Returns: + Filtered list of ToolRegistration objects. + + Raises: + ValueError: If validate=True and config has validation errors. + """ + if not tools: + return [] + + # Validate config if requested + if validate: + available_categories = {reg.category for reg in tools} + available_tools = {reg.tool.name for reg in tools} + result = validate_config(config, available_categories, available_tools) + + for warning in result.warnings: + logger.warning("Config warning: %s", warning) + + if not result.valid: + error_msg = "Config validation failed:\n" + "\n".join( + f" - {e}" for e in result.errors + ) + raise ValueError(error_msg) + + # Step 1: Apply include filter + if config.include.is_empty(): + # No include filter = include all + filtered = tools + else: + filtered = [reg for reg in tools if _matches_filter(reg, config.include)] + + # Step 2: Apply exclude filter + if not config.exclude.is_empty(): + filtered = [reg for reg in filtered if not _matches_filter(reg, config.exclude)] + + logger.info( + "Filtered tools: %d/%d (include: %s, exclude: %s)", + len(filtered), + len(tools), + "all" + if config.include.is_empty() + else f"{len(config.include.categories)} categories, {len(config.include.tools)} tools", + "none" + if config.exclude.is_empty() + else f"{len(config.exclude.categories)} categories, {len(config.exclude.tools)} tools", + ) + + return filtered + + +def _is_wildcard(pattern: str) -> bool: + """Check if pattern contains wildcard characters.""" + return "*" in pattern or "?" in pattern or "[" in pattern + + +def _matches_filter(reg: RegisteredTool, filter_config: FilterConfig) -> bool: + """Check if a tool matches the filter criteria. + + A tool matches if: + - Its category is in the categories list, OR + - Its name matches any pattern in the tools list (supports wildcards) + """ + # Check category match + if reg.category in filter_config.categories: + return True + + # Check tool name match (with wildcard support) + for pattern in filter_config.tools: + if _is_wildcard(pattern): + if fnmatch.fnmatch(reg.tool.name, pattern): + return True + elif reg.tool.name == pattern: + return True + + return False + + +def get_filtered_tools( + tools: list[RegisteredTool], + config_path: Path | None = None, +) -> list[RegisteredTool]: + """Convenience function to load config and filter tools in one step. + + Args: + tools: List of tools to filter. + config_path: Path to config file. If None, uses default path. + + Returns: + Filtered list of ToolRegistration objects. + """ + config = load_config(config_path) + return filter_tools(config, tools) diff --git a/src/humcp/decorator.py b/src/humcp/decorator.py index 9726a22..3aca7cf 100644 --- a/src/humcp/decorator.py +++ b/src/humcp/decorator.py @@ -1,63 +1,105 @@ -"""Tool decorator for registering MCP tools.""" +"""Tool decorator and registration types for MCP tools. + +The @tool decorator marks functions with name and category metadata. +FastMCP handles description and schema generation. +""" import inspect from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, NamedTuple + +from fastmcp.tools import FunctionTool + +__all__ = [ + "tool", + "is_tool", + "get_tool_name", + "get_tool_category", + "RegisteredTool", + "ToolMetadata", +] + +TOOL_ATTR = "_humcp_tool" + + +@dataclass(frozen=True) +class ToolMetadata: + """Metadata stored on decorated functions.""" + + name: str + category: str + -from src.humcp.registry import _TOOL_NAMES, TOOL_REGISTRY, ToolRegistration +class RegisteredTool(NamedTuple): + """A tool registered with FastMCP, with category for grouping. -__all__ = ["tool"] + Attributes: + tool: The FastMCP FunctionTool object (has name, description, parameters, fn). + category: Category for REST endpoint grouping. + """ + + tool: FunctionTool + category: str def tool( - name: str | None = None, category: str | None = None + tool_name: str | None = None, + category: str | None = None, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator to register a function as an MCP tool. + """Mark a function as an MCP tool. Args: - name: Tool name. Defaults to "{category}_{function_name}". - category: Tool category. Defaults to parent folder name. - - Raises: - ValueError: If a tool with the same name already exists. + tool_name: Tool name for MCP registration. Defaults to function name. + category: Tool category for grouping. Defaults to parent folder name. Example: - # In src/tools/local/calculator.py - @tool() # name="local_add", category="local" + @tool() # name from function, category from file path async def add(a: float, b: float) -> dict: - return {"success": True, "data": {"result": a + b}} + return {"result": a + b} - @tool("my_tool", category="custom") # explicit name and category - async def func() -> dict: - ... + @tool("calculator_add", "math") # explicit name and category + async def multiply(a: float, b: float) -> dict: + return {"result": a * b} """ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - # Determine category from parent folder name - if category: - tool_category = category - else: - tool_category = _get_category_from_path(func) + # Resolve name (default to function name) + resolved_name = tool_name if tool_name is not None else func.__name__ + + # Resolve category (default to parent folder name) + resolved_category = category + if resolved_category is None: + try: + file_path = Path(inspect.getfile(func)) + resolved_category = file_path.parent.name + except (TypeError, OSError): + resolved_category = "uncategorized" + + metadata = ToolMetadata(name=resolved_name, category=resolved_category) + setattr(func, TOOL_ATTR, metadata) + return func - # Determine name - tool_name = name or f"{tool_category}_{func.__name__}" + return decorator - # Check for duplicate names - if tool_name in _TOOL_NAMES: - raise ValueError(f"Duplicate tool name: '{tool_name}' already registered") - _TOOL_NAMES.add(tool_name) - TOOL_REGISTRY.append(ToolRegistration(tool_name, tool_category, func)) - return func +def is_tool(func: Any) -> bool: + """Check if a function is marked as a tool.""" + return hasattr(func, TOOL_ATTR) - return decorator + +def get_tool_name(func: Callable[..., Any]) -> str: + """Get tool name from a decorated function.""" + metadata = getattr(func, TOOL_ATTR, None) + if isinstance(metadata, ToolMetadata): + return metadata.name + return func.__name__ -def _get_category_from_path(func: Callable[..., Any]) -> str: - """Get category from function's file immediate parent folder name.""" - try: - file_path = Path(inspect.getfile(func)) - return file_path.parent.name - except (TypeError, OSError): - return "uncategorized" +def get_tool_category(func: Callable[..., Any]) -> str: + """Get tool category from a decorated function.""" + metadata = getattr(func, TOOL_ATTR, None) + if isinstance(metadata, ToolMetadata): + return metadata.category + return "uncategorized" diff --git a/src/humcp/registry.py b/src/humcp/registry.py deleted file mode 100644 index 2b44923..0000000 --- a/src/humcp/registry.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Tool registry - stores all registered tools. - -This module provides the global registry for MCP tools. Tools are registered -at module import time using the @tool decorator from the decorator module. - -Thread Safety: - TOOL_REGISTRY and _TOOL_NAMES are module-level globals that are populated - at import time. They are safe for concurrent reads after server startup. - Dynamic tool registration during runtime is not thread-safe and should - be avoided in production. -""" - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -__all__ = ["ToolRegistration", "TOOL_REGISTRY", "_TOOL_NAMES"] - - -@dataclass(frozen=True) -class ToolRegistration: - """A registered tool. - - Attributes: - name: Unique identifier for the tool. - category: Category grouping (e.g., 'google', 'local', 'data'). - func: The async function that implements the tool. - """ - - name: str - category: str - func: Callable[..., Any] - - -# Global registry populated at import time by @tool decorators -TOOL_REGISTRY: list[ToolRegistration] = [] -_TOOL_NAMES: set[str] = set() diff --git a/src/humcp/routes.py b/src/humcp/routes.py index 3e12c6e..2848582 100644 --- a/src/humcp/routes.py +++ b/src/humcp/routes.py @@ -1,6 +1,5 @@ """REST route generation for tools.""" -import inspect import logging from pathlib import Path from typing import Any @@ -8,7 +7,7 @@ from fastapi import Body, FastAPI, HTTPException from pydantic import BaseModel, Field, create_model -from src.humcp.registry import TOOL_REGISTRY, ToolRegistration +from src.humcp.decorator import RegisteredTool from src.humcp.schemas import ( CategorySummary, GetCategoryResponse, @@ -25,52 +24,43 @@ def _format_tag(category: str) -> str: - """Format category name as a display-friendly tag. - - Converts snake_case or lowercase to Title Case. - E.g., "google" -> "Google", "local_files" -> "Local Files" - """ + """Format category as display tag: 'local_files' -> 'Local Files'.""" return category.replace("_", " ").title() -def register_routes(app: FastAPI, tools_path: Path | None = None) -> None: - """Register REST routes from TOOL_REGISTRY. +def register_routes( + app: FastAPI, + tools_path: Path, + tools: list[RegisteredTool], +) -> None: + """Register REST routes for tools.""" + # Build lookups + categories = _build_categories(tools) + tool_lookup = {(t.category, t.tool.name): t for t in tools} - Args: - app: FastAPI application. - tools_path: Path to tools directory for skill discovery. - """ # Tool execution endpoints - for reg in TOOL_REGISTRY: + for reg in tools: _add_tool_route(app, reg) - # Build cached lookup structures once at startup - categories = _build_categories() - tool_lookup = _build_tool_lookup() - total_tools = len(TOOL_REGISTRY) - - # Discover skills from SKILL.md files - if tools_path is None: - tools_path = Path(__file__).parent.parent / "tools" + # Discover skills skills = discover_skills(tools_path) # Info endpoints @app.get("/tools", tags=["Info"], response_model=ListToolsResponse) async def list_tools() -> ListToolsResponse: return ListToolsResponse( - total_tools=total_tools, + total_tools=len(tools), categories={ - k: CategorySummary( - count=len(v), - tools=[ToolSummary(**t) for t in v], + cat: CategorySummary( + count=len(items), + tools=[ToolSummary(**t) for t in items], skill=SkillMetadata( - name=skills[k].name, - description=skills[k].description, + name=skills[cat].name, description=skills[cat].description ) - if k in skills + if cat in skills else None, ) - for k, v in sorted(categories.items()) + for cat, items in sorted(categories.items()) }, ) @@ -84,9 +74,7 @@ async def get_category(category: str) -> GetCategoryResponse: count=len(categories[category]), tools=[ToolSummary(**t) for t in categories[category]], skill=SkillFull( - name=skill.name, - description=skill.description, - content=skill.content, + name=skill.name, description=skill.description, content=skill.content ) if skill else None, @@ -96,144 +84,77 @@ async def get_category(category: str) -> GetCategoryResponse: "/tools/{category}/{tool_name}", tags=["Info"], response_model=GetToolResponse ) async def get_tool(category: str, tool_name: str) -> GetToolResponse: - # Try exact match first, then with category prefix - reg = tool_lookup.get((category, tool_name)) or tool_lookup.get( - (category, f"{category}_{tool_name}") - ) + reg = tool_lookup.get((category, tool_name)) if not reg: - raise HTTPException( - 404, f"Tool '{tool_name}' not found in category '{category}'" - ) - schema = _get_schema_from_func(reg.func) + raise HTTPException(404, f"Tool '{tool_name}' not found in '{category}'") return GetToolResponse( - name=reg.name, + name=reg.tool.name, category=reg.category, - description=reg.func.__doc__, - endpoint=f"/tools/{reg.name}", - input_schema=InputSchema(**schema), + description=reg.tool.description, + endpoint=f"/tools/{reg.tool.name}", + input_schema=InputSchema(**reg.tool.parameters), + output_schema=reg.tool.output_schema, ) -def _add_tool_route(app: FastAPI, reg: ToolRegistration) -> None: +def _add_tool_route(app: FastAPI, reg: RegisteredTool) -> None: """Add POST /tools/{name} endpoint for a tool.""" - schema = _get_schema_from_func(reg.func) - InputModel = _create_model(schema, f"{_pascal(reg.name)}Input") + tool = reg.tool + InputModel = _create_model_from_schema( + tool.parameters, f"{_pascal(tool.name)}Input" + ) - async def endpoint(data: BaseModel = Body(...)) -> dict[str, Any]: # type: ignore[assignment] + async def endpoint(data: BaseModel = Body(...)) -> dict[str, Any]: try: params = data.model_dump(exclude_none=True) - result = await reg.func(**params) + result = await tool.fn(**params) return {"result": result} except HTTPException: raise except Exception as e: - logger.exception("Tool %s failed", reg.name) - # Don't expose internal error details to users + logger.exception("Tool %s failed", tool.name) raise HTTPException(500, "Tool execution failed") from e endpoint.__annotations__["data"] = InputModel app.add_api_route( - f"/tools/{reg.name}", + f"/tools/{tool.name}", endpoint, methods=["POST"], - summary=reg.func.__doc__ or reg.name, + summary=tool.description or tool.name, tags=[_format_tag(reg.category)], - name=reg.name, + name=tool.name, ) -def _build_categories() -> dict[str, list[dict[str, Any]]]: - """Build category map from TOOL_REGISTRY (called once at startup).""" +def _build_categories(tools: list[RegisteredTool]) -> dict[str, list[dict[str, Any]]]: + """Build category -> tools map.""" cats: dict[str, list[dict[str, Any]]] = {} - for reg in TOOL_REGISTRY: + for reg in tools: cats.setdefault(reg.category, []).append( { - "name": reg.name, - "description": reg.func.__doc__, - "endpoint": f"/tools/{reg.name}", + "name": reg.tool.name, + "description": reg.tool.description, + "endpoint": f"/tools/{reg.tool.name}", } ) return cats -def build_openapi_tags() -> list[dict[str, str]]: - """Build OpenAPI tag metadata for all tool categories. - - Returns a list of tag definitions with name and description, - sorted alphabetically by tag name. Includes the "Info" tag first. - """ - # Collect unique categories - categories = sorted({reg.category for reg in TOOL_REGISTRY}) - - # Build tag metadata - tags = [ - {"name": "Info", "description": "Server and tool information endpoints"}, - ] - - for category in categories: - # Count tools in this category - tool_count = sum(1 for reg in TOOL_REGISTRY if reg.category == category) +def build_openapi_tags(tools: list[RegisteredTool]) -> list[dict[str, str]]: + """Build OpenAPI tag metadata.""" + categories = sorted({reg.category for reg in tools}) + tags = [{"name": "Info", "description": "Server and tool information"}] + for cat in categories: + count = sum(1 for reg in tools if reg.category == cat) tags.append( { - "name": _format_tag(category), - "description": f"{_format_tag(category)} tools ({tool_count} endpoints)", + "name": _format_tag(cat), + "description": f"{_format_tag(cat)} tools ({count} endpoints)", } ) - return tags -def _build_tool_lookup() -> dict[tuple[str, str], ToolRegistration]: - """Build (category, name) -> ToolRegistration lookup (called once at startup).""" - return {(reg.category, reg.name): reg for reg in TOOL_REGISTRY} - - -def _get_schema_from_func(func: Any) -> dict[str, Any]: - """Extract JSON schema from function type hints.""" - - sig = inspect.signature(func) - properties: dict[str, Any] = {} - required: list[str] = [] - - type_map = { - str: "string", - int: "integer", - float: "number", - bool: "boolean", - list: "array", - dict: "object", - } - - for name, param in sig.parameters.items(): - if param.annotation != inspect.Parameter.empty: - # Get base type (handle Optional, etc.) - ann = param.annotation - # Skip parameters that are explicitly annotated as None. - # Optional[...] / Union[..., None] are handled in the Union logic below. - if ann is type(None): - continue - - # Handle Union types (e.g., str | None) - args = getattr(ann, "__args__", None) - if args: - ann = next((a for a in args if a is not type(None)), ann) - - json_type = type_map.get(ann) - if json_type is None: - logger.warning( - "Unknown type annotation %s for parameter %s, defaulting to string", - ann, - name, - ) - json_type = "string" - properties[name] = {"type": json_type} - - if param.default == inspect.Parameter.empty: - required.append(name) - - return {"type": "object", "properties": properties, "required": required} - - def _pascal(name: str) -> str: """Convert to PascalCase.""" name = name.replace("_", " ").replace("-", " ").replace(".", " ") @@ -241,13 +162,13 @@ def _pascal(name: str) -> str: return f"Model{name}" if name and not name[0].isalpha() else name or "Model" -def _create_model(schema: dict[str, Any], name: str) -> type[BaseModel]: +def _create_model_from_schema(schema: dict[str, Any], name: str) -> type[BaseModel]: """Create Pydantic model from JSON schema.""" if schema.get("type") != "object": - return create_model(name, value=(Any, ...)) # type: ignore[call-overload] + return create_model(name, value=(Any, ...)) - props: dict[str, Any] = schema.get("properties", {}) - required: list[str] = schema.get("required", []) + props = schema.get("properties", {}) + required = schema.get("required", []) fields: dict[str, Any] = {} type_map = { @@ -273,4 +194,4 @@ def _create_model(schema: dict[str, Any], name: str) -> type[BaseModel]: else (ftype | None, None) ) - return create_model(name, **fields) # type: ignore[call-overload] + return create_model(name, **fields) diff --git a/src/humcp/schemas.py b/src/humcp/schemas.py index 1484af1..5f5d3e1 100644 --- a/src/humcp/schemas.py +++ b/src/humcp/schemas.py @@ -76,3 +76,6 @@ class GetToolResponse(BaseModel): description: str | None = Field(None, description="Tool description") endpoint: str = Field(..., description="API endpoint path") input_schema: InputSchema = Field(..., description="JSON Schema for tool input") + output_schema: dict[str, Any] | None = Field( + description="JSON schema for tool output", default=None + ) diff --git a/src/humcp/server.py b/src/humcp/server.py index a19bb9e..4620f21 100644 --- a/src/humcp/server.py +++ b/src/humcp/server.py @@ -1,46 +1,39 @@ """HuMCP Server - app creation with REST and MCP endpoints.""" import importlib.util +import inspect import logging import os import sys -from collections.abc import Callable from contextlib import asynccontextmanager from pathlib import Path -from typing import Any +from types import ModuleType from fastapi import FastAPI from fastmcp import FastMCP -from src.humcp.registry import TOOL_REGISTRY +from src.humcp.config import DEFAULT_CONFIG_PATH, filter_tools, load_config +from src.humcp.decorator import ( + RegisteredTool, + get_tool_category, + get_tool_name, + is_tool, +) from src.humcp.routes import build_openapi_tags, register_routes logger = logging.getLogger("humcp") -def _discover_tools(tools_path: Path) -> int: - """Auto-discover and import tool modules from a directory. - - Recursively scans the directory for Python files (excluding those starting - with '_') and imports them, triggering any @tool decorators. - - Args: - tools_path: Directory to scan for tool modules. - - Returns: - Number of modules successfully loaded. - """ +def _load_modules(tools_path: Path) -> list[ModuleType]: + """Load Python modules from a directory.""" if not tools_path.exists(): - logger.debug("Tools path does not exist: %s", tools_path) - return 0 + return [] - loaded = 0 + modules: list[ModuleType] = [] for file_path in sorted(tools_path.rglob("*.py")): if file_path.name.startswith("_"): continue - # Create unique module name based on relative path - # e.g., tools/local/calculator.py -> humcp_tools.local.calculator relative = file_path.relative_to(tools_path) module_name = ( f"humcp_tools.{relative.with_suffix('').as_posix().replace('/', '.')}" @@ -48,79 +41,97 @@ def _discover_tools(tools_path: Path) -> int: try: spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None or spec.loader is None: - logger.warning("Could not create spec for %s", file_path) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + modules.append(module) + logger.debug("Loaded module: %s", module_name) + except Exception as e: + logger.warning("Error loading %s: %s", file_path.name, e) + + return modules + + +def _discover_and_register( + mcp: FastMCP, modules: list[ModuleType] +) -> list[RegisteredTool]: + """Discover @tool functions and register with FastMCP. + + Returns list of RegisteredTool (FunctionTool + category). + """ + tools: list[RegisteredTool] = [] + seen_names: set[str] = set() + + for module in modules: + for _, func in inspect.getmembers(module, inspect.isfunction): + if not is_tool(func): continue - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - loaded += 1 - logger.debug("Loaded tool module: %s", module_name) - except ImportError as e: - logger.warning("Import error loading %s: %s", file_path.name, e) - except SyntaxError as e: - logger.warning("Syntax error in %s: %s", file_path.name, e) - except Exception as e: - logger.warning("Unexpected error loading %s: %s", file_path.name, e) + # Get tool metadata from decorator + tool_name = get_tool_name(func) + category = get_tool_category(func) + + # Check for duplicates + if tool_name in seen_names: + logger.warning("Duplicate tool '%s', skipping", tool_name) + continue - return loaded + seen_names.add(tool_name) + + # Register with FastMCP using custom name - returns FunctionTool + fn_tool = mcp.tool(name=tool_name)(func) + tools.append(RegisteredTool(tool=fn_tool, category=category)) + logger.debug("Registered: %s (category: %s)", fn_tool.name, category) + + return tools def create_app( tools_path: Path | str | None = None, + config_path: Path | str | None = None, title: str = "HuMCP Server", description: str = "REST and MCP endpoints for tools", version: str = "1.0.0", ) -> FastAPI: - """Create FastAPI app with REST (/tools) and MCP (/mcp) endpoints. - - Args: - tools_path: Path to tools directory. Defaults to src/tools/. - title: App title for OpenAPI docs. - description: App description. - version: App version. - - Returns: - FastAPI app with REST at /tools/* and MCP at /mcp - """ - # Auto-discover tool modules + """Create FastAPI app with REST (/tools) and MCP (/mcp) endpoints.""" path = Path(tools_path) if tools_path else Path(__file__).parent.parent / "tools" - loaded = _discover_tools(path) - logger.info("Discovered %d tool modules from %s", loaded, path) # Create MCP server mcp = FastMCP("HuMCP Server") - seen: set[Callable[..., Any]] = set() - for reg in TOOL_REGISTRY: - if reg.func not in seen: - seen.add(reg.func) - mcp.tool(name=reg.name)(reg.func) - logger.info("Registered MCP tool: %s", reg.name) + # Load modules and register tools with FastMCP + modules = _load_modules(path) + tools = _discover_and_register(mcp, modules) + logger.info("Registered %d tools from %s", len(tools), path) + + # Filter tools by config + cfg_path = Path(config_path) if config_path else DEFAULT_CONFIG_PATH + config = load_config(cfg_path) + filtered = filter_tools(config, tools, validate=True) + logger.info("Filtered: %d/%d tools", len(filtered), len(tools)) + + # Setup MCP HTTP app mcp_http_app = mcp.http_app(path="/") @asynccontextmanager - async def lifespan(_app: FastAPI): + async def lifespan(_: FastAPI): async with mcp_http_app.router.lifespan_context(mcp_http_app): yield - # Build OpenAPI tags from discovered categories - openapi_tags = build_openapi_tags() - + # Create FastAPI app app = FastAPI( title=title, description=description, version=version, lifespan=lifespan, - openapi_tags=openapi_tags, + openapi_tags=build_openapi_tags(filtered), ) - # Register REST routes from TOOL_REGISTRY - register_routes(app) - logger.info("Registered %d REST endpoints", len(TOOL_REGISTRY)) + # Register REST routes + register_routes(app, tools_path=path, tools=filtered) - # Root info endpoint + # Root endpoint mcp_url = os.getenv("MCP_SERVER_URL", "http://0.0.0.0:8080/mcp") @app.get("/", tags=["Info"]) @@ -129,10 +140,9 @@ async def root(): "name": title, "version": version, "mcp_server": mcp_url, - "tools_count": len(TOOL_REGISTRY), + "tools_count": len(filtered), "endpoints": {"docs": "/docs", "tools": "/tools", "mcp": "/mcp"}, } - # Mount MCP app.mount("/mcp", mcp_http_app) return app diff --git a/src/humcp/skills.py b/src/humcp/skills.py index 1d1d198..9c19def 100644 --- a/src/humcp/skills.py +++ b/src/humcp/skills.py @@ -3,6 +3,7 @@ import logging import re from dataclasses import dataclass +from functools import lru_cache from pathlib import Path logger = logging.getLogger("humcp.skills") @@ -46,9 +47,12 @@ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]: return frontmatter, content +@lru_cache(maxsize=100) def discover_skills(tools_path: Path) -> dict[str, Skill]: """Discover all SKILL.md files in tool directories. + Results are cached to avoid repeated filesystem scans. + Args: tools_path: Path to the tools directory. diff --git a/src/tools/__init__.py b/src/tools/__init__.py index 9b6e3aa..2e46cd5 100644 --- a/src/tools/__init__.py +++ b/src/tools/__init__.py @@ -13,9 +13,9 @@ Example: from src.humcp.decorator import tool - @tool("my_tool", category="custom") + @tool(category="custom") # or @tool() to auto-detect from folder async def my_tool(param: str) -> dict: - '''Tool description.''' + '''Tool description (used by FastMCP).''' return {"success": True, "data": param} """ diff --git a/src/tools/data/csv.py b/src/tools/data/csv.py index 355c73e..8a6531a 100644 --- a/src/tools/data/csv.py +++ b/src/tools/data/csv.py @@ -68,7 +68,7 @@ def set_csv_files(csv_files: list): _csv_manager = CSVManager(csv_files) -@tool("list_csv_files") +@tool() async def list_csv_files() -> dict: """List all available CSV files.""" try: @@ -84,7 +84,7 @@ async def list_csv_files() -> dict: return {"success": False, "error": str(e)} -@tool("read_csv_file") +@tool() async def read_csv_file(csv_name: str, row_limit: int | None = None) -> dict: """Read contents of a CSV file.""" try: @@ -108,7 +108,7 @@ async def read_csv_file(csv_name: str, row_limit: int | None = None) -> dict: return {"success": False, "error": str(e)} -@tool("get_csv_columns") +@tool() async def get_csv_columns(csv_name: str) -> dict: """Get column names from a CSV file.""" try: @@ -148,7 +148,7 @@ def _validate_sql_query(query: str) -> tuple[bool, str]: return True, "" -@tool("query_csv_file") +@tool() async def query_csv_file(csv_name: str, sql_query: str) -> dict: """Execute SQL query on CSV file using DuckDB. @@ -203,7 +203,7 @@ async def query_csv_file(csv_name: str, sql_query: str) -> dict: return {"success": False, "error": str(e)} -@tool("describe_csv_file") +@tool() async def describe_csv_file(csv_name: str) -> dict: """Get detailed information about a CSV file.""" try: @@ -242,7 +242,7 @@ async def describe_csv_file(csv_name: str) -> dict: return {"success": False, "error": str(e)} -@tool("add_csv_file") +@tool() async def add_csv_file(file_path: str) -> dict: """Add a CSV file to available files.""" try: @@ -266,7 +266,7 @@ async def add_csv_file(file_path: str) -> dict: return {"success": False, "error": str(e)} -@tool("remove_csv_file") +@tool() async def remove_csv_file(csv_name: str) -> dict: """Remove a CSV file from available files.""" try: diff --git a/src/tools/data/pandas.py b/src/tools/data/pandas.py index b2e2a99..4d6bbce 100644 --- a/src/tools/data/pandas.py +++ b/src/tools/data/pandas.py @@ -229,7 +229,7 @@ def get_dataframe_manager(): return _dataframe_manager -@tool("create_pandas_dataframe") +@tool() async def create_pandas_dataframe( dataframe_name: str, create_using_function: str, @@ -322,7 +322,7 @@ async def create_pandas_dataframe( return {"success": False, "error": f"Error creating DataFrame: {str(e)}"} -@tool("run_dataframe_operation") +@tool() async def run_dataframe_operation( dataframe_name: str, operation: str, @@ -412,7 +412,7 @@ async def run_dataframe_operation( return {"success": False, "error": f"Error running operation: {str(e)}"} -@tool("list_dataframes") +@tool() async def list_dataframes() -> dict: """ List all DataFrames currently stored in memory. @@ -443,7 +443,7 @@ async def list_dataframes() -> dict: return {"success": False, "error": str(e)} -@tool("get_dataframe_info") +@tool() async def get_dataframe_info(dataframe_name: str) -> dict: """ Get detailed information about a specific DataFrame. @@ -480,7 +480,7 @@ async def get_dataframe_info(dataframe_name: str) -> dict: return {"success": False, "error": str(e)} -@tool("delete_dataframe") +@tool() async def delete_dataframe(dataframe_name: str) -> dict: """ Delete a DataFrame from memory. @@ -511,7 +511,7 @@ async def delete_dataframe(dataframe_name: str) -> dict: return {"success": False, "error": str(e)} -@tool("export_dataframe") +@tool() async def export_dataframe( dataframe_name: str, export_function: str, diff --git a/src/tools/files/pdf_to_markdown.py b/src/tools/files/pdf_to_markdown.py index 875b493..b39dedb 100644 --- a/src/tools/files/pdf_to_markdown.py +++ b/src/tools/files/pdf_to_markdown.py @@ -15,7 +15,7 @@ logger = logging.getLogger("humcp.tools.pdf_to_markdown") -@tool("convert_to_markdown") +@tool() async def convert_to_markdown(pdf_path: str) -> dict: """ Convert a PDF file to Markdown format. diff --git a/src/tools/google/calendar.py b/src/tools/google/calendar.py index e506313..eb6a055 100644 --- a/src/tools/google/calendar.py +++ b/src/tools/google/calendar.py @@ -14,8 +14,8 @@ CALENDAR_FULL_SCOPES = [SCOPES["calendar"]] -@tool("google_calendar_list") -async def list_calendars() -> dict: +@tool() +async def google_calendar_list() -> dict: """List all calendars accessible to the user. Returns a list of calendars with their IDs, names, descriptions, @@ -53,8 +53,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_calendar_events") -async def events( +@tool() +async def google_calendar_events( calendar_id: str = "primary", days_ahead: int = 7, max_results: int = 50, @@ -126,8 +126,8 @@ def _list_events(): return {"success": False, "error": str(e)} -@tool("google_calendar_create_event") -async def create_event( +@tool() +async def google_calendar_create_event( title: str, start_time: str, end_time: str, @@ -192,8 +192,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_calendar_delete_event") -async def delete_event( +@tool() +async def google_calendar_delete_event( event_id: str, calendar_id: str = "primary", ) -> dict: diff --git a/src/tools/google/chat.py b/src/tools/google/chat.py index b367be2..4069eff 100644 --- a/src/tools/google/chat.py +++ b/src/tools/google/chat.py @@ -12,8 +12,10 @@ CHAT_FULL_SCOPES = [SCOPES["chat_spaces"], SCOPES["chat_messages"]] -@tool("google_chat_list_spaces") -async def list_spaces(space_type: str = "all", max_results: int = 100) -> dict: +@tool() +async def google_chat_list_spaces( + space_type: str = "all", max_results: int = 100 +) -> dict: """List Google Chat spaces (rooms and direct messages). Returns all accessible spaces, optionally filtered by type. @@ -59,8 +61,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_chat_get_space") -async def get_space(space_name: str) -> dict: +@tool() +async def google_chat_get_space(space_name: str) -> dict: """Get details about a specific space. Args: @@ -92,8 +94,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_chat_get_messages") -async def get_messages( +@tool() +async def google_chat_get_messages( space_name: str, max_results: int = 25, order_by: str = "createTime desc", @@ -145,8 +147,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_chat_get_message") -async def get_message(message_name: str) -> dict: +@tool() +async def google_chat_get_message(message_name: str) -> dict: """Get a specific message by name. Args: @@ -179,8 +181,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_chat_send_message") -async def send_message( +@tool() +async def google_chat_send_message( space_name: str, text: str, thread_key: str = "", diff --git a/src/tools/google/docs.py b/src/tools/google/docs.py index b3097eb..0727e7c 100644 --- a/src/tools/google/docs.py +++ b/src/tools/google/docs.py @@ -12,8 +12,8 @@ DOCS_FULL_SCOPES = [SCOPES["docs"], SCOPES["drive"]] -@tool("google_docs_search") -async def search_docs(query: str, max_results: int = 25) -> dict: +@tool() +async def google_docs_search(query: str, max_results: int = 25) -> dict: """Search for Google Docs by name. Searches for documents whose names contain the query string. @@ -65,8 +65,8 @@ def _search(): return {"success": False, "error": str(e)} -@tool("google_docs_get_content") -async def get_doc_content(document_id: str) -> dict: +@tool() +async def google_docs_get_content(document_id: str) -> dict: """Get the content of a Google Doc. Extracts all text content from a document. @@ -106,8 +106,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_docs_create") -async def create_doc(title: str, content: str = "") -> dict: +@tool() +async def google_docs_create(title: str, content: str = "") -> dict: """Create a new Google Doc. Creates an empty document with the specified title, optionally with initial content. @@ -149,8 +149,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_docs_append_text") -async def append_text(document_id: str, text: str) -> dict: +@tool() +async def google_docs_append_text(document_id: str, text: str) -> dict: """Append text to the end of a Google Doc. Adds text content at the end of the document. @@ -188,8 +188,8 @@ def _append(): return {"success": False, "error": str(e)} -@tool("google_docs_find_replace") -async def find_and_replace( +@tool() +async def google_docs_find_replace( document_id: str, find_text: str, replace_text: str, match_case: bool = False ) -> dict: """Find and replace text in a Google Doc. @@ -243,8 +243,8 @@ def _replace(): return {"success": False, "error": str(e)} -@tool("google_docs_list_in_folder") -async def list_docs_in_folder(folder_id: str, max_results: int = 50) -> dict: +@tool() +async def google_docs_list_in_folder(folder_id: str, max_results: int = 50) -> dict: """List all Google Docs in a specific folder. Returns all documents within the specified Drive folder. diff --git a/src/tools/google/drive.py b/src/tools/google/drive.py index 312fd97..c66466e 100644 --- a/src/tools/google/drive.py +++ b/src/tools/google/drive.py @@ -15,8 +15,8 @@ DRIVE_READONLY_SCOPES = [SCOPES["drive_readonly"]] -@tool("google_drive_list") -async def list_files( +@tool() +async def google_drive_list( folder_id: str = "root", max_results: int = 50, file_type: str = "", @@ -77,8 +77,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_drive_search") -async def search(query: str, max_results: int = 50) -> dict: +@tool() +async def google_drive_search(query: str, max_results: int = 50) -> dict: """Search for files in Google Drive. Performs a full-text search across all accessible files. @@ -138,8 +138,8 @@ def _search(): return {"success": False, "error": str(e)} -@tool("google_drive_get_file") -async def get_file(file_id: str) -> dict: +@tool() +async def google_drive_get_file(file_id: str) -> dict: """Get detailed metadata for a file. Retrieves comprehensive information about a specific file. @@ -190,8 +190,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_drive_read_text_file") -async def read_text_file(file_id: str) -> dict: +@tool() +async def google_drive_read_text_file(file_id: str) -> dict: """Read the content of a text-based file from Google Drive. Supports Google Docs (exported as plain text), Google Sheets (exported as CSV), diff --git a/src/tools/google/forms.py b/src/tools/google/forms.py index eb33673..8dee7ee 100644 --- a/src/tools/google/forms.py +++ b/src/tools/google/forms.py @@ -13,8 +13,8 @@ FORMS_RESPONSES_SCOPES = [SCOPES["forms_responses"]] -@tool("google_forms_list_forms") -async def list_forms(max_results: int = 25) -> dict: +@tool() +async def google_forms_list_forms(max_results: int = 25) -> dict: """List Google Forms accessible to the user. Returns recent forms ordered by modification time. @@ -62,8 +62,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_forms_get_form") -async def get_form(form_id: str) -> dict: +@tool() +async def google_forms_get_form(form_id: str) -> dict: """Get details about a form including questions. Returns form metadata and all questions with their types and options. @@ -134,8 +134,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_forms_create_form") -async def create_form(title: str, document_title: str = "") -> dict: +@tool() +async def google_forms_create_form(title: str, document_title: str = "") -> dict: """Create a new Google Form. Creates an empty form with the specified title. @@ -175,8 +175,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_forms_list_responses") -async def list_form_responses(form_id: str, max_results: int = 100) -> dict: +@tool() +async def google_forms_list_responses(form_id: str, max_results: int = 100) -> dict: """List responses submitted to a form. Returns summary information about form responses. @@ -221,8 +221,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_forms_get_response") -async def get_form_response(form_id: str, response_id: str) -> dict: +@tool() +async def google_forms_get_response(form_id: str, response_id: str) -> dict: """Get a specific form response with all answers. Returns detailed answer data for a single form submission. diff --git a/src/tools/google/gmail.py b/src/tools/google/gmail.py index 96eeff6..3e92b89 100644 --- a/src/tools/google/gmail.py +++ b/src/tools/google/gmail.py @@ -15,8 +15,8 @@ GMAIL_SEND_SCOPES = [SCOPES["gmail_send"]] -@tool("google_gmail_search") -async def search(query: str = "", max_results: int = 10) -> dict: +@tool() +async def google_gmail_search(query: str = "", max_results: int = 10) -> dict: """Search Gmail messages. Searches for emails matching the query using Gmail's search syntax. @@ -79,8 +79,8 @@ def _search(): return {"success": False, "error": str(e)} -@tool("google_gmail_read") -async def read(message_id: str) -> dict: +@tool() +async def google_gmail_read(message_id: str) -> dict: """Read the full content of a Gmail message. Retrieves the complete email including headers, body text, and labels. @@ -146,8 +146,8 @@ def _read(): return {"success": False, "error": str(e)} -@tool("google_gmail_send") -async def send( +@tool() +async def google_gmail_send( to: str, subject: str, body: str, @@ -202,8 +202,8 @@ def _send(): return {"success": False, "error": str(e)} -@tool("google_gmail_labels") -async def labels() -> dict: +@tool() +async def google_gmail_labels() -> dict: """List all Gmail labels. Returns all labels in the user's mailbox including system labels diff --git a/src/tools/google/sheets.py b/src/tools/google/sheets.py index 05ccb84..dd85baf 100644 --- a/src/tools/google/sheets.py +++ b/src/tools/google/sheets.py @@ -12,8 +12,8 @@ SHEETS_FULL_SCOPES = [SCOPES["sheets"], SCOPES["drive"]] -@tool("google_sheets_list_spreadsheets") -async def list_spreadsheets(max_results: int = 25) -> dict: +@tool() +async def google_sheets_list_spreadsheets(max_results: int = 25) -> dict: """List Google Spreadsheets accessible to the user. Returns recent spreadsheets ordered by modification time. @@ -63,8 +63,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_sheets_get_info") -async def get_spreadsheet_info(spreadsheet_id: str) -> dict: +@tool() +async def google_sheets_get_info(spreadsheet_id: str) -> dict: """Get metadata about a spreadsheet. Returns information about all sheets in the spreadsheet including dimensions. @@ -112,8 +112,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_sheets_read_values") -async def read_sheet_values( +@tool() +async def google_sheets_read_values( spreadsheet_id: str, range_notation: str = "Sheet1" ) -> dict: """Read values from a spreadsheet range. @@ -153,8 +153,8 @@ def _read(): return {"success": False, "error": str(e)} -@tool("google_sheets_write_values") -async def write_sheet_values( +@tool() +async def google_sheets_write_values( spreadsheet_id: str, range_notation: str, values: list, @@ -206,8 +206,8 @@ def _write(): return {"success": False, "error": str(e)} -@tool("google_sheets_append_values") -async def append_sheet_values( +@tool() +async def google_sheets_append_values( spreadsheet_id: str, range_notation: str, values: list, @@ -260,8 +260,10 @@ def _append(): return {"success": False, "error": str(e)} -@tool("google_sheets_create_spreadsheet") -async def create_spreadsheet(title: str, sheet_names: list[str] | None = None) -> dict: +@tool() +async def google_sheets_create_spreadsheet( + title: str, sheet_names: list[str] | None = None +) -> dict: """Create a new Google Spreadsheet. Creates a spreadsheet with optional named sheets. @@ -304,8 +306,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_sheets_add_sheet") -async def add_sheet(spreadsheet_id: str, sheet_title: str) -> dict: +@tool() +async def google_sheets_add_sheet(spreadsheet_id: str, sheet_title: str) -> dict: """Add a new sheet to an existing spreadsheet. Creates a new tab/sheet within the spreadsheet. @@ -342,8 +344,8 @@ def _add(): return {"success": False, "error": str(e)} -@tool("google_sheets_clear_values") -async def clear_sheet_values(spreadsheet_id: str, range_notation: str) -> dict: +@tool() +async def google_sheets_clear_values(spreadsheet_id: str, range_notation: str) -> dict: """Clear values from a spreadsheet range. Removes all values from cells in the specified range without deleting the cells. diff --git a/src/tools/google/slides.py b/src/tools/google/slides.py index 6f6730b..7b6cd29 100644 --- a/src/tools/google/slides.py +++ b/src/tools/google/slides.py @@ -12,8 +12,8 @@ SLIDES_FULL_SCOPES = [SCOPES["slides"], SCOPES["drive"]] -@tool("google_slides_list_presentations") -async def list_presentations(max_results: int = 25) -> dict: +@tool() +async def google_slides_list_presentations(max_results: int = 25) -> dict: """List Google Slides presentations accessible to the user. Returns recent presentations ordered by modification time. @@ -63,8 +63,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_slides_get_presentation") -async def get_presentation(presentation_id: str) -> dict: +@tool() +async def google_slides_get_presentation(presentation_id: str) -> dict: """Get details about a presentation including slides content. Returns presentation metadata and text content from all slides. @@ -132,8 +132,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_slides_create_presentation") -async def create_presentation(title: str) -> dict: +@tool() +async def google_slides_create_presentation(title: str) -> dict: """Create a new Google Slides presentation. Creates an empty presentation with one blank slide. @@ -166,8 +166,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_slides_add_slide") -async def add_slide( +@tool() +async def google_slides_add_slide( presentation_id: str, layout: str = "BLANK", insert_at: int = -1, @@ -224,8 +224,8 @@ def _add(): return {"success": False, "error": str(e)} -@tool("google_slides_add_text") -async def add_text_to_slide( +@tool() +async def google_slides_add_text( presentation_id: str, slide_id: str, text: str, @@ -306,8 +306,8 @@ def _add_text(): return {"success": False, "error": str(e)} -@tool("google_slides_get_thumbnail") -async def get_slide_thumbnail( +@tool() +async def google_slides_get_thumbnail( presentation_id: str, slide_id: str, size: str = "MEDIUM", diff --git a/src/tools/google/tasks.py b/src/tools/google/tasks.py index df04455..effde65 100644 --- a/src/tools/google/tasks.py +++ b/src/tools/google/tasks.py @@ -12,8 +12,8 @@ TASKS_FULL_SCOPES = [SCOPES["tasks"]] -@tool("google_tasks_list_task_lists") -async def list_task_lists(max_results: int = 100) -> dict: +@tool() +async def google_tasks_list_task_lists(max_results: int = 100) -> dict: """List all task lists for the user. Returns all task lists including the default list. @@ -50,8 +50,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_tasks_get_task_list") -async def get_task_list(task_list_id: str) -> dict: +@tool() +async def google_tasks_get_task_list(task_list_id: str) -> dict: """Get details of a specific task list. Args: @@ -79,8 +79,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_tasks_create_task_list") -async def create_task_list(title: str) -> dict: +@tool() +async def google_tasks_create_task_list(title: str) -> dict: """Create a new task list. Args: @@ -108,8 +108,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_tasks_delete_task_list") -async def delete_task_list(task_list_id: str) -> dict: +@tool() +async def google_tasks_delete_task_list(task_list_id: str) -> dict: """Delete a task list. Permanently removes the task list and all its tasks. @@ -135,8 +135,8 @@ def _delete(): return {"success": False, "error": str(e)} -@tool("google_tasks_list_tasks") -async def list_tasks( +@tool() +async def google_tasks_list_tasks( task_list_id: str = "@default", show_completed: bool = True, show_hidden: bool = False, @@ -195,8 +195,8 @@ def _list(): return {"success": False, "error": str(e)} -@tool("google_tasks_get_task") -async def get_task(task_list_id: str, task_id: str) -> dict: +@tool() +async def google_tasks_get_task(task_list_id: str, task_id: str) -> dict: """Get details of a specific task. Args: @@ -231,8 +231,8 @@ def _get(): return {"success": False, "error": str(e)} -@tool("google_tasks_create_task") -async def create_task( +@tool() +async def google_tasks_create_task( task_list_id: str = "@default", title: str = "", notes: str = "", @@ -284,8 +284,8 @@ def _create(): return {"success": False, "error": str(e)} -@tool("google_tasks_update_task") -async def update_task( +@tool() +async def google_tasks_update_task( task_list_id: str, task_id: str, title: str = "", @@ -347,8 +347,8 @@ def _update(): return {"success": False, "error": str(e)} -@tool("google_tasks_delete_task") -async def delete_task(task_list_id: str, task_id: str) -> dict: +@tool() +async def google_tasks_delete_task(task_list_id: str, task_id: str) -> dict: """Delete a task. Permanently removes a task from the task list. @@ -375,8 +375,8 @@ def _delete(): return {"success": False, "error": str(e)} -@tool("google_tasks_complete_task") -async def complete_task(task_list_id: str, task_id: str) -> dict: +@tool() +async def google_tasks_complete_task(task_list_id: str, task_id: str) -> dict: """Mark a task as completed. Convenience function to set a task's status to completed. @@ -388,11 +388,11 @@ async def complete_task(task_list_id: str, task_id: str) -> dict: Returns: Updated task with completion status. """ - return await update_task(task_list_id, task_id, status="completed") + return await google_tasks_update_task(task_list_id, task_id, status="completed") -@tool("google_tasks_clear_completed") -async def clear_completed_tasks(task_list_id: str = "@default") -> dict: +@tool() +async def google_tasks_clear_completed(task_list_id: str = "@default") -> dict: """Clear all completed tasks from a task list. Removes all tasks marked as completed from the specified list. diff --git a/src/tools/local/calculator.py b/src/tools/local/calculator.py index c40e9fe..f7c9574 100644 --- a/src/tools/local/calculator.py +++ b/src/tools/local/calculator.py @@ -14,25 +14,25 @@ def _err(msg: str) -> dict: return {"success": False, "error": msg} -@tool("add") +@tool() async def add(a: float, b: float) -> dict: """Add two numbers.""" return _ok({"operation": "add", "a": a, "b": b, "result": a + b}) -@tool("subtract") +@tool() async def subtract(a: float, b: float) -> dict: """Subtract b from a.""" return _ok({"operation": "subtract", "a": a, "b": b, "result": a - b}) -@tool("multiply") +@tool() async def multiply(a: float, b: float) -> dict: """Multiply two numbers.""" return _ok({"operation": "multiply", "a": a, "b": b, "result": a * b}) -@tool("divide") +@tool() async def divide(a: float, b: float) -> dict: """Divide a by b.""" if b == 0: @@ -40,7 +40,7 @@ async def divide(a: float, b: float) -> dict: return _ok({"operation": "divide", "a": a, "b": b, "result": a / b}) -@tool("exponentiate") +@tool() async def exponentiate(a: float, b: float) -> dict: """Raise a to the power of b.""" try: @@ -50,7 +50,7 @@ async def exponentiate(a: float, b: float) -> dict: return _ok({"operation": "power", "a": a, "b": b, "result": result}) -@tool("factorial") +@tool() async def factorial(n: int) -> dict: """Calculate factorial of n (must be non-negative).""" if n < 0: @@ -62,7 +62,7 @@ async def factorial(n: int) -> dict: return _ok({"operation": "factorial", "n": n, "result": result}) -@tool("is_prime") +@tool() async def is_prime(n: int) -> dict: """Check if n is prime.""" if n <= 1: @@ -73,7 +73,7 @@ async def is_prime(n: int) -> dict: return _ok({"n": n, "is_prime": True}) -@tool("square_root") +@tool() async def square_root(n: float) -> dict: """Calculate square root of n (must be non-negative).""" if n < 0: @@ -81,13 +81,13 @@ async def square_root(n: float) -> dict: return _ok({"operation": "sqrt", "n": n, "result": math.sqrt(n)}) -@tool("absolute_value") +@tool() async def absolute_value(n: float) -> dict: """Calculate absolute value of n.""" return _ok({"operation": "abs", "n": n, "result": abs(n)}) -@tool("logarithm") +@tool() async def logarithm(n: float, base: float = 0) -> dict: """Calculate logarithm of n. Base defaults to e (natural log) if 0.""" if n <= 0: @@ -99,7 +99,7 @@ async def logarithm(n: float, base: float = 0) -> dict: return _ok({"operation": "log", "n": n, "base": base, "result": math.log(n, base)}) -@tool("modulo") +@tool() async def modulo(a: float, b: float) -> dict: """Calculate a modulo b.""" if b == 0: @@ -107,7 +107,7 @@ async def modulo(a: float, b: float) -> dict: return _ok({"operation": "mod", "a": a, "b": b, "result": a % b}) -@tool("greatest_common_divisor") +@tool() async def greatest_common_divisor(a: int, b: int) -> dict: """Calculate GCD of a and b.""" try: diff --git a/src/tools/local/local_file_system.py b/src/tools/local/local_file_system.py index a3bf4df..1bc6cd4 100644 --- a/src/tools/local/local_file_system.py +++ b/src/tools/local/local_file_system.py @@ -83,8 +83,8 @@ def _get_safe_path( return file_path.resolve(), None -@tool("filesystem_write_file") -async def write_file( +@tool() +async def filesystem_write_file( content: str, filename: str = "", directory: str = "", @@ -157,8 +157,8 @@ async def write_file( return {"success": False, "error": f"Failed to write file: {str(e)}"} -@tool("filesystem_read_file") -async def read_file( +@tool() +async def filesystem_read_file( filename: str, directory: str = "", ) -> dict: @@ -200,8 +200,8 @@ async def read_file( return {"success": False, "error": f"Failed to read file: {str(e)}"} -@tool("filesystem_list_files") -async def list_files( +@tool() +async def filesystem_list_files( directory: str = "", pattern: str = "*", recursive: bool = False, @@ -266,8 +266,8 @@ async def list_files( return {"success": False, "error": f"Failed to list files: {str(e)}"} -@tool("filesystem_delete_file") -async def delete_file( +@tool() +async def filesystem_delete_file( filename: str, directory: str = "", ) -> dict: @@ -308,8 +308,8 @@ async def delete_file( return {"success": False, "error": f"Failed to delete file: {str(e)}"} -@tool("filesystem_create_directory") -async def create_directory( +@tool() +async def filesystem_create_directory( directory: str, parents: bool = True, ) -> dict: @@ -360,8 +360,8 @@ async def create_directory( return {"success": False, "error": f"Failed to create directory: {str(e)}"} -@tool("filesystem_file_exists") -async def file_exists( +@tool() +async def filesystem_file_exists( filename: str, directory: str = "", ) -> dict: @@ -395,8 +395,8 @@ async def file_exists( return {"success": False, "error": str(e)} -@tool("filesystem_get_file_info") -async def get_file_info( +@tool() +async def filesystem_get_file_info( filename: str, directory: str = "", ) -> dict: @@ -442,8 +442,8 @@ async def get_file_info( return {"success": False, "error": str(e)} -@tool("filesystem_append_to_file") -async def append_to_file( +@tool() +async def filesystem_append_to_file( content: str, filename: str, directory: str = "", @@ -485,8 +485,8 @@ async def append_to_file( return {"success": False, "error": f"Failed to append to file: {str(e)}"} -@tool("filesystem_copy_file") -async def copy_file( +@tool() +async def filesystem_copy_file( source_filename: str, destination_filename: str, source_directory: str = "", diff --git a/src/tools/local/shell.py b/src/tools/local/shell.py index 993964a..a848048 100644 --- a/src/tools/local/shell.py +++ b/src/tools/local/shell.py @@ -133,8 +133,8 @@ async def run_shell_command( return {"success": False, "error": f"Failed to run shell command: {str(e)}"} -@tool("shell_run_shell_script") -async def run_shell_script( +@tool() +async def shell_run_shell_script( script: str, shell: str = "/bin/bash", base_dir: str = "", @@ -214,8 +214,8 @@ async def run_shell_script( return {"success": False, "error": f"Failed to run shell script: {str(e)}"} -@tool("shell_check_command_exists") -async def check_command_exists(command: str) -> dict: +@tool() +async def shell_check_command_exists(command: str) -> dict: """ Check if a command exists in the system PATH. @@ -247,8 +247,8 @@ async def check_command_exists(command: str) -> dict: return {"success": False, "error": str(e)} -@tool("shell_get_environment_variable") -async def get_environment_variable(variable_name: str) -> dict: +@tool() +async def shell_get_environment_variable(variable_name: str) -> dict: """ Get the value of an environment variable. @@ -282,8 +282,8 @@ async def get_environment_variable(variable_name: str) -> dict: return {"success": False, "error": str(e)} -@tool("shell_get_current_directory") -async def get_current_directory() -> dict: +@tool() +async def shell_get_current_directory() -> dict: """ Get the current working directory. @@ -301,8 +301,8 @@ async def get_current_directory() -> dict: return {"success": False, "error": str(e)} -@tool("shell_get_system_info") -async def get_system_info() -> dict: +@tool() +async def shell_get_system_info() -> dict: """ Get basic system information. diff --git a/src/tools/search/tavily_tool.py b/src/tools/search/tavily_tool.py index 52aa422..cb35d62 100644 --- a/src/tools/search/tavily_tool.py +++ b/src/tools/search/tavily_tool.py @@ -79,7 +79,7 @@ def web_search_using_tavily(self, query: str, max_results: int = 5) -> dict: return clean_response if clean_response else {} -@tool("tavily_web_search") +@tool() async def tavily_web_search( query: str, max_results: int = 5, diff --git a/tests/conftest.py b/tests/conftest.py index 9e09123..039666a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,73 +2,49 @@ import pytest -from src.humcp.registry import _TOOL_NAMES, TOOL_REGISTRY - @pytest.fixture(autouse=True) def allow_absolute_paths_for_tests(monkeypatch): - """Allow absolute paths in filesystem tools during tests. - - Tests use tmp_path fixtures which create temporary directories outside - the current working directory. This fixture enables absolute path access - during test execution. - """ + """Allow absolute paths in filesystem tools during tests.""" monkeypatch.setenv("HUMCP_ALLOW_ABSOLUTE_PATHS", "true") -@pytest.fixture(autouse=True) -def clear_tool_registry(): - """Clear the tool registry before and after each test. - - This ensures tests don't interfere with each other by leaving - tools registered from previous tests. - """ - # Store original state - original_registry = TOOL_REGISTRY.copy() - original_names = _TOOL_NAMES.copy() - - # Clear for test - TOOL_REGISTRY.clear() - _TOOL_NAMES.clear() - - yield - - # Restore original state after test - TOOL_REGISTRY.clear() - TOOL_REGISTRY.extend(original_registry) - _TOOL_NAMES.clear() - _TOOL_NAMES.update(original_names) - - @pytest.fixture -def sample_tool_func(): - """Create a sample async tool function for testing.""" - - async def sample_func(param: str) -> dict: - """A sample tool function.""" - return {"success": True, "data": {"param": param}} - - return sample_func - - -@pytest.fixture -def register_sample_tools(): - """Register sample tools for testing routes and server.""" - from src.humcp.decorator import tool - - @tool("test_tool_one", category="test") - async def tool_one(value: str) -> dict: - """First test tool.""" - return {"success": True, "data": {"value": value}} - - @tool("test_tool_two", category="test") - async def tool_two(a: int, b: int = 10) -> dict: - """Second test tool with optional param.""" - return {"success": True, "data": {"result": a + b}} - - @tool("other_category_tool", category="other") - async def tool_three() -> dict: - """Tool in different category.""" - return {"success": True, "data": {}} - - return [tool_one, tool_two, tool_three] +def sample_tools(tmp_path): + """Create sample tool files for testing.""" + # test category + test_dir = tmp_path / "test" + test_dir.mkdir() + + (test_dir / "tool_one.py").write_text(''' +from src.humcp.decorator import tool + +@tool(category="test") +async def test_tool_one(value: str) -> dict: + """First test tool.""" + return {"success": True, "data": {"value": value}} +''') + + (test_dir / "tool_two.py").write_text(''' +from src.humcp.decorator import tool + +@tool(category="test") +async def test_tool_two(a: int, b: int = 10) -> dict: + """Second test tool.""" + return {"success": True, "data": {"result": a + b}} +''') + + # other category + other_dir = tmp_path / "other" + other_dir.mkdir() + + (other_dir / "tool_three.py").write_text(''' +from src.humcp.decorator import tool + +@tool(category="other") +async def other_tool() -> dict: + """Tool in other category.""" + return {"success": True, "data": {}} +''') + + return tmp_path diff --git a/tests/humcp/test_config.py b/tests/humcp/test_config.py new file mode 100644 index 0000000..22f6f55 --- /dev/null +++ b/tests/humcp/test_config.py @@ -0,0 +1,384 @@ +"""Tests for humcp config loader.""" + +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from src.humcp.config import ( + ConfigValidationResult, + FilterConfig, + ToolsConfig, + _is_wildcard, + _matches_filter, + filter_tools, + load_config, + validate_config, +) +from src.humcp.decorator import RegisteredTool + + +def make_registered_tool(name: str, category: str) -> RegisteredTool: + """Create a mock RegisteredTool for testing.""" + mock_tool = Mock() + mock_tool.name = name + mock_tool.description = f"Description for {name}" + mock_tool.parameters = {"type": "object", "properties": {}} + mock_tool.fn = lambda: None + return RegisteredTool(tool=mock_tool, category=category) + + +# Test fixtures +@pytest.fixture +def sample_tools() -> list[RegisteredTool]: + """Create sample tools for testing.""" + return [ + make_registered_tool("calculator_add", "local"), + make_registered_tool("calculator_subtract", "local"), + make_registered_tool("shell_run_script", "local"), + make_registered_tool("read_csv", "data"), + make_registered_tool("google_sheets_read", "google"), + ] + + +class TestFilterConfig: + """Tests for FilterConfig model.""" + + def test_empty_filter(self): + """Empty filter should report is_empty True.""" + config = FilterConfig() + assert config.is_empty() + + def test_filter_with_categories(self): + """Filter with categories should not be empty.""" + config = FilterConfig(categories=["local"]) + assert not config.is_empty() + + def test_filter_with_tools(self): + """Filter with tools should not be empty.""" + config = FilterConfig(tools=["calculator_add"]) + assert not config.is_empty() + + +class TestToolsConfig: + """Tests for ToolsConfig model.""" + + def test_default_config(self): + """Default config should have empty filters.""" + config = ToolsConfig() + assert config.include.is_empty() + assert config.exclude.is_empty() + + def test_config_with_include(self): + """Config with include should parse correctly.""" + config = ToolsConfig( + include=FilterConfig(categories=["local", "data"]), + ) + assert config.include.categories == ["local", "data"] + assert config.exclude.is_empty() + + def test_config_handles_none_values(self): + """Config should handle None values from YAML.""" + data = { + "include": None, + "exclude": {"categories": None, "tools": ["test"]}, + } + config = ToolsConfig.model_validate(data) + assert config.include.is_empty() + assert config.exclude.tools == ["test"] + + +class TestLoadConfig: + """Tests for load_config function.""" + + def test_load_nonexistent_file(self, tmp_path: Path): + """Should return default config for missing file.""" + config = load_config(tmp_path / "nonexistent.yaml") + assert config.include.is_empty() + assert config.exclude.is_empty() + + def test_load_empty_file(self, tmp_path: Path): + """Should return default config for empty file.""" + config_file = tmp_path / "empty.yaml" + config_file.write_text("") + config = load_config(config_file) + assert config.include.is_empty() + assert config.exclude.is_empty() + + def test_load_include_categories(self, tmp_path: Path): + """Should load include categories from YAML.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(""" +include: + categories: + - local + - data +""") + config = load_config(config_file) + assert config.include.categories == ["local", "data"] + + def test_load_exclude_tools(self, tmp_path: Path): + """Should load exclude tools from YAML.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(""" +exclude: + tools: + - shell_* + - dangerous_tool +""") + config = load_config(config_file) + assert config.exclude.tools == ["shell_*", "dangerous_tool"] + + def test_load_complex_config(self, tmp_path: Path): + """Should load complex config with both include and exclude.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(""" +include: + categories: + - local + tools: + - read_csv +exclude: + tools: + - shell_* +""") + config = load_config(config_file) + assert config.include.categories == ["local"] + assert config.include.tools == ["read_csv"] + assert config.exclude.tools == ["shell_*"] + + def test_load_invalid_yaml(self, tmp_path: Path): + """Should raise error for invalid YAML.""" + config_file = tmp_path / "invalid.yaml" + config_file.write_text("{ invalid yaml [") + with pytest.raises(ValueError, match="Invalid YAML"): + load_config(config_file) + + +class TestValidateConfig: + """Tests for validate_config function.""" + + def test_valid_config(self, sample_tools: list[RegisteredTool]): + """Valid config should pass validation.""" + config = ToolsConfig( + include=FilterConfig(categories=["local"]), + ) + available_categories = {t.category for t in sample_tools} + available_tools = {t.tool.name for t in sample_tools} + + result = validate_config(config, available_categories, available_tools) + assert result.valid + assert not result.errors + assert not result.warnings + + def test_invalid_category(self, sample_tools: list[RegisteredTool]): + """Unknown category should cause validation error.""" + config = ToolsConfig( + include=FilterConfig(categories=["nonexistent"]), + ) + available_categories = {t.category for t in sample_tools} + available_tools = {t.tool.name for t in sample_tools} + + result = validate_config(config, available_categories, available_tools) + assert not result.valid + assert any("nonexistent" in e for e in result.errors) + + def test_invalid_tool(self, sample_tools: list[RegisteredTool]): + """Unknown tool should cause validation error.""" + config = ToolsConfig( + include=FilterConfig(tools=["nonexistent_tool"]), + ) + available_categories = {t.category for t in sample_tools} + available_tools = {t.tool.name for t in sample_tools} + + result = validate_config(config, available_categories, available_tools) + assert not result.valid + assert any("nonexistent_tool" in e for e in result.errors) + + def test_wildcard_no_match_warning(self, sample_tools: list[RegisteredTool]): + """Wildcard matching no tools should cause warning.""" + config = ToolsConfig( + exclude=FilterConfig(tools=["xyz_*"]), + ) + available_categories = {t.category for t in sample_tools} + available_tools = {t.tool.name for t in sample_tools} + + result = validate_config(config, available_categories, available_tools) + assert result.valid # Still valid, just warning + assert any("xyz_*" in w for w in result.warnings) + + def test_wildcard_with_matches(self, sample_tools: list[RegisteredTool]): + """Wildcard matching tools should pass validation.""" + config = ToolsConfig( + exclude=FilterConfig(tools=["calculator_*"]), + ) + available_categories = {t.category for t in sample_tools} + available_tools = {t.tool.name for t in sample_tools} + + result = validate_config(config, available_categories, available_tools) + assert result.valid + assert not result.warnings + + +class TestIsWildcard: + """Tests for _is_wildcard function.""" + + def test_asterisk(self): + assert _is_wildcard("shell_*") + + def test_question_mark(self): + assert _is_wildcard("tool_?") + + def test_bracket(self): + assert _is_wildcard("tool_[abc]") + + def test_no_wildcard(self): + assert not _is_wildcard("calculator_add") + + +class TestMatchesFilter: + """Tests for _matches_filter function.""" + + def test_match_by_category(self): + """Tool should match if category is in filter.""" + reg = make_registered_tool("test", "local") + filter_config = FilterConfig(categories=["local"]) + assert _matches_filter(reg, filter_config) + + def test_no_match_by_category(self): + """Tool should not match if category not in filter.""" + reg = make_registered_tool("test", "data") + filter_config = FilterConfig(categories=["local"]) + assert not _matches_filter(reg, filter_config) + + def test_match_by_exact_tool_name(self): + """Tool should match by exact name.""" + reg = make_registered_tool("calculator_add", "local") + filter_config = FilterConfig(tools=["calculator_add"]) + assert _matches_filter(reg, filter_config) + + def test_match_by_wildcard(self): + """Tool should match by wildcard pattern.""" + reg = make_registered_tool("calculator_add", "local") + filter_config = FilterConfig(tools=["calculator_*"]) + assert _matches_filter(reg, filter_config) + + def test_no_match_by_wildcard(self): + """Tool should not match if wildcard doesn't match.""" + reg = make_registered_tool("shell_run", "local") + filter_config = FilterConfig(tools=["calculator_*"]) + assert not _matches_filter(reg, filter_config) + + +class TestFilterTools: + """Tests for filter_tools function.""" + + def test_empty_config_returns_all(self, sample_tools: list[RegisteredTool]): + """Empty config should return all tools.""" + config = ToolsConfig() + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == len(sample_tools) + + def test_include_category(self, sample_tools: list[RegisteredTool]): + """Include category should filter to that category.""" + config = ToolsConfig( + include=FilterConfig(categories=["local"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 3 # calculator_add, calculator_subtract, shell_run_script + assert all(r.category == "local" for r in result) + + def test_include_multiple_categories(self, sample_tools: list[RegisteredTool]): + """Include multiple categories should filter to those categories.""" + config = ToolsConfig( + include=FilterConfig(categories=["local", "data"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 4 + assert all(r.category in ["local", "data"] for r in result) + + def test_include_specific_tools(self, sample_tools: list[RegisteredTool]): + """Include specific tools should filter to those tools.""" + config = ToolsConfig( + include=FilterConfig(tools=["calculator_add", "read_csv"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 2 + assert {r.tool.name for r in result} == {"calculator_add", "read_csv"} + + def test_exclude_category(self, sample_tools: list[RegisteredTool]): + """Exclude category should remove that category.""" + config = ToolsConfig( + exclude=FilterConfig(categories=["google"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 4 + assert all(r.category != "google" for r in result) + + def test_exclude_tools_wildcard(self, sample_tools: list[RegisteredTool]): + """Exclude with wildcard should remove matching tools.""" + config = ToolsConfig( + exclude=FilterConfig(tools=["calculator_*"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 3 + assert all(not r.tool.name.startswith("calculator_") for r in result) + + def test_include_and_exclude(self, sample_tools: list[RegisteredTool]): + """Include and exclude should work together.""" + config = ToolsConfig( + include=FilterConfig(categories=["local"]), + exclude=FilterConfig(tools=["shell_*"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 2 # calculator_add, calculator_subtract + assert all(r.category == "local" for r in result) + assert all(not r.tool.name.startswith("shell_") for r in result) + + def test_include_category_and_tool(self, sample_tools: list[RegisteredTool]): + """Include category OR tool should work (union).""" + config = ToolsConfig( + include=FilterConfig(categories=["local"], tools=["read_csv"]), + ) + result = filter_tools(config, sample_tools, validate=False) + assert len(result) == 4 # 3 local + read_csv + + def test_validation_error_raises(self, sample_tools: list[RegisteredTool]): + """Validation errors should raise ValueError.""" + config = ToolsConfig( + include=FilterConfig(categories=["nonexistent"]), + ) + with pytest.raises(ValueError, match="Config validation failed"): + filter_tools(config, sample_tools, validate=True) + + def test_empty_tools_list(self): + """Should handle empty tools list.""" + config = ToolsConfig() + result = filter_tools(config, [], validate=False) + assert result == [] + + +class TestConfigValidationResult: + """Tests for ConfigValidationResult model.""" + + def test_valid_result(self): + result = ConfigValidationResult(valid=True) + assert result.valid + assert result.warnings == [] + assert result.errors == [] + + def test_invalid_result_with_errors(self): + result = ConfigValidationResult( + valid=False, + errors=["Error 1", "Error 2"], + ) + assert not result.valid + assert len(result.errors) == 2 + + def test_valid_result_with_warnings(self): + result = ConfigValidationResult( + valid=True, + warnings=["Warning 1"], + ) + assert result.valid + assert len(result.warnings) == 1 diff --git a/tests/humcp/test_decorator.py b/tests/humcp/test_decorator.py index a22b8a0..56fe11b 100644 --- a/tests/humcp/test_decorator.py +++ b/tests/humcp/test_decorator.py @@ -2,73 +2,90 @@ import asyncio -import pytest - -from src.humcp.decorator import tool -from src.humcp.registry import _TOOL_NAMES, TOOL_REGISTRY, ToolRegistration +from src.humcp.decorator import ( + TOOL_ATTR, + ToolMetadata, + get_tool_category, + get_tool_name, + is_tool, + tool, +) class TestToolDecorator: """Tests for the @tool decorator.""" - def test_decorator_with_explicit_name(self): - """Should register tool with explicit name.""" + def test_decorator_marks_function(self): + """Should mark function with tool attribute.""" - @tool("my_explicit_tool") + @tool() async def my_func(): pass - assert len(TOOL_REGISTRY) == 1 - reg = TOOL_REGISTRY[-1] - assert reg.name == "my_explicit_tool" - assert reg.func is my_func + assert is_tool(my_func) + assert hasattr(my_func, TOOL_ATTR) - def test_decorator_auto_generates_name_from_category_and_func(self): - """Should auto-generate name as category_funcname when no name given.""" + def test_decorator_with_explicit_category(self): + """Should store explicit category.""" - @tool(category="test_category") - async def my_func(): + @tool(category="custom") + async def func(): pass - reg = TOOL_REGISTRY[-1] - assert reg.name == "test_category_my_func" - assert reg.category == "test_category" + assert get_tool_category(func) == "custom" - def test_decorator_with_explicit_category(self): - """Should use explicit category when provided.""" + def test_decorator_with_explicit_name(self): + """Should store explicit name.""" - @tool("tool_name", category="custom_category") - async def categorized_func(): + @tool("custom_name") + async def func(): pass - reg = TOOL_REGISTRY[-1] - assert reg.category == "custom_category" - assert reg.name == "tool_name" + assert get_tool_name(func) == "custom_name" - def test_decorator_default_category(self): - """Should use 'humcp' category when not specified.""" + def test_decorator_with_explicit_name_and_category(self): + """Should store both explicit name and category.""" - @tool("default_cat_tool") - async def default_cat_func(): + @tool("my_tool", "my_category") + async def func(): pass - reg = TOOL_REGISTRY[-1] - assert reg.category == "humcp" + assert get_tool_name(func) == "my_tool" + assert get_tool_category(func) == "my_category" + + def test_decorator_auto_detects_category(self): + """Should auto-detect category from file path.""" + + @tool() + async def func(): + pass + + # Category inferred from this file's parent dir (humcp) + assert get_tool_category(func) == "humcp" + + def test_decorator_auto_detects_name(self): + """Should auto-detect name from function name.""" + + @tool() + async def my_function(): + pass + + assert get_tool_name(my_function) == "my_function" def test_decorator_returns_original_function(self): - """Decorated function should behave identically to original.""" + """Decorated function should behave identically.""" - @tool("preserved_func", category="test") - async def original_func(a: int, b: int) -> int: + @tool() + async def add(a: int, b: int) -> int: return a + b - result = asyncio.get_event_loop().run_until_complete(original_func(2, 3)) + result = asyncio.get_event_loop().run_until_complete(add(2, 3)) assert result == 5 def test_decorator_preserves_function_metadata(self): """Should preserve function name and docstring.""" - @tool("metadata_tool", category="test") + @tool() async def documented_func(param: str) -> dict: """This is a docstring.""" return {"param": param} @@ -76,173 +93,110 @@ async def documented_func(param: str) -> dict: assert documented_func.__name__ == "documented_func" assert documented_func.__doc__ == "This is a docstring." - def test_duplicate_name_raises_value_error(self): - """Should raise ValueError when registering duplicate tool name.""" - - @tool("duplicate_tool", category="test") - async def func1(): - pass - - with pytest.raises(ValueError, match="Duplicate tool name"): - - @tool("duplicate_tool", category="test") - async def func2(): - pass - def test_decorator_with_sync_function(self): """Should work with synchronous functions.""" - @tool("sync_tool", category="test") + @tool(category="test") def sync_func(x: int) -> int: return x * 2 - assert len(TOOL_REGISTRY) >= 1 - reg = TOOL_REGISTRY[-1] - assert reg.name == "sync_tool" + assert is_tool(sync_func) assert sync_func(5) == 10 def test_decorator_with_no_params(self): - """Should register tool with no parameters.""" - - @tool("no_param_tool", category="test") - async def no_param_func() -> dict: - return {"success": True} - - reg = TOOL_REGISTRY[-1] - assert reg.name == "no_param_tool" - - def test_decorator_with_type_hints(self): - """Should register tool with complex type hints.""" - - @tool("typed_tool", category="test") - async def typed_func( - required: str, - optional: int = 10, - nullable: str | None = None, - ) -> dict: - return {"required": required, "optional": optional} - - reg = TOOL_REGISTRY[-1] - assert reg.name == "typed_tool" - - -class TestToolRegistration: - """Tests for the ToolRegistration dataclass.""" + """Should auto-detect name and category when no params given.""" - def test_tool_registration_frozen(self): - """ToolRegistration should be immutable (frozen).""" - - def dummy_func(): - return None - - registration = ToolRegistration( - name="test", category="test_cat", func=dummy_func - ) - - with pytest.raises((AttributeError, TypeError)): - registration.name = "new_name" - - def test_tool_registration_equality(self): - """ToolRegistrations with same values should be equal.""" + @tool() + async def my_func(): + return {} - def dummy_func(): - return None + assert is_tool(my_func) + # Name from function name + assert get_tool_name(my_func) == "my_func" + # Category from file path (humcp) + assert get_tool_category(my_func) == "humcp" - reg1 = ToolRegistration(name="test", category="cat", func=dummy_func) - reg2 = ToolRegistration(name="test", category="cat", func=dummy_func) - assert reg1 == reg2 - def test_tool_registration_inequality_name(self): - """ToolRegistrations with different names should not be equal.""" +class TestIsTool: + """Tests for is_tool function.""" - def dummy_func(): - return None + def test_returns_true_for_decorated(self): + """Should return True for decorated functions.""" - reg1 = ToolRegistration(name="test1", category="cat", func=dummy_func) - reg2 = ToolRegistration(name="test2", category="cat", func=dummy_func) - assert reg1 != reg2 + @tool() + def func(): + pass - def test_tool_registration_inequality_category(self): - """ToolRegistrations with different categories should not be equal.""" + assert is_tool(func) - def dummy_func(): - return None + def test_returns_false_for_undecorated(self): + """Should return False for undecorated functions.""" - reg1 = ToolRegistration(name="test", category="cat1", func=dummy_func) - reg2 = ToolRegistration(name="test", category="cat2", func=dummy_func) - assert reg1 != reg2 + def func(): + pass - def test_tool_registration_hashable(self): - """ToolRegistration should be hashable for use in sets.""" + assert not is_tool(func) - def dummy_func(): - return None - reg = ToolRegistration(name="test", category="cat", func=dummy_func) - # Should not raise - hash(reg) - # Should be usable in set - s = {reg} - assert reg in s +class TestGetToolName: + """Tests for get_tool_name function.""" + def test_returns_name_for_decorated(self): + """Should return name for decorated functions.""" -class TestToolRegistry: - """Tests for the global TOOL_REGISTRY.""" + @tool("custom") + def func(): + pass - def test_registry_is_list(self): - """TOOL_REGISTRY should be a list.""" - assert isinstance(TOOL_REGISTRY, list) + assert get_tool_name(func) == "custom" - def test_registry_stores_registrations(self): - """TOOL_REGISTRY should contain ToolRegistration objects.""" + def test_returns_function_name_for_default(self): + """Should return function name when no explicit name.""" - @tool("registry_test", category="test") - async def test_func(): + @tool() + def my_tool(): pass - assert len(TOOL_REGISTRY) >= 1 - assert all(isinstance(reg, ToolRegistration) for reg in TOOL_REGISTRY) + assert get_tool_name(my_tool) == "my_tool" - def test_tool_names_tracks_names(self): - """_TOOL_NAMES should track registered names.""" + def test_returns_function_name_for_undecorated(self): + """Should return function name for undecorated functions.""" - @tool("tracked_tool", category="test") - async def tracked_func(): + def func(): pass - assert "tracked_tool" in _TOOL_NAMES + assert get_tool_name(func) == "func" -class TestDecoratorEdgeCases: - """Tests for edge cases in the @tool decorator.""" +class TestGetToolCategory: + """Tests for get_tool_category function.""" - def test_empty_name_uses_auto_generation(self): - """Empty string name should trigger auto-generation.""" + def test_returns_category_for_decorated(self): + """Should return category for decorated functions.""" - @tool("", category="edge") - async def empty_name_func(): + @tool(category="cat") + def func(): pass - reg = TOOL_REGISTRY[-1] - # Empty name should auto-generate - assert reg.name == "edge_empty_name_func" + assert get_tool_category(func) == "cat" - def test_whitespace_category(self): - """Category with only whitespace should be handled.""" + def test_returns_uncategorized_for_undecorated(self): + """Should return 'uncategorized' for undecorated functions.""" - @tool("whitespace_cat_tool", category=" ") - async def whitespace_cat_func(): + def func(): pass - reg = TOOL_REGISTRY[-1] - assert reg.category == " " # Preserves as-is + assert get_tool_category(func) == "uncategorized" - def test_unicode_name(self): - """Should handle unicode characters in names.""" + def test_metadata_attached_via_attribute(self): + """ToolMetadata should be attached via TOOL_ATTR.""" - @tool("unicode_tool_\u00e9", category="test") - async def unicode_func(): + @tool("test_name", "test_cat") + def func(): pass - reg = TOOL_REGISTRY[-1] - assert reg.name == "unicode_tool_\u00e9" + assert hasattr(func, TOOL_ATTR) + metadata = getattr(func, TOOL_ATTR) + assert isinstance(metadata, ToolMetadata) + assert metadata.name == "test_name" + assert metadata.category == "test_cat" diff --git a/tests/humcp/test_models.py b/tests/humcp/test_models.py index d05a719..9bf25fe 100644 --- a/tests/humcp/test_models.py +++ b/tests/humcp/test_models.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from src.humcp.routes import _create_model, _pascal +from src.humcp.routes import _create_model_from_schema, _pascal class TestPascalCase: @@ -29,7 +29,7 @@ def test_empty_string(self): assert _pascal("") == "Model" -class TestCreateModel: +class TestCreateModelFromSchema: def test_simple_object_schema(self): schema = { "type": "object", @@ -40,7 +40,7 @@ def test_simple_object_schema(self): "required": ["name"], } - Model = _create_model(schema, "TestModel") + Model = _create_model_from_schema(schema, "TestModel") assert issubclass(Model, BaseModel) assert "name" in Model.model_fields @@ -56,7 +56,7 @@ def test_required_fields(self): "required": ["required_field"], } - Model = _create_model(schema, "TestModel") + Model = _create_model_from_schema(schema, "TestModel") assert Model.model_fields["required_field"].is_required() assert not Model.model_fields["optional_field"].is_required() @@ -75,7 +75,7 @@ def test_all_types(self): "required": [], } - Model = _create_model(schema, "AllTypesModel") + Model = _create_model_from_schema(schema, "AllTypesModel") assert issubclass(Model, BaseModel) assert len(Model.model_fields) == 6 @@ -83,7 +83,7 @@ def test_all_types(self): def test_non_object_schema(self): schema = {"type": "string"} - Model = _create_model(schema, "SimpleModel") + Model = _create_model_from_schema(schema, "SimpleModel") assert issubclass(Model, BaseModel) assert "value" in Model.model_fields @@ -91,7 +91,7 @@ def test_non_object_schema(self): def test_empty_schema(self): schema = {} - Model = _create_model(schema, "EmptyModel") + Model = _create_model_from_schema(schema, "EmptyModel") assert issubclass(Model, BaseModel) assert "value" in Model.model_fields @@ -106,7 +106,7 @@ def test_model_instantiation(self): "required": ["a", "b"], } - Model = _create_model(schema, "CalcInput") + Model = _create_model_from_schema(schema, "CalcInput") instance = Model(a=5.0, b=3.0) assert instance.a == 5.0 @@ -122,7 +122,7 @@ def test_model_dump(self): "required": ["required_field"], } - Model = _create_model(schema, "DumpModel") + Model = _create_model_from_schema(schema, "DumpModel") instance = Model(required_field="value", optional_field=None) dumped = instance.model_dump(exclude_none=True) diff --git a/tests/humcp/test_routes.py b/tests/humcp/test_routes.py index 5c1dfe7..a9a2486 100644 --- a/tests/humcp/test_routes.py +++ b/tests/humcp/test_routes.py @@ -1,517 +1,172 @@ """Tests for humcp routes module.""" +from unittest.mock import Mock + +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient -from src.humcp.decorator import tool -from src.humcp.registry import ToolRegistration +from src.humcp.decorator import RegisteredTool from src.humcp.routes import ( _build_categories, - _build_tool_lookup, _format_tag, - _get_schema_from_func, build_openapi_tags, register_routes, ) +def make_registered_tool( + name: str, category: str, description: str, parameters: dict, fn +) -> RegisteredTool: + """Create a mock RegisteredTool for testing.""" + mock_tool = Mock() + mock_tool.name = name + mock_tool.description = description + mock_tool.parameters = parameters + mock_tool.output_schema = None + mock_tool.fn = fn + return RegisteredTool(tool=mock_tool, category=category) + + +@pytest.fixture +def sample_registrations(): + """Create sample tool registrations.""" + + async def tool_one(value: str) -> dict: + """First test tool.""" + return {"success": True, "data": {"value": value}} + + async def tool_two(a: int, b: int = 10) -> dict: + """Second test tool.""" + return {"success": True, "data": {"result": a + b}} + + async def tool_three() -> dict: + """Tool in other category.""" + return {"success": True, "data": {}} + + return [ + make_registered_tool( + "test_tool_one", + "test", + "First test tool.", + { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + tool_one, + ), + make_registered_tool( + "test_tool_two", + "test", + "Second test tool.", + { + "type": "object", + "properties": { + "a": {"type": "integer"}, + "b": {"type": "integer"}, + }, + "required": ["a"], + }, + tool_two, + ), + make_registered_tool( + "other_tool", + "other", + "Tool in other category.", + {"type": "object", "properties": {}, "required": []}, + tool_three, + ), + ] + + class TestFormatTag: - """Tests for the _format_tag function.""" + """Tests for _format_tag.""" - def test_lowercase_to_title(self): - """Should convert lowercase to title case.""" + def test_lowercase(self): assert _format_tag("google") == "Google" - def test_snake_case_to_title(self): - """Should convert snake_case to Title Case with spaces.""" + def test_snake_case(self): assert _format_tag("local_files") == "Local Files" - def test_multiple_underscores(self): - """Should handle multiple underscores.""" - assert _format_tag("my_long_category_name") == "My Long Category Name" - - def test_already_capitalized(self): - """Should handle already capitalized strings.""" - assert _format_tag("Google") == "Google" - - def test_empty_string(self): - """Should handle empty string.""" + def test_empty(self): assert _format_tag("") == "" - def test_single_letter(self): - """Should handle single letter.""" - assert _format_tag("a") == "A" - class TestBuildOpenapiTags: - """Tests for the build_openapi_tags function.""" + """Tests for build_openapi_tags.""" - def test_returns_list(self, register_sample_tools): - """Should return a list of tag definitions.""" - tags = build_openapi_tags() - assert isinstance(tags, list) - - def test_includes_info_tag_first(self, register_sample_tools): - """Should include Info tag as first element.""" - tags = build_openapi_tags() + def test_includes_info_tag(self, sample_registrations): + tags = build_openapi_tags(sample_registrations) assert tags[0]["name"] == "Info" - assert "description" in tags[0] - - def test_includes_category_tags(self, register_sample_tools): - """Should include tags for each category.""" - tags = build_openapi_tags() - tag_names = [t["name"] for t in tags] - assert "Test" in tag_names # "test" -> "Test" - assert "Other" in tag_names # "other" -> "Other" + def test_includes_category_tags(self, sample_registrations): + tags = build_openapi_tags(sample_registrations) + names = [t["name"] for t in tags] + assert "Test" in names + assert "Other" in names - def test_tag_has_name_and_description(self, register_sample_tools): - """Each tag should have name and description.""" - tags = build_openapi_tags() - - for tag in tags: - assert "name" in tag - assert "description" in tag - - def test_category_tags_sorted(self, register_sample_tools): - """Category tags should be sorted alphabetically.""" - tags = build_openapi_tags() - - # Skip Info tag (first) - category_tags = tags[1:] - tag_names = [t["name"] for t in category_tags] - assert tag_names == sorted(tag_names) - - def test_description_includes_tool_count(self, register_sample_tools): - """Category descriptions should include tool count.""" - tags = build_openapi_tags() - - test_tag = next(t for t in tags if t["name"] == "Test") - assert "2 endpoints" in test_tag["description"] - - other_tag = next(t for t in tags if t["name"] == "Other") - assert "1 endpoints" in other_tag["description"] - - def test_empty_registry(self): - """Should return only Info tag when registry is empty.""" - tags = build_openapi_tags() + def test_empty_list(self): + tags = build_openapi_tags([]) assert len(tags) == 1 assert tags[0]["name"] == "Info" -class TestGetSchemaFromFunc: - """Tests for the _get_schema_from_func function.""" - - def test_simple_string_param(self): - """Should extract string parameter.""" - - async def func(name: str): - pass - - schema = _get_schema_from_func(func) - assert schema["type"] == "object" - assert schema["properties"]["name"]["type"] == "string" - assert "name" in schema["required"] - - def test_integer_param(self): - """Should extract integer parameter.""" - - async def func(count: int): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["count"]["type"] == "integer" - - def test_float_param(self): - """Should extract float parameter.""" - - async def func(value: float): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["value"]["type"] == "number" - - def test_boolean_param(self): - """Should extract boolean parameter.""" - - async def func(flag: bool): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["flag"]["type"] == "boolean" - - def test_list_param(self): - """Should extract list parameter.""" - - async def func(items: list): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["items"]["type"] == "array" - - def test_dict_param(self): - """Should extract dict parameter.""" - - async def func(data: dict): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["data"]["type"] == "object" - - def test_optional_param_not_required(self): - """Should not include optional params in required list.""" - - async def func(required_param: str, optional_param: str = "default"): - pass - - schema = _get_schema_from_func(func) - assert "required_param" in schema["required"] - assert "optional_param" not in schema["required"] - - def test_union_with_none(self): - """Should handle Union[type, None] (Optional).""" - - async def func(value: str | None): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["value"]["type"] == "string" - - def test_multiple_params(self): - """Should extract multiple parameters.""" - - async def func(name: str, count: int, active: bool = True): - pass - - schema = _get_schema_from_func(func) - assert len(schema["properties"]) == 3 - assert schema["properties"]["name"]["type"] == "string" - assert schema["properties"]["count"]["type"] == "integer" - assert schema["properties"]["active"]["type"] == "boolean" - assert "name" in schema["required"] - assert "count" in schema["required"] - assert "active" not in schema["required"] - - def test_no_params(self): - """Should handle function with no parameters.""" - - async def func(): - pass - - schema = _get_schema_from_func(func) - assert schema["type"] == "object" - assert schema["properties"] == {} - assert schema["required"] == [] - - def test_unknown_type_defaults_to_string(self): - """Should default unknown types to string.""" - - class CustomType: - pass - - async def func(value: CustomType): - pass - - schema = _get_schema_from_func(func) - assert schema["properties"]["value"]["type"] == "string" - - class TestBuildCategories: - """Tests for the _build_categories function.""" - - def test_builds_category_map(self, register_sample_tools): - """Should build category map from registry.""" - categories = _build_categories() - - assert "test" in categories - assert "other" in categories - assert len(categories["test"]) == 2 - assert len(categories["other"]) == 1 - - def test_category_entry_structure(self, register_sample_tools): - """Category entries should have name, description, endpoint.""" - categories = _build_categories() - - tool_entry = categories["test"][0] - assert "name" in tool_entry - assert "description" in tool_entry - assert "endpoint" in tool_entry - assert tool_entry["endpoint"].startswith("/tools/") + """Tests for _build_categories.""" - def test_empty_registry(self): - """Should handle empty registry.""" - categories = _build_categories() - assert categories == {} + def test_builds_map(self, sample_registrations): + cats = _build_categories(sample_registrations) + assert "test" in cats + assert len(cats["test"]) == 2 - -class TestBuildToolLookup: - """Tests for the _build_tool_lookup function.""" - - def test_builds_lookup_map(self, register_sample_tools): - """Should build (category, name) -> ToolRegistration map.""" - lookup = _build_tool_lookup() - - assert ("test", "test_tool_one") in lookup - assert ("test", "test_tool_two") in lookup - assert ("other", "other_category_tool") in lookup - - def test_lookup_returns_registration(self, register_sample_tools): - """Lookup should return ToolRegistration objects.""" - lookup = _build_tool_lookup() - - reg = lookup[("test", "test_tool_one")] - assert isinstance(reg, ToolRegistration) - assert reg.name == "test_tool_one" - assert reg.category == "test" - - def test_empty_registry(self): - """Should handle empty registry.""" - lookup = _build_tool_lookup() - assert lookup == {} + def test_empty_list(self): + assert _build_categories([]) == {} class TestRegisterRoutes: - """Tests for the register_routes function.""" - - def test_registers_tool_endpoints(self, register_sample_tools): - """Should register POST endpoints for each tool.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.post("/tools/test_tool_one", json={"value": "test"}) - assert response.status_code == 200 - - def test_registers_info_endpoints(self, register_sample_tools): - """Should register info endpoints.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - # /tools endpoint - response = client.get("/tools") - assert response.status_code == 200 - data = response.json() - assert "total_tools" in data - assert "categories" in data - - def test_tool_execution_success(self, register_sample_tools): - """Should execute tool and return result.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.post("/tools/test_tool_one", json={"value": "hello"}) - assert response.status_code == 200 - data = response.json() - assert data["result"]["success"] is True - assert data["result"]["data"]["value"] == "hello" - - def test_tool_execution_with_defaults(self, register_sample_tools): - """Should use default values for optional params.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.post("/tools/test_tool_two", json={"a": 5}) - assert response.status_code == 200 - data = response.json() - assert data["result"]["data"]["result"] == 15 # 5 + 10 (default) - - def test_tool_execution_error(self, register_sample_tools): - """Should return 500 on tool execution failure.""" - - @tool("failing_tool", category="test") - async def failing_tool(): - raise ValueError("Intentional failure") - - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.post("/tools/failing_tool", json={}) - assert response.status_code == 500 - assert "Tool execution failed" in response.json()["detail"] - - def test_category_endpoint(self, register_sample_tools): - """Should list tools in category.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.get("/tools/test") - assert response.status_code == 200 - data = response.json() - assert data["category"] == "test" - assert data["count"] == 2 - tool_names = [t["name"] for t in data["tools"]] - assert "test_tool_one" in tool_names - assert "test_tool_two" in tool_names - - def test_category_not_found(self, register_sample_tools): - """Should return 404 for nonexistent category.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.get("/tools/nonexistent") - assert response.status_code == 404 - assert "not found" in response.json()["detail"] - - def test_tool_info_endpoint(self, register_sample_tools): - """Should return tool info with schema.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) + """Tests for register_routes.""" - response = client.get("/tools/test/test_tool_one") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "test_tool_one" - assert data["category"] == "test" - assert "input_schema" in data - assert data["endpoint"] == "/tools/test_tool_one" - - def test_tool_info_with_category_prefix(self, register_sample_tools): - """Should find tool by name with category prefix removed.""" + def test_registers_endpoints(self, sample_registrations, tmp_path): app = FastAPI() - register_routes(app) + register_routes(app, tmp_path, sample_registrations) client = TestClient(app) - # Access tool via shortened name (without category prefix) - response = client.get("/tools/test/tool_one") - # This should work because it tries f"{category}_{tool_name}" - assert response.status_code == 200 - data = response.json() - assert data["name"] == "test_tool_one" + resp = client.post("/tools/test_tool_one", json={"value": "test"}) + assert resp.status_code == 200 - def test_tool_info_not_found(self, register_sample_tools): - """Should return 404 for nonexistent tool.""" + def test_tools_list_endpoint(self, sample_registrations, tmp_path): app = FastAPI() - register_routes(app) + register_routes(app, tmp_path, sample_registrations) client = TestClient(app) - response = client.get("/tools/test/nonexistent_tool") - assert response.status_code == 404 - assert "not found" in response.json()["detail"] - + resp = client.get("/tools") + assert resp.status_code == 200 + assert resp.json()["total_tools"] == 3 -class TestToolInfoEndpointSchema: - """Tests for input_schema in tool info endpoint.""" - - def test_schema_contains_properties(self, register_sample_tools): - """Should include property definitions in schema.""" + def test_category_endpoint(self, sample_registrations, tmp_path): app = FastAPI() - register_routes(app) + register_routes(app, tmp_path, sample_registrations) client = TestClient(app) - response = client.get("/tools/test/test_tool_two") - data = response.json() - - schema = data["input_schema"] - assert "a" in schema["properties"] - assert "b" in schema["properties"] - assert schema["properties"]["a"]["type"] == "integer" - assert schema["properties"]["b"]["type"] == "integer" + resp = client.get("/tools/test") + assert resp.status_code == 200 + assert resp.json()["count"] == 2 - def test_schema_contains_required_fields(self, register_sample_tools): - """Should mark required fields in schema.""" + def test_tool_info_endpoint(self, sample_registrations, tmp_path): app = FastAPI() - register_routes(app) + register_routes(app, tmp_path, sample_registrations) client = TestClient(app) - response = client.get("/tools/test/test_tool_two") - data = response.json() - - schema = data["input_schema"] - assert "a" in schema["required"] - assert "b" not in schema["required"] - - -class TestOpenApiCategoryTags: - """Tests for OpenAPI category tags in tool endpoints.""" - - def test_tool_routes_use_category_tags(self, register_sample_tools): - """Tool routes should use formatted category as tag.""" - app = FastAPI() - register_routes(app) - - # Get OpenAPI schema - openapi = app.openapi() - paths = openapi["paths"] - - # Check test_tool_one uses "Test" tag - tool_one_path = paths.get("/tools/test_tool_one") - assert tool_one_path is not None - assert "Test" in tool_one_path["post"]["tags"] - - # Check other_category_tool uses "Other" tag - other_tool_path = paths.get("/tools/other_category_tool") - assert other_tool_path is not None - assert "Other" in other_tool_path["post"]["tags"] + resp = client.get("/tools/test/test_tool_one") + assert resp.status_code == 200 + assert "input_schema" in resp.json() - def test_info_endpoints_use_info_tag(self, register_sample_tools): - """Info endpoints should use 'Info' tag.""" + def test_tool_execution(self, sample_registrations, tmp_path): app = FastAPI() - register_routes(app) - - openapi = app.openapi() - paths = openapi["paths"] - - # Check /tools uses Info tag - tools_path = paths.get("/tools") - assert tools_path is not None - assert "Info" in tools_path["get"]["tags"] - - def test_openapi_tags_metadata(self, register_sample_tools): - """App with openapi_tags should have tag descriptions.""" - tags = build_openapi_tags() - app = FastAPI(openapi_tags=tags) - register_routes(app) - - openapi = app.openapi() - assert "tags" in openapi - - tag_names = [t["name"] for t in openapi["tags"]] - assert "Info" in tag_names - assert "Test" in tag_names - assert "Other" in tag_names - - -class TestListToolsEndpoint: - """Tests for the /tools list endpoint.""" - - def test_total_tools_count(self, register_sample_tools): - """Should return correct total tool count.""" - app = FastAPI() - register_routes(app) + register_routes(app, tmp_path, sample_registrations) client = TestClient(app) - response = client.get("/tools") - data = response.json() - assert data["total_tools"] == 3 - - def test_categories_structure(self, register_sample_tools): - """Should return categories with count and tools list.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.get("/tools") - data = response.json() - - assert "test" in data["categories"] - assert data["categories"]["test"]["count"] == 2 - assert len(data["categories"]["test"]["tools"]) == 2 - - def test_categories_sorted(self, register_sample_tools): - """Should return categories in sorted order.""" - app = FastAPI() - register_routes(app) - client = TestClient(app) - - response = client.get("/tools") - data = response.json() - - category_names = list(data["categories"].keys()) - assert category_names == sorted(category_names) + resp = client.post("/tools/test_tool_two", json={"a": 5}) + assert resp.status_code == 200 + assert resp.json()["result"]["data"]["result"] == 15 diff --git a/tests/humcp/test_server.py b/tests/humcp/test_server.py index acaa72e..8bc7fb4 100644 --- a/tests/humcp/test_server.py +++ b/tests/humcp/test_server.py @@ -2,283 +2,111 @@ import tempfile from pathlib import Path -from unittest.mock import patch from fastapi import FastAPI from fastapi.testclient import TestClient -from src.humcp.server import _discover_tools, create_app +from src.humcp.server import _load_modules, create_app -class TestDiscoverTools: - """Tests for the _discover_tools function.""" +class TestLoadModules: + """Tests for the _load_modules function.""" - def test_discover_tools_returns_zero_for_nonexistent_path(self): - """Should return 0 when tools path doesn't exist.""" - result = _discover_tools(Path("/nonexistent/path")) - assert result == 0 + def test_returns_empty_for_nonexistent_path(self): + """Should return empty list when path doesn't exist.""" + result = _load_modules(Path("/nonexistent/path")) + assert result == [] - def test_discover_tools_skips_underscore_files(self, tmp_path): + def test_skips_underscore_files(self, tmp_path): """Should skip files starting with underscore.""" - # Create a file starting with underscore - init_file = tmp_path / "_init.py" - init_file.write_text("# init file") + (tmp_path / "_init.py").write_text("# init") + result = _load_modules(tmp_path) + assert result == [] - result = _discover_tools(tmp_path) - assert result == 0 - - def test_discover_tools_loads_valid_module(self, tmp_path): + def test_loads_valid_module(self, tmp_path): """Should load valid Python modules.""" - tool_file = tmp_path / "simple_tool.py" - tool_file.write_text( - """ -def dummy_function(): - pass -""" - ) - - result = _discover_tools(tmp_path) - assert result == 1 - - def test_discover_tools_handles_import_errors_gracefully(self, tmp_path): - """Should handle import errors without crashing.""" - tool_file = tmp_path / "broken_tool.py" - tool_file.write_text( - """ -import nonexistent_module_12345 -""" - ) - - result = _discover_tools(tmp_path) - assert result == 0 - - def test_discover_tools_recursive(self, tmp_path): - """Should discover tools in subdirectories.""" - subdir = tmp_path / "subdir" + (tmp_path / "tool.py").write_text("x = 1") + result = _load_modules(tmp_path) + assert len(result) == 1 + + def test_handles_import_errors(self, tmp_path): + """Should handle import errors gracefully.""" + (tmp_path / "broken.py").write_text("import nonexistent_xyz") + result = _load_modules(tmp_path) + assert result == [] + + def test_recursive_loading(self, tmp_path): + """Should load from subdirectories.""" + subdir = tmp_path / "sub" subdir.mkdir() - - tool1 = tmp_path / "tool1.py" - tool1.write_text("x = 1") - - tool2 = subdir / "tool2.py" - tool2.write_text("y = 2") - - result = _discover_tools(tmp_path) - assert result == 2 - - def test_discover_tools_sorted_loading(self, tmp_path): - """Should load modules in sorted order for determinism.""" - # Create files in reverse alphabetical order - (tmp_path / "zebra.py").write_text("z = 1") - (tmp_path / "alpha.py").write_text("a = 1") - (tmp_path / "beta.py").write_text("b = 1") - - with patch("src.humcp.server.logger") as mock_logger: - result = _discover_tools(tmp_path) - assert result == 3 - - # Check that modules were logged in sorted order - debug_calls = [ - call - for call in mock_logger.debug.call_args_list - if "Loaded" in str(call) - ] - # Verify at least some modules were loaded - assert len(debug_calls) == 3 + (tmp_path / "a.py").write_text("x = 1") + (subdir / "b.py").write_text("y = 2") + result = _load_modules(tmp_path) + assert len(result) == 2 class TestCreateApp: """Tests for the create_app function.""" - def test_create_app_returns_fastapi_instance(self): - """Should return a FastAPI application.""" + def test_returns_fastapi(self): + """Should return a FastAPI instance.""" with tempfile.TemporaryDirectory() as tmp: app = create_app(tools_path=tmp) assert isinstance(app, FastAPI) - def test_create_app_with_custom_title(self): + def test_custom_title(self): """Should use custom title.""" with tempfile.TemporaryDirectory() as tmp: - app = create_app(tools_path=tmp, title="Custom Title") - assert app.title == "Custom Title" + app = create_app(tools_path=tmp, title="Custom") + assert app.title == "Custom" - def test_create_app_with_custom_version(self): - """Should use custom version.""" - with tempfile.TemporaryDirectory() as tmp: - app = create_app(tools_path=tmp, version="2.0.0") - assert app.version == "2.0.0" - - def test_create_app_with_custom_description(self): - """Should use custom description.""" - with tempfile.TemporaryDirectory() as tmp: - app = create_app(tools_path=tmp, description="Custom description") - assert app.description == "Custom description" - - def test_create_app_has_root_endpoint(self): - """Should have a root info endpoint.""" + def test_root_endpoint(self): + """Should have root info endpoint.""" with tempfile.TemporaryDirectory() as tmp: app = create_app(tools_path=tmp) client = TestClient(app) - response = client.get("/") - assert response.status_code == 200 - data = response.json() + resp = client.get("/") + assert resp.status_code == 200 + data = resp.json() assert "name" in data - assert "version" in data assert "mcp_server" in data assert "tools_count" in data - assert "endpoints" in data - def test_create_app_has_tools_endpoint(self): - """Should have a /tools endpoint.""" + def test_tools_endpoint(self): + """Should have /tools endpoint.""" with tempfile.TemporaryDirectory() as tmp: app = create_app(tools_path=tmp) client = TestClient(app) - response = client.get("/tools") - assert response.status_code == 200 - data = response.json() - assert "total_tools" in data - assert "categories" in data + resp = client.get("/tools") + assert resp.status_code == 200 + assert "total_tools" in resp.json() - def test_create_app_mounts_mcp(self): + def test_mounts_mcp(self): """Should mount MCP at /mcp.""" with tempfile.TemporaryDirectory() as tmp: app = create_app(tools_path=tmp) - # Check that /mcp route exists - routes = [route.path for route in app.routes] + routes = [r.path for r in app.routes] assert "/mcp" in routes or any("/mcp" in str(r) for r in routes) - def test_create_app_default_tools_path(self): - """Should use default tools path when none provided.""" - # This test verifies the code path works even with real tools - app = create_app() - assert isinstance(app, FastAPI) - - def test_create_app_has_openapi_tags(self, tmp_path, register_sample_tools): - """Should include OpenAPI tags for categories.""" - app = create_app(tools_path=str(tmp_path)) - - openapi = app.openapi() - assert "tags" in openapi - - tag_names = [t["name"] for t in openapi["tags"]] - assert "Info" in tag_names - assert "Test" in tag_names # From register_sample_tools fixture - assert "Other" in tag_names - - def test_openapi_tags_have_descriptions(self, tmp_path, register_sample_tools): - """OpenAPI tags should have descriptions.""" - app = create_app(tools_path=str(tmp_path)) - - openapi = app.openapi() - for tag in openapi["tags"]: - assert "name" in tag - assert "description" in tag - - -class TestCreateAppWithTools: - """Tests for create_app with registered tools.""" - - def test_create_app_registers_tools(self, tmp_path, register_sample_tools): - """Should register tools from TOOL_REGISTRY.""" - app = create_app(tools_path=str(tmp_path)) - client = TestClient(app) - - response = client.get("/tools") - assert response.status_code == 200 - data = response.json() - assert data["total_tools"] >= 3 # At least our 3 sample tools - - def test_create_app_deduplicates_tools(self, tmp_path): - """Should not register the same function twice.""" - from src.humcp.decorator import tool - - @tool("dedupe_test_1", category="test") - async def shared_func(): - return {"success": True} - - # Register same function with different name (shouldn't happen normally) - # but this tests the deduplication logic - app = create_app(tools_path=str(tmp_path)) - assert isinstance(app, FastAPI) - - def test_root_endpoint_shows_tool_count(self, tmp_path, register_sample_tools): - """Root endpoint should show correct tool count.""" - app = create_app(tools_path=str(tmp_path)) - client = TestClient(app) - - response = client.get("/") - data = response.json() - assert data["tools_count"] >= 3 - - -class TestAppIntegration: - """Integration tests for the full app.""" - - def test_tool_execution_endpoint(self, tmp_path, register_sample_tools): - """Should be able to execute tools via REST.""" - app = create_app(tools_path=str(tmp_path)) + def test_discovers_and_registers_tools(self, sample_tools): + """Should discover and register tools.""" + app = create_app(tools_path=str(sample_tools)) client = TestClient(app) + resp = client.get("/tools") + assert resp.json()["total_tools"] == 3 - response = client.post( - "/tools/test_tool_one", - json={"value": "test_value"}, - ) - assert response.status_code == 200 - data = response.json() - assert data["result"]["success"] is True - assert data["result"]["data"]["value"] == "test_value" - - def test_tool_with_optional_params(self, tmp_path, register_sample_tools): - """Should handle optional parameters.""" - app = create_app(tools_path=str(tmp_path)) + def test_tool_execution(self, sample_tools): + """Should execute tools via REST.""" + app = create_app(tools_path=str(sample_tools)) client = TestClient(app) + resp = client.post("/tools/test_tool_one", json={"value": "hello"}) + assert resp.status_code == 200 + assert resp.json()["result"]["data"]["value"] == "hello" - # Call with only required param - response = client.post( - "/tools/test_tool_two", - json={"a": 5}, - ) - assert response.status_code == 200 - data = response.json() - assert data["result"]["data"]["result"] == 15 # 5 + 10 (default) - - # Call with both params - response = client.post( - "/tools/test_tool_two", - json={"a": 5, "b": 20}, - ) - assert response.status_code == 200 - data = response.json() - assert data["result"]["data"]["result"] == 25 - - def test_category_endpoint(self, tmp_path, register_sample_tools): + def test_category_endpoint(self, sample_tools): """Should list tools by category.""" - app = create_app(tools_path=str(tmp_path)) + app = create_app(tools_path=str(sample_tools)) client = TestClient(app) - - response = client.get("/tools/test") - assert response.status_code == 200 - data = response.json() - assert data["category"] == "test" - assert data["count"] >= 2 - - def test_category_not_found(self, tmp_path, register_sample_tools): - """Should return 404 for nonexistent category.""" - app = create_app(tools_path=str(tmp_path)) - client = TestClient(app) - - response = client.get("/tools/nonexistent_category_xyz") - assert response.status_code == 404 - - def test_tool_info_endpoint(self, tmp_path, register_sample_tools): - """Should get tool info with schema.""" - app = create_app(tools_path=str(tmp_path)) - client = TestClient(app) - - response = client.get("/tools/test/test_tool_one") - assert response.status_code == 200 - data = response.json() - assert data["name"] == "test_tool_one" - assert data["category"] == "test" - assert "input_schema" in data - assert data["input_schema"]["properties"]["value"]["type"] == "string" + resp = client.get("/tools/test") + assert resp.status_code == 200 + assert resp.json()["count"] == 2 diff --git a/tests/tools/google/test_calendar.py b/tests/tools/google/test_calendar.py index 5627262..508e48e 100644 --- a/tests/tools/google/test_calendar.py +++ b/tests/tools/google/test_calendar.py @@ -3,10 +3,10 @@ import pytest from src.tools.google.calendar import ( - create_event, - delete_event, - events, - list_calendars, + google_calendar_create_event, + google_calendar_delete_event, + google_calendar_events, + google_calendar_list, ) @@ -40,7 +40,7 @@ async def test_list_calendars_success(self, mock_calendar_service): ] } - result = await list_calendars() + result = await google_calendar_list() assert result["success"] is True assert result["data"]["total"] == 2 assert result["data"]["calendars"][0]["id"] == "primary" @@ -50,7 +50,7 @@ async def test_list_calendars_success(self, mock_calendar_service): async def test_list_calendars_empty(self, mock_calendar_service): mock_calendar_service.calendarList().list().execute.return_value = {"items": []} - result = await list_calendars() + result = await google_calendar_list() assert result["success"] is True assert result["data"]["total"] == 0 @@ -60,7 +60,7 @@ async def test_list_calendars_error(self, mock_calendar_service): "Auth failed" ) - result = await list_calendars() + result = await google_calendar_list() assert result["success"] is False assert "Auth failed" in result["error"] @@ -83,7 +83,7 @@ async def test_events_success(self, mock_calendar_service): ] } - result = await events() + result = await google_calendar_events() assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["events"][0]["title"] == "Team Meeting" @@ -103,7 +103,7 @@ async def test_events_all_day(self, mock_calendar_service): ] } - result = await events() + result = await google_calendar_events() assert result["success"] is True assert result["data"]["events"][0]["start"] == "2024-01-01" @@ -111,7 +111,7 @@ async def test_events_all_day(self, mock_calendar_service): async def test_events_with_custom_params(self, mock_calendar_service): mock_calendar_service.events().list().execute.return_value = {"items": []} - result = await events( + result = await google_calendar_events( calendar_id="work@group.calendar.google.com", days_ahead=14 ) assert result["success"] is True @@ -122,7 +122,7 @@ async def test_events_error(self, mock_calendar_service): "Calendar not found" ) - result = await events(calendar_id="invalid") + result = await google_calendar_events(calendar_id="invalid") assert result["success"] is False @@ -137,7 +137,7 @@ async def test_create_event_success(self, mock_calendar_service): "htmlLink": "https://calendar.google.com/new_event", } - result = await create_event( + result = await google_calendar_create_event( title="New Meeting", start_time="2024-01-20T14:00:00Z", end_time="2024-01-20T15:00:00Z", @@ -156,7 +156,7 @@ async def test_create_event_with_attendees(self, mock_calendar_service): "htmlLink": "https://calendar.google.com/team_event", } - result = await create_event( + result = await google_calendar_create_event( title="Team Sync", start_time="2024-01-20T14:00:00Z", end_time="2024-01-20T15:00:00Z", @@ -170,7 +170,7 @@ async def test_create_event_error(self, mock_calendar_service): "Invalid time" ) - result = await create_event( + result = await google_calendar_create_event( title="Bad Event", start_time="invalid", end_time="invalid", @@ -183,7 +183,7 @@ class TestDeleteEvent: async def test_delete_event_success(self, mock_calendar_service): mock_calendar_service.events().delete().execute.return_value = None - result = await delete_event("event123") + result = await google_calendar_delete_event("event123") assert result["success"] is True assert result["data"]["deleted_event_id"] == "event123" @@ -193,5 +193,5 @@ async def test_delete_event_not_found(self, mock_calendar_service): "Event not found" ) - result = await delete_event("nonexistent") + result = await google_calendar_delete_event("nonexistent") assert result["success"] is False diff --git a/tests/tools/google/test_chat.py b/tests/tools/google/test_chat.py index 95e6763..e6aae1d 100644 --- a/tests/tools/google/test_chat.py +++ b/tests/tools/google/test_chat.py @@ -3,11 +3,11 @@ import pytest from src.tools.google.chat import ( - get_message, - get_messages, - get_space, - list_spaces, - send_message, + google_chat_get_message, + google_chat_get_messages, + google_chat_get_space, + google_chat_list_spaces, + google_chat_send_message, ) @@ -41,7 +41,7 @@ async def test_list_spaces_success(self, mock_chat_service): ] } - result = await list_spaces() + result = await google_chat_list_spaces() assert result["success"] is True assert result["data"]["total"] == 2 assert result["data"]["spaces"][0]["display_name"] == "Engineering Team" @@ -56,7 +56,7 @@ async def test_list_spaces_filter_room(self, mock_chat_service): ] } - result = await list_spaces(space_type="room") + result = await google_chat_list_spaces(space_type="room") assert result["success"] is True assert result["data"]["total"] == 1 @@ -64,7 +64,7 @@ async def test_list_spaces_filter_room(self, mock_chat_service): async def test_list_spaces_empty(self, mock_chat_service): mock_chat_service.spaces().list().execute.return_value = {"spaces": []} - result = await list_spaces() + result = await google_chat_list_spaces() assert result["success"] is True assert result["data"]["total"] == 0 @@ -72,7 +72,7 @@ async def test_list_spaces_empty(self, mock_chat_service): async def test_list_spaces_error(self, mock_chat_service): mock_chat_service.spaces().list().execute.side_effect = Exception("API error") - result = await list_spaces() + result = await google_chat_list_spaces() assert result["success"] is False @@ -88,7 +88,7 @@ async def test_get_space_success(self, mock_chat_service): "externalUserAllowed": False, } - result = await get_space("spaces/space1") + result = await google_chat_get_space("spaces/space1") assert result["success"] is True assert result["data"]["name"] == "spaces/space1" assert result["data"]["display_name"] == "Project Alpha" @@ -98,7 +98,7 @@ async def test_get_space_success(self, mock_chat_service): async def test_get_space_error(self, mock_chat_service): mock_chat_service.spaces().get().execute.side_effect = Exception("Not found") - result = await get_space("spaces/invalid") + result = await google_chat_get_space("spaces/invalid") assert result["success"] is False @@ -124,7 +124,7 @@ async def test_get_messages_success(self, mock_chat_service): ] } - result = await get_messages("spaces/space1") + result = await google_chat_get_messages("spaces/space1") assert result["success"] is True assert result["data"]["total"] == 2 assert result["data"]["messages"][0]["text"] == "Hello everyone!" @@ -136,7 +136,7 @@ async def test_get_messages_empty(self, mock_chat_service): "messages": [] } - result = await get_messages("spaces/space1") + result = await google_chat_get_messages("spaces/space1") assert result["success"] is True assert result["data"]["total"] == 0 @@ -146,7 +146,7 @@ async def test_get_messages_error(self, mock_chat_service): "Space not found" ) - result = await get_messages("spaces/invalid") + result = await google_chat_get_messages("spaces/invalid") assert result["success"] is False @@ -162,7 +162,7 @@ async def test_get_message_success(self, mock_chat_service): "space": {"name": "spaces/space1"}, } - result = await get_message("spaces/space1/messages/msg1") + result = await google_chat_get_message("spaces/space1/messages/msg1") assert result["success"] is True assert result["data"]["text"] == "Important update" assert result["data"]["sender_type"] == "BOT" @@ -173,7 +173,7 @@ async def test_get_message_error(self, mock_chat_service): "Message not found" ) - result = await get_message("spaces/space1/messages/invalid") + result = await google_chat_get_message("spaces/space1/messages/invalid") assert result["success"] is False @@ -187,7 +187,7 @@ async def test_send_message_success(self, mock_chat_service): "thread": {"name": "spaces/space1/threads/new_thread"}, } - result = await send_message("spaces/space1", "Hello from the bot!") + result = await google_chat_send_message("spaces/space1", "Hello from the bot!") assert result["success"] is True assert result["data"]["text"] == "Hello from the bot!" assert "new_msg" in result["data"]["name"] @@ -201,7 +201,7 @@ async def test_send_message_with_thread(self, mock_chat_service): "thread": {"name": "spaces/space1/threads/existing_thread"}, } - result = await send_message( + result = await google_chat_send_message( "spaces/space1", "This is a reply", thread_key="existing_thread" ) assert result["success"] is True @@ -213,5 +213,5 @@ async def test_send_message_error(self, mock_chat_service): "Permission denied" ) - result = await send_message("spaces/space1", "Test message") + result = await google_chat_send_message("spaces/space1", "Test message") assert result["success"] is False diff --git a/tests/tools/google/test_docs.py b/tests/tools/google/test_docs.py index 89285c1..97cd0f1 100644 --- a/tests/tools/google/test_docs.py +++ b/tests/tools/google/test_docs.py @@ -3,12 +3,12 @@ import pytest from src.tools.google.docs import ( - append_text, - create_doc, - find_and_replace, - get_doc_content, - list_docs_in_folder, - search_docs, + google_docs_append_text, + google_docs_create, + google_docs_find_replace, + google_docs_get_content, + google_docs_list_in_folder, + google_docs_search, ) @@ -34,7 +34,7 @@ async def test_search_docs_success(self, mock_docs_service): ] } - result = await search_docs("report") + result = await google_docs_search("report") assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["documents"][0]["name"] == "Project Report" @@ -43,7 +43,7 @@ async def test_search_docs_success(self, mock_docs_service): async def test_search_docs_no_results(self, mock_docs_service): mock_docs_service.files().list().execute.return_value = {"files": []} - result = await search_docs("nonexistent") + result = await google_docs_search("nonexistent") assert result["success"] is True assert result["data"]["total"] == 0 @@ -53,7 +53,7 @@ async def test_search_docs_error(self, mock_docs_service): "Search failed" ) - result = await search_docs("test") + result = await google_docs_search("test") assert result["success"] is False @@ -82,7 +82,7 @@ async def test_get_doc_content_success(self, mock_docs_service): }, } - result = await get_doc_content("doc1") + result = await google_docs_get_content("doc1") assert result["success"] is True assert result["data"]["id"] == "doc1" assert result["data"]["title"] == "My Document" @@ -96,7 +96,7 @@ async def test_get_doc_content_empty(self, mock_docs_service): "body": {"content": []}, } - result = await get_doc_content("doc2") + result = await google_docs_get_content("doc2") assert result["success"] is True assert result["data"]["content"] == "" @@ -106,7 +106,7 @@ async def test_get_doc_content_error(self, mock_docs_service): "Document not found" ) - result = await get_doc_content("invalid") + result = await google_docs_get_content("invalid") assert result["success"] is False @@ -118,7 +118,7 @@ async def test_create_doc_success(self, mock_docs_service): "title": "New Document", } - result = await create_doc("New Document") + result = await google_docs_create("New Document") assert result["success"] is True assert result["data"]["id"] == "new_doc" assert result["data"]["title"] == "New Document" @@ -132,7 +132,9 @@ async def test_create_doc_with_content(self, mock_docs_service): } mock_docs_service.documents().batchUpdate().execute.return_value = {} - result = await create_doc("Doc with Content", content="Initial content here") + result = await google_docs_create( + "Doc with Content", content="Initial content here" + ) assert result["success"] is True @pytest.mark.asyncio @@ -141,7 +143,7 @@ async def test_create_doc_error(self, mock_docs_service): "Creation failed" ) - result = await create_doc("Test") + result = await google_docs_create("Test") assert result["success"] is False @@ -153,7 +155,7 @@ async def test_append_text_success(self, mock_docs_service): } mock_docs_service.documents().batchUpdate().execute.return_value = {} - result = await append_text("doc1", " appended text") + result = await google_docs_append_text("doc1", " appended text") assert result["success"] is True assert result["data"]["updated"] is True assert result["data"]["document_id"] == "doc1" @@ -164,7 +166,7 @@ async def test_append_text_error(self, mock_docs_service): "Document not found" ) - result = await append_text("invalid", "text") + result = await google_docs_append_text("invalid", "text") assert result["success"] is False @@ -175,7 +177,7 @@ async def test_find_and_replace_success(self, mock_docs_service): "replies": [{"replaceAllText": {"occurrencesChanged": 5}}] } - result = await find_and_replace("doc1", "old", "new") + result = await google_docs_find_replace("doc1", "old", "new") assert result["success"] is True assert result["data"]["replacements"] == 5 assert result["data"]["find_text"] == "old" @@ -187,7 +189,7 @@ async def test_find_and_replace_no_matches(self, mock_docs_service): "replies": [{"replaceAllText": {"occurrencesChanged": 0}}] } - result = await find_and_replace("doc1", "nonexistent", "new") + result = await google_docs_find_replace("doc1", "nonexistent", "new") assert result["success"] is True assert result["data"]["replacements"] == 0 @@ -197,7 +199,7 @@ async def test_find_and_replace_error(self, mock_docs_service): "Permission denied" ) - result = await find_and_replace("doc1", "old", "new") + result = await google_docs_find_replace("doc1", "old", "new") assert result["success"] is False @@ -221,7 +223,7 @@ async def test_list_docs_in_folder_success(self, mock_docs_service): ] } - result = await list_docs_in_folder("folder123") + result = await google_docs_list_in_folder("folder123") assert result["success"] is True assert result["data"]["total"] == 2 @@ -229,7 +231,7 @@ async def test_list_docs_in_folder_success(self, mock_docs_service): async def test_list_docs_in_folder_empty(self, mock_docs_service): mock_docs_service.files().list().execute.return_value = {"files": []} - result = await list_docs_in_folder("empty_folder") + result = await google_docs_list_in_folder("empty_folder") assert result["success"] is True assert result["data"]["total"] == 0 @@ -239,5 +241,5 @@ async def test_list_docs_in_folder_error(self, mock_docs_service): "Folder not found" ) - result = await list_docs_in_folder("invalid") + result = await google_docs_list_in_folder("invalid") assert result["success"] is False diff --git a/tests/tools/google/test_drive.py b/tests/tools/google/test_drive.py index 1f50adb..2b026fa 100644 --- a/tests/tools/google/test_drive.py +++ b/tests/tools/google/test_drive.py @@ -2,7 +2,12 @@ import pytest -from src.tools.google.drive import get_file, list_files, read_text_file, search +from src.tools.google.drive import ( + google_drive_get_file, + google_drive_list, + google_drive_read_text_file, + google_drive_search, +) @pytest.fixture @@ -29,7 +34,7 @@ async def test_list_files_success(self, mock_drive_service): ] } - result = await list_files() + result = await google_drive_list() assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["files"][0]["id"] == "file1" @@ -39,7 +44,7 @@ async def test_list_files_success(self, mock_drive_service): async def test_list_files_empty(self, mock_drive_service): mock_drive_service.files().list().execute.return_value = {"files": []} - result = await list_files() + result = await google_drive_list() assert result["success"] is True assert result["data"]["total"] == 0 assert result["data"]["files"] == [] @@ -48,21 +53,21 @@ async def test_list_files_empty(self, mock_drive_service): async def test_list_files_with_folder_id(self, mock_drive_service): mock_drive_service.files().list().execute.return_value = {"files": []} - result = await list_files(folder_id="folder123") + result = await google_drive_list(folder_id="folder123") assert result["success"] is True @pytest.mark.asyncio async def test_list_files_with_file_type(self, mock_drive_service): mock_drive_service.files().list().execute.return_value = {"files": []} - result = await list_files(file_type="image") + result = await google_drive_list(file_type="image") assert result["success"] is True @pytest.mark.asyncio async def test_list_files_error(self, mock_drive_service): mock_drive_service.files().list().execute.side_effect = Exception("API error") - result = await list_files() + result = await google_drive_list() assert result["success"] is False assert "API error" in result["error"] @@ -83,7 +88,7 @@ async def test_search_success(self, mock_drive_service): ] } - result = await search("report") + result = await google_drive_search("report") assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["query"] == "report" @@ -93,7 +98,7 @@ async def test_search_success(self, mock_drive_service): async def test_search_no_results(self, mock_drive_service): mock_drive_service.files().list().execute.return_value = {"files": []} - result = await search("nonexistent") + result = await google_drive_search("nonexistent") assert result["success"] is True assert result["data"]["total"] == 0 @@ -103,7 +108,7 @@ async def test_search_error(self, mock_drive_service): "Search failed" ) - result = await search("test") + result = await google_drive_search("test") assert result["success"] is False assert "Search failed" in result["error"] @@ -123,7 +128,7 @@ async def test_get_file_success(self, mock_drive_service): "parents": ["folder1"], } - result = await get_file("file123") + result = await google_drive_get_file("file123") assert result["success"] is True assert result["data"]["id"] == "file123" assert result["data"]["name"] == "Important.pdf" @@ -135,7 +140,7 @@ async def test_get_file_not_found(self, mock_drive_service): "File not found" ) - result = await get_file("nonexistent") + result = await google_drive_get_file("nonexistent") assert result["success"] is False assert "not found" in result["error"].lower() @@ -160,7 +165,7 @@ async def test_read_text_file_success(self, mock_drive_service): mock_buffer.getvalue.return_value = b"Hello, World!" mock_bytesio.return_value = mock_buffer - result = await read_text_file("file123") + result = await google_drive_read_text_file("file123") assert result["success"] is True assert result["data"]["name"] == "notes.txt" assert result["data"]["content"] == "Hello, World!" @@ -182,7 +187,7 @@ async def test_read_text_file_google_doc(self, mock_drive_service): mock_buffer.getvalue.return_value = b"Document content" mock_bytesio.return_value = mock_buffer - result = await read_text_file("doc123") + result = await google_drive_read_text_file("doc123") assert result["success"] is True @pytest.mark.asyncio @@ -191,6 +196,6 @@ async def test_read_text_file_error(self, mock_drive_service): "Access denied" ) - result = await read_text_file("file123") + result = await google_drive_read_text_file("file123") assert result["success"] is False assert "Access denied" in result["error"] diff --git a/tests/tools/google/test_forms.py b/tests/tools/google/test_forms.py index 44bca86..5fa755f 100644 --- a/tests/tools/google/test_forms.py +++ b/tests/tools/google/test_forms.py @@ -3,11 +3,11 @@ import pytest from src.tools.google.forms import ( - create_form, - get_form, - get_form_response, - list_form_responses, - list_forms, + google_forms_create_form, + google_forms_get_form, + google_forms_get_response, + google_forms_list_forms, + google_forms_list_responses, ) @@ -33,7 +33,7 @@ async def test_list_forms_success(self, mock_forms_service): ] } - result = await list_forms() + result = await google_forms_list_forms() assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["forms"][0]["name"] == "Customer Survey" @@ -42,7 +42,7 @@ async def test_list_forms_success(self, mock_forms_service): async def test_list_forms_empty(self, mock_forms_service): mock_forms_service.files().list().execute.return_value = {"files": []} - result = await list_forms() + result = await google_forms_list_forms() assert result["success"] is True assert result["data"]["total"] == 0 @@ -50,7 +50,7 @@ async def test_list_forms_empty(self, mock_forms_service): async def test_list_forms_error(self, mock_forms_service): mock_forms_service.files().list().execute.side_effect = Exception("API error") - result = await list_forms() + result = await google_forms_list_forms() assert result["success"] is False @@ -89,7 +89,7 @@ async def test_get_form_success(self, mock_forms_service): ], } - result = await get_form("form1") + result = await google_forms_get_form("form1") assert result["success"] is True assert result["data"]["id"] == "form1" assert result["data"]["title"] == "Customer Survey" @@ -124,7 +124,7 @@ async def test_get_form_with_choice_question(self, mock_forms_service): ], } - result = await get_form("form2") + result = await google_forms_get_form("form2") assert result["success"] is True assert result["data"]["questions"][0]["type"] == "radio" assert len(result["data"]["questions"][0]["options"]) == 3 @@ -133,7 +133,7 @@ async def test_get_form_with_choice_question(self, mock_forms_service): async def test_get_form_error(self, mock_forms_service): mock_forms_service.forms().get().execute.side_effect = Exception("Not found") - result = await get_form("invalid") + result = await google_forms_get_form("invalid") assert result["success"] is False @@ -146,7 +146,7 @@ async def test_create_form_success(self, mock_forms_service): "responderUri": "https://docs.google.com/forms/d/new_form/viewform", } - result = await create_form("New Form") + result = await google_forms_create_form("New Form") assert result["success"] is True assert result["data"]["id"] == "new_form" assert result["data"]["title"] == "New Form" @@ -160,7 +160,9 @@ async def test_create_form_with_document_title(self, mock_forms_service): "responderUri": "https://docs.google.com/forms/d/new_form/viewform", } - result = await create_form("Survey Title", document_title="Survey Doc") + result = await google_forms_create_form( + "Survey Title", document_title="Survey Doc" + ) assert result["success"] is True assert result["data"]["document_title"] == "Survey Doc" @@ -170,7 +172,7 @@ async def test_create_form_error(self, mock_forms_service): "Creation failed" ) - result = await create_form("Test") + result = await google_forms_create_form("Test") assert result["success"] is False @@ -194,7 +196,7 @@ async def test_list_form_responses_success(self, mock_forms_service): ] } - result = await list_form_responses("form1") + result = await google_forms_list_responses("form1") assert result["success"] is True assert result["data"]["total"] == 2 assert result["data"]["responses"][0]["id"] == "resp1" @@ -206,7 +208,7 @@ async def test_list_form_responses_empty(self, mock_forms_service): "responses": [] } - result = await list_form_responses("form1") + result = await google_forms_list_responses("form1") assert result["success"] is True assert result["data"]["total"] == 0 @@ -216,7 +218,7 @@ async def test_list_form_responses_error(self, mock_forms_service): "Not found" ) - result = await list_form_responses("invalid") + result = await google_forms_list_responses("invalid") assert result["success"] is False @@ -233,7 +235,7 @@ async def test_get_form_response_success(self, mock_forms_service): }, } - result = await get_form_response("form1", "resp1") + result = await google_forms_get_response("form1", "resp1") assert result["success"] is True assert result["data"]["response_id"] == "resp1" assert len(result["data"]["answers"]) == 2 @@ -254,7 +256,7 @@ async def test_get_form_response_with_file_upload(self, mock_forms_service): }, } - result = await get_form_response("form1", "resp2") + result = await google_forms_get_response("form1", "resp2") assert result["success"] is True assert result["data"]["answers"][0]["type"] == "file" assert result["data"]["answers"][0]["files"][0]["name"] == "document.pdf" @@ -265,5 +267,5 @@ async def test_get_form_response_error(self, mock_forms_service): "Not found" ) - result = await get_form_response("form1", "invalid") + result = await google_forms_get_response("form1", "invalid") assert result["success"] is False diff --git a/tests/tools/google/test_gmail.py b/tests/tools/google/test_gmail.py index ccd8e7f..33e28e8 100644 --- a/tests/tools/google/test_gmail.py +++ b/tests/tools/google/test_gmail.py @@ -2,7 +2,12 @@ import pytest -from src.tools.google.gmail import labels, read, search, send +from src.tools.google.gmail import ( + google_gmail_labels, + google_gmail_read, + google_gmail_search, + google_gmail_send, +) @pytest.fixture @@ -33,7 +38,7 @@ async def test_search_success(self, mock_gmail_service): }, } - result = await search("test") + result = await google_gmail_search("test") assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["messages"][0]["subject"] == "Test Subject" @@ -45,7 +50,7 @@ async def test_search_no_results(self, mock_gmail_service): "messages": [] } - result = await search("nonexistent query") + result = await google_gmail_search("nonexistent query") assert result["success"] is True assert result["data"]["total"] == 0 @@ -55,7 +60,7 @@ async def test_search_error(self, mock_gmail_service): "Search failed" ) - result = await search("test") + result = await google_gmail_search("test") assert result["success"] is False @@ -78,7 +83,7 @@ async def test_read_success(self, mock_gmail_service): }, } - result = await read("msg1") + result = await google_gmail_read("msg1") assert result["success"] is True assert result["data"]["subject"] == "Important Email" assert result["data"]["body"] == "Hello, World!" @@ -108,7 +113,7 @@ async def test_read_multipart(self, mock_gmail_service): }, } - result = await read("msg2") + result = await google_gmail_read("msg2") assert result["success"] is True assert result["data"]["body"] == "Plain text" @@ -118,7 +123,7 @@ async def test_read_error(self, mock_gmail_service): "Message not found" ) - result = await read("invalid_id") + result = await google_gmail_read("invalid_id") assert result["success"] is False @@ -130,7 +135,7 @@ async def test_send_success(self, mock_gmail_service): "threadId": "new_thread", } - result = await send( + result = await google_gmail_send( to="recipient@example.com", subject="Test Email", body="This is a test email body.", @@ -145,7 +150,7 @@ async def test_send_with_cc_bcc(self, mock_gmail_service): "threadId": "thread2", } - result = await send( + result = await google_gmail_send( to="recipient@example.com", subject="Team Update", body="Update content", @@ -160,7 +165,7 @@ async def test_send_error(self, mock_gmail_service): "Send failed" ) - result = await send( + result = await google_gmail_send( to="invalid", subject="Test", body="Body", @@ -179,7 +184,7 @@ async def test_labels_success(self, mock_gmail_service): ] } - result = await labels() + result = await google_gmail_labels() assert result["success"] is True assert result["data"]["total"] == 3 assert result["data"]["labels"][0]["name"] == "INBOX" @@ -190,5 +195,5 @@ async def test_labels_error(self, mock_gmail_service): "Failed to list labels" ) - result = await labels() + result = await google_gmail_labels() assert result["success"] is False diff --git a/tests/tools/google/test_sheets.py b/tests/tools/google/test_sheets.py index 44d22a2..ed7d3a9 100644 --- a/tests/tools/google/test_sheets.py +++ b/tests/tools/google/test_sheets.py @@ -3,14 +3,14 @@ import pytest from src.tools.google.sheets import ( - add_sheet, - append_sheet_values, - clear_sheet_values, - create_spreadsheet, - get_spreadsheet_info, - list_spreadsheets, - read_sheet_values, - write_sheet_values, + google_sheets_add_sheet, + google_sheets_append_values, + google_sheets_clear_values, + google_sheets_create_spreadsheet, + google_sheets_get_info, + google_sheets_list_spreadsheets, + google_sheets_read_values, + google_sheets_write_values, ) @@ -36,7 +36,7 @@ async def test_list_spreadsheets_success(self, mock_sheets_service): ] } - result = await list_spreadsheets() + result = await google_sheets_list_spreadsheets() assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["spreadsheets"][0]["id"] == "sheet1" @@ -46,7 +46,7 @@ async def test_list_spreadsheets_success(self, mock_sheets_service): async def test_list_spreadsheets_empty(self, mock_sheets_service): mock_sheets_service.files().list().execute.return_value = {"files": []} - result = await list_spreadsheets() + result = await google_sheets_list_spreadsheets() assert result["success"] is True assert result["data"]["total"] == 0 @@ -54,7 +54,7 @@ async def test_list_spreadsheets_empty(self, mock_sheets_service): async def test_list_spreadsheets_error(self, mock_sheets_service): mock_sheets_service.files().list().execute.side_effect = Exception("API error") - result = await list_spreadsheets() + result = await google_sheets_list_spreadsheets() assert result["success"] is False assert "API error" in result["error"] @@ -78,7 +78,7 @@ async def test_get_spreadsheet_info_success(self, mock_sheets_service): ], } - result = await get_spreadsheet_info("sheet1") + result = await google_sheets_get_info("sheet1") assert result["success"] is True assert result["data"]["id"] == "sheet1" assert result["data"]["title"] == "My Spreadsheet" @@ -91,7 +91,7 @@ async def test_get_spreadsheet_info_error(self, mock_sheets_service): "Not found" ) - result = await get_spreadsheet_info("nonexistent") + result = await google_sheets_get_info("nonexistent") assert result["success"] is False @@ -107,7 +107,7 @@ async def test_read_sheet_values_success(self, mock_sheets_service): ], } - result = await read_sheet_values("sheet1", "Sheet1!A1:C3") + result = await google_sheets_read_values("sheet1", "Sheet1!A1:C3") assert result["success"] is True assert result["data"]["row_count"] == 3 assert result["data"]["column_count"] == 3 @@ -120,7 +120,7 @@ async def test_read_sheet_values_empty(self, mock_sheets_service): "values": [], } - result = await read_sheet_values("sheet1") + result = await google_sheets_read_values("sheet1") assert result["success"] is True assert result["data"]["row_count"] == 0 @@ -130,7 +130,7 @@ async def test_read_sheet_values_error(self, mock_sheets_service): Exception("Invalid range") ) - result = await read_sheet_values("sheet1", "Invalid!") + result = await google_sheets_read_values("sheet1", "Invalid!") assert result["success"] is False @@ -144,7 +144,7 @@ async def test_write_sheet_values_success(self, mock_sheets_service): "updatedCells": 4, } - result = await write_sheet_values( + result = await google_sheets_write_values( "sheet1", "Sheet1!A1:B2", [["A", "B"], ["C", "D"]] ) assert result["success"] is True @@ -157,7 +157,7 @@ async def test_write_sheet_values_error(self, mock_sheets_service): Exception("Permission denied") ) - result = await write_sheet_values("sheet1", "Sheet1!A1", [["data"]]) + result = await google_sheets_write_values("sheet1", "Sheet1!A1", [["data"]]) assert result["success"] is False @@ -172,7 +172,7 @@ async def test_append_sheet_values_success(self, mock_sheets_service): } } - result = await append_sheet_values("sheet1", "Sheet1", [["New", "Row"]]) + result = await google_sheets_append_values("sheet1", "Sheet1", [["New", "Row"]]) assert result["success"] is True assert result["data"]["updated_rows"] == 1 @@ -182,7 +182,7 @@ async def test_append_sheet_values_error(self, mock_sheets_service): Exception("Quota exceeded") ) - result = await append_sheet_values("sheet1", "Sheet1", [["data"]]) + result = await google_sheets_append_values("sheet1", "Sheet1", [["data"]]) assert result["success"] is False @@ -196,7 +196,7 @@ async def test_create_spreadsheet_success(self, mock_sheets_service): "sheets": [{"properties": {"title": "Sheet1"}}], } - result = await create_spreadsheet("New Spreadsheet") + result = await google_sheets_create_spreadsheet("New Spreadsheet") assert result["success"] is True assert result["data"]["id"] == "new_sheet" assert result["data"]["title"] == "New Spreadsheet" @@ -213,7 +213,7 @@ async def test_create_spreadsheet_with_sheets(self, mock_sheets_service): ], } - result = await create_spreadsheet( + result = await google_sheets_create_spreadsheet( "Multi Sheet", sheet_names=["Data", "Summary"] ) assert result["success"] is True @@ -225,7 +225,7 @@ async def test_create_spreadsheet_error(self, mock_sheets_service): "Creation failed" ) - result = await create_spreadsheet("Test") + result = await google_sheets_create_spreadsheet("Test") assert result["success"] is False @@ -238,7 +238,7 @@ async def test_add_sheet_success(self, mock_sheets_service): ] } - result = await add_sheet("sheet1", "New Tab") + result = await google_sheets_add_sheet("sheet1", "New Tab") assert result["success"] is True assert result["data"]["sheet_id"] == 123 assert result["data"]["title"] == "New Tab" @@ -249,7 +249,7 @@ async def test_add_sheet_error(self, mock_sheets_service): Exception("Duplicate name") ) - result = await add_sheet("sheet1", "Sheet1") + result = await google_sheets_add_sheet("sheet1", "Sheet1") assert result["success"] is False @@ -260,7 +260,7 @@ async def test_clear_sheet_values_success(self, mock_sheets_service): "clearedRange": "Sheet1!A1:Z100" } - result = await clear_sheet_values("sheet1", "Sheet1!A1:Z100") + result = await google_sheets_clear_values("sheet1", "Sheet1!A1:Z100") assert result["success"] is True assert result["data"]["cleared_range"] == "Sheet1!A1:Z100" @@ -270,5 +270,5 @@ async def test_clear_sheet_values_error(self, mock_sheets_service): Exception("Invalid range") ) - result = await clear_sheet_values("sheet1", "Invalid!") + result = await google_sheets_clear_values("sheet1", "Invalid!") assert result["success"] is False diff --git a/tests/tools/google/test_slides.py b/tests/tools/google/test_slides.py index 231a9ee..bb3aae5 100644 --- a/tests/tools/google/test_slides.py +++ b/tests/tools/google/test_slides.py @@ -3,12 +3,12 @@ import pytest from src.tools.google.slides import ( - add_slide, - add_text_to_slide, - create_presentation, - get_presentation, - get_slide_thumbnail, - list_presentations, + google_slides_add_slide, + google_slides_add_text, + google_slides_create_presentation, + google_slides_get_presentation, + google_slides_get_thumbnail, + google_slides_list_presentations, ) @@ -34,7 +34,7 @@ async def test_list_presentations_success(self, mock_slides_service): ] } - result = await list_presentations() + result = await google_slides_list_presentations() assert result["success"] is True assert result["data"]["total"] == 1 assert result["data"]["presentations"][0]["name"] == "Q1 Review" @@ -43,7 +43,7 @@ async def test_list_presentations_success(self, mock_slides_service): async def test_list_presentations_empty(self, mock_slides_service): mock_slides_service.files().list().execute.return_value = {"files": []} - result = await list_presentations() + result = await google_slides_list_presentations() assert result["success"] is True assert result["data"]["total"] == 0 @@ -51,7 +51,7 @@ async def test_list_presentations_empty(self, mock_slides_service): async def test_list_presentations_error(self, mock_slides_service): mock_slides_service.files().list().execute.side_effect = Exception("API error") - result = await list_presentations() + result = await google_slides_list_presentations() assert result["success"] is False @@ -83,7 +83,7 @@ async def test_get_presentation_success(self, mock_slides_service): ], } - result = await get_presentation("pres1") + result = await google_slides_get_presentation("pres1") assert result["success"] is True assert result["data"]["id"] == "pres1" assert result["data"]["title"] == "My Presentation" @@ -95,7 +95,7 @@ async def test_get_presentation_error(self, mock_slides_service): "Not found" ) - result = await get_presentation("invalid") + result = await google_slides_get_presentation("invalid") assert result["success"] is False @@ -108,7 +108,7 @@ async def test_create_presentation_success(self, mock_slides_service): "slides": [{"objectId": "slide1"}], } - result = await create_presentation("New Presentation") + result = await google_slides_create_presentation("New Presentation") assert result["success"] is True assert result["data"]["id"] == "new_pres" assert result["data"]["title"] == "New Presentation" @@ -120,7 +120,7 @@ async def test_create_presentation_error(self, mock_slides_service): "Creation failed" ) - result = await create_presentation("Test") + result = await google_slides_create_presentation("Test") assert result["success"] is False @@ -134,7 +134,7 @@ async def test_add_slide_success(self, mock_slides_service): "replies": [{"createSlide": {"objectId": "new_slide"}}] } - result = await add_slide("pres1") + result = await google_slides_add_slide("pres1") assert result["success"] is True assert result["data"]["slide_id"] == "new_slide" assert result["data"]["presentation_id"] == "pres1" @@ -148,7 +148,7 @@ async def test_add_slide_with_layout(self, mock_slides_service): "replies": [{"createSlide": {"objectId": "new_slide"}}] } - result = await add_slide("pres1", layout="TITLE_AND_BODY") + result = await google_slides_add_slide("pres1", layout="TITLE_AND_BODY") assert result["success"] is True assert result["data"]["layout"] == "TITLE_AND_BODY" @@ -158,7 +158,7 @@ async def test_add_slide_error(self, mock_slides_service): Exception("Presentation not found") ) - result = await add_slide("invalid") + result = await google_slides_add_slide("invalid") assert result["success"] is False @@ -167,7 +167,7 @@ class TestAddTextToSlide: async def test_add_text_to_slide_success(self, mock_slides_service): mock_slides_service.presentations().batchUpdate().execute.return_value = {} - result = await add_text_to_slide("pres1", "slide1", "Hello, World!") + result = await google_slides_add_text("pres1", "slide1", "Hello, World!") assert result["success"] is True assert result["data"]["text"] == "Hello, World!" assert result["data"]["slide_id"] == "slide1" @@ -176,7 +176,7 @@ async def test_add_text_to_slide_success(self, mock_slides_service): async def test_add_text_to_slide_with_position(self, mock_slides_service): mock_slides_service.presentations().batchUpdate().execute.return_value = {} - result = await add_text_to_slide( + result = await google_slides_add_text( "pres1", "slide1", "Positioned text", x=200, y=300, width=500, height=50 ) assert result["success"] is True @@ -187,7 +187,7 @@ async def test_add_text_to_slide_error(self, mock_slides_service): Exception("Slide not found") ) - result = await add_text_to_slide("pres1", "invalid", "text") + result = await google_slides_add_text("pres1", "invalid", "text") assert result["success"] is False @@ -200,7 +200,7 @@ async def test_get_slide_thumbnail_success(self, mock_slides_service): "height": 450, } - result = await get_slide_thumbnail("pres1", "slide1") + result = await google_slides_get_thumbnail("pres1", "slide1") assert result["success"] is True assert result["data"]["slide_id"] == "slide1" assert "thumbnail.png" in result["data"]["content_url"] @@ -213,7 +213,7 @@ async def test_get_slide_thumbnail_with_size(self, mock_slides_service): "height": 900, } - result = await get_slide_thumbnail("pres1", "slide1", size="LARGE") + result = await google_slides_get_thumbnail("pres1", "slide1", size="LARGE") assert result["success"] is True @pytest.mark.asyncio @@ -222,5 +222,5 @@ async def test_get_slide_thumbnail_error(self, mock_slides_service): "Slide not found" ) - result = await get_slide_thumbnail("pres1", "invalid") + result = await google_slides_get_thumbnail("pres1", "invalid") assert result["success"] is False diff --git a/tests/tools/google/test_tasks.py b/tests/tools/google/test_tasks.py index 2f3391a..5fbd83c 100644 --- a/tests/tools/google/test_tasks.py +++ b/tests/tools/google/test_tasks.py @@ -3,17 +3,17 @@ import pytest from src.tools.google.tasks import ( - clear_completed_tasks, - complete_task, - create_task, - create_task_list, - delete_task, - delete_task_list, - get_task, - get_task_list, - list_task_lists, - list_tasks, - update_task, + google_tasks_clear_completed, + google_tasks_complete_task, + google_tasks_create_task, + google_tasks_create_task_list, + google_tasks_delete_task, + google_tasks_delete_task_list, + google_tasks_get_task, + google_tasks_get_task_list, + google_tasks_list_task_lists, + google_tasks_list_tasks, + google_tasks_update_task, ) @@ -35,7 +35,7 @@ async def test_list_task_lists_success(self, mock_tasks_service): ] } - result = await list_task_lists() + result = await google_tasks_list_task_lists() assert result["success"] is True assert result["data"]["total"] == 2 assert result["data"]["task_lists"][0]["title"] == "My Tasks" @@ -44,7 +44,7 @@ async def test_list_task_lists_success(self, mock_tasks_service): async def test_list_task_lists_empty(self, mock_tasks_service): mock_tasks_service.tasklists().list().execute.return_value = {"items": []} - result = await list_task_lists() + result = await google_tasks_list_task_lists() assert result["success"] is True assert result["data"]["total"] == 0 @@ -54,7 +54,7 @@ async def test_list_task_lists_error(self, mock_tasks_service): "API error" ) - result = await list_task_lists() + result = await google_tasks_list_task_lists() assert result["success"] is False @@ -67,7 +67,7 @@ async def test_get_task_list_success(self, mock_tasks_service): "updated": "2024-01-01T00:00:00Z", } - result = await get_task_list("list1") + result = await google_tasks_get_task_list("list1") assert result["success"] is True assert result["data"]["id"] == "list1" assert result["data"]["title"] == "My Tasks" @@ -78,7 +78,7 @@ async def test_get_task_list_error(self, mock_tasks_service): "Not found" ) - result = await get_task_list("invalid") + result = await google_tasks_get_task_list("invalid") assert result["success"] is False @@ -91,7 +91,7 @@ async def test_create_task_list_success(self, mock_tasks_service): "updated": "2024-01-15T00:00:00Z", } - result = await create_task_list("New List") + result = await google_tasks_create_task_list("New List") assert result["success"] is True assert result["data"]["id"] == "new_list" assert result["data"]["title"] == "New List" @@ -102,7 +102,7 @@ async def test_create_task_list_error(self, mock_tasks_service): "Creation failed" ) - result = await create_task_list("Test") + result = await google_tasks_create_task_list("Test") assert result["success"] is False @@ -111,7 +111,7 @@ class TestDeleteTaskList: async def test_delete_task_list_success(self, mock_tasks_service): mock_tasks_service.tasklists().delete().execute.return_value = None - result = await delete_task_list("list1") + result = await google_tasks_delete_task_list("list1") assert result["success"] is True assert result["data"]["deleted_task_list_id"] == "list1" @@ -121,7 +121,7 @@ async def test_delete_task_list_error(self, mock_tasks_service): "Not found" ) - result = await delete_task_list("invalid") + result = await google_tasks_delete_task_list("invalid") assert result["success"] is False @@ -146,7 +146,7 @@ async def test_list_tasks_success(self, mock_tasks_service): ] } - result = await list_tasks() + result = await google_tasks_list_tasks() assert result["success"] is True assert result["data"]["total"] == 2 assert result["data"]["tasks"][0]["title"] == "Buy groceries" @@ -155,7 +155,7 @@ async def test_list_tasks_success(self, mock_tasks_service): async def test_list_tasks_empty(self, mock_tasks_service): mock_tasks_service.tasks().list().execute.return_value = {"items": []} - result = await list_tasks() + result = await google_tasks_list_tasks() assert result["success"] is True assert result["data"]["total"] == 0 @@ -163,7 +163,7 @@ async def test_list_tasks_empty(self, mock_tasks_service): async def test_list_tasks_error(self, mock_tasks_service): mock_tasks_service.tasks().list().execute.side_effect = Exception("API error") - result = await list_tasks() + result = await google_tasks_list_tasks() assert result["success"] is False @@ -179,7 +179,7 @@ async def test_get_task_success(self, mock_tasks_service): "links": [], } - result = await get_task("list1", "task1") + result = await google_tasks_get_task("list1", "task1") assert result["success"] is True assert result["data"]["id"] == "task1" assert result["data"]["title"] == "Important task" @@ -188,7 +188,7 @@ async def test_get_task_success(self, mock_tasks_service): async def test_get_task_error(self, mock_tasks_service): mock_tasks_service.tasks().get().execute.side_effect = Exception("Not found") - result = await get_task("list1", "invalid") + result = await google_tasks_get_task("list1", "invalid") assert result["success"] is False @@ -203,7 +203,7 @@ async def test_create_task_success(self, mock_tasks_service): "due": "2024-01-25T00:00:00Z", } - result = await create_task(title="New task", notes="Notes here") + result = await google_tasks_create_task(title="New task", notes="Notes here") assert result["success"] is True assert result["data"]["id"] == "new_task" assert result["data"]["title"] == "New task" @@ -214,7 +214,7 @@ async def test_create_task_error(self, mock_tasks_service): "Creation failed" ) - result = await create_task(title="Test") + result = await google_tasks_create_task(title="Test") assert result["success"] is False @@ -232,7 +232,7 @@ async def test_update_task_success(self, mock_tasks_service): "status": "needsAction", } - result = await update_task("list1", "task1", title="Updated title") + result = await google_tasks_update_task("list1", "task1", title="Updated title") assert result["success"] is True assert result["data"]["title"] == "Updated title" @@ -240,7 +240,7 @@ async def test_update_task_success(self, mock_tasks_service): async def test_update_task_error(self, mock_tasks_service): mock_tasks_service.tasks().get().execute.side_effect = Exception("Not found") - result = await update_task("list1", "invalid", title="New title") + result = await google_tasks_update_task("list1", "invalid", title="New title") assert result["success"] is False @@ -249,7 +249,7 @@ class TestDeleteTask: async def test_delete_task_success(self, mock_tasks_service): mock_tasks_service.tasks().delete().execute.return_value = None - result = await delete_task("list1", "task1") + result = await google_tasks_delete_task("list1", "task1") assert result["success"] is True assert result["data"]["deleted_task_id"] == "task1" @@ -257,7 +257,7 @@ async def test_delete_task_success(self, mock_tasks_service): async def test_delete_task_error(self, mock_tasks_service): mock_tasks_service.tasks().delete().execute.side_effect = Exception("Not found") - result = await delete_task("list1", "invalid") + result = await google_tasks_delete_task("list1", "invalid") assert result["success"] is False @@ -276,7 +276,7 @@ async def test_complete_task_success(self, mock_tasks_service): "completed": "2024-01-15T00:00:00Z", } - result = await complete_task("list1", "task1") + result = await google_tasks_complete_task("list1", "task1") assert result["success"] is True assert result["data"]["status"] == "completed" @@ -286,7 +286,7 @@ class TestClearCompletedTasks: async def test_clear_completed_tasks_success(self, mock_tasks_service): mock_tasks_service.tasks().clear().execute.return_value = None - result = await clear_completed_tasks() + result = await google_tasks_clear_completed() assert result["success"] is True assert result["data"]["cleared"] is True @@ -296,5 +296,5 @@ async def test_clear_completed_tasks_error(self, mock_tasks_service): "Clear failed" ) - result = await clear_completed_tasks() + result = await google_tasks_clear_completed() assert result["success"] is False diff --git a/tests/tools/local/test_local_file_system.py b/tests/tools/local/test_local_file_system.py index 2b623c5..5f861da 100644 --- a/tests/tools/local/test_local_file_system.py +++ b/tests/tools/local/test_local_file_system.py @@ -1,22 +1,22 @@ import pytest from src.tools.local.local_file_system import ( - append_to_file, - copy_file, - create_directory, - delete_file, - file_exists, - get_file_info, - list_files, - read_file, - write_file, + filesystem_append_to_file, + filesystem_copy_file, + filesystem_create_directory, + filesystem_delete_file, + filesystem_file_exists, + filesystem_get_file_info, + filesystem_list_files, + filesystem_read_file, + filesystem_write_file, ) class TestWriteFile: @pytest.mark.asyncio async def test_write_file_basic(self, tmp_path): - result = await write_file( + result = await filesystem_write_file( content="Hello, World!", filename="test.txt", directory=str(tmp_path) ) assert result["success"] is True @@ -25,7 +25,7 @@ async def test_write_file_basic(self, tmp_path): @pytest.mark.asyncio async def test_write_file_with_extension(self, tmp_path): - result = await write_file( + result = await filesystem_write_file( content="data", filename="myfile", directory=str(tmp_path), extension="json" ) assert result["success"] is True @@ -38,13 +38,17 @@ async def test_read_file_exists(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("Hello, World!") - result = await read_file(filename="test.txt", directory=str(tmp_path)) + result = await filesystem_read_file( + filename="test.txt", directory=str(tmp_path) + ) assert result["success"] is True assert result["data"]["content"] == "Hello, World!" @pytest.mark.asyncio async def test_read_file_not_found(self, tmp_path): - result = await read_file(filename="nonexistent.txt", directory=str(tmp_path)) + result = await filesystem_read_file( + filename="nonexistent.txt", directory=str(tmp_path) + ) assert result["success"] is False assert "not found" in result["error"].lower() @@ -55,7 +59,7 @@ async def test_list_files_basic(self, tmp_path): (tmp_path / "file1.txt").write_text("content1") (tmp_path / "file2.txt").write_text("content2") - result = await list_files(directory=str(tmp_path)) + result = await filesystem_list_files(directory=str(tmp_path)) assert result["success"] is True assert result["count"] == 2 @@ -64,13 +68,13 @@ async def test_list_files_with_pattern(self, tmp_path): (tmp_path / "file1.txt").write_text("content1") (tmp_path / "file2.py").write_text("content2") - result = await list_files(directory=str(tmp_path), pattern="*.txt") + result = await filesystem_list_files(directory=str(tmp_path), pattern="*.txt") assert result["success"] is True assert result["count"] == 1 @pytest.mark.asyncio async def test_list_files_empty_dir(self, tmp_path): - result = await list_files(directory=str(tmp_path)) + result = await filesystem_list_files(directory=str(tmp_path)) assert result["success"] is True assert result["count"] == 0 @@ -81,13 +85,17 @@ async def test_delete_file_exists(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("content") - result = await delete_file(filename="test.txt", directory=str(tmp_path)) + result = await filesystem_delete_file( + filename="test.txt", directory=str(tmp_path) + ) assert result["success"] is True assert not test_file.exists() @pytest.mark.asyncio async def test_delete_file_not_found(self, tmp_path): - result = await delete_file(filename="nonexistent.txt", directory=str(tmp_path)) + result = await filesystem_delete_file( + filename="nonexistent.txt", directory=str(tmp_path) + ) assert result["success"] is False @@ -95,7 +103,7 @@ class TestCreateDirectory: @pytest.mark.asyncio async def test_create_directory_basic(self, tmp_path): new_dir = tmp_path / "new_folder" - result = await create_directory(directory=str(new_dir)) + result = await filesystem_create_directory(directory=str(new_dir)) assert result["success"] is True assert new_dir.exists() @@ -104,7 +112,7 @@ async def test_create_directory_already_exists(self, tmp_path): existing_dir = tmp_path / "existing" existing_dir.mkdir() - result = await create_directory(directory=str(existing_dir)) + result = await filesystem_create_directory(directory=str(existing_dir)) assert result["success"] is False assert "already exists" in result["error"].lower() @@ -115,13 +123,17 @@ async def test_file_exists_true(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("content") - result = await file_exists(filename="test.txt", directory=str(tmp_path)) + result = await filesystem_file_exists( + filename="test.txt", directory=str(tmp_path) + ) assert result["success"] is True assert result["data"]["exists"] is True @pytest.mark.asyncio async def test_file_exists_false(self, tmp_path): - result = await file_exists(filename="nonexistent.txt", directory=str(tmp_path)) + result = await filesystem_file_exists( + filename="nonexistent.txt", directory=str(tmp_path) + ) assert result["success"] is True assert result["data"]["exists"] is False @@ -132,14 +144,16 @@ async def test_get_file_info_exists(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("Hello!") - result = await get_file_info(filename="test.txt", directory=str(tmp_path)) + result = await filesystem_get_file_info( + filename="test.txt", directory=str(tmp_path) + ) assert result["success"] is True assert result["data"]["size_bytes"] == 6 assert result["data"]["extension"] == "txt" @pytest.mark.asyncio async def test_get_file_info_not_found(self, tmp_path): - result = await get_file_info( + result = await filesystem_get_file_info( filename="nonexistent.txt", directory=str(tmp_path) ) assert result["success"] is False @@ -151,7 +165,7 @@ async def test_append_to_file(self, tmp_path): test_file = tmp_path / "test.txt" test_file.write_text("Hello") - result = await append_to_file( + result = await filesystem_append_to_file( content=", World!", filename="test.txt", directory=str(tmp_path) ) assert result["success"] is True @@ -159,7 +173,7 @@ async def test_append_to_file(self, tmp_path): @pytest.mark.asyncio async def test_append_to_nonexistent_file(self, tmp_path): - result = await append_to_file( + result = await filesystem_append_to_file( content="data", filename="nonexistent.txt", directory=str(tmp_path) ) assert result["success"] is False @@ -171,7 +185,7 @@ async def test_copy_file_basic(self, tmp_path): source = tmp_path / "source.txt" source.write_text("content") - result = await copy_file( + result = await filesystem_copy_file( source_filename="source.txt", destination_filename="dest.txt", source_directory=str(tmp_path), @@ -183,7 +197,7 @@ async def test_copy_file_basic(self, tmp_path): @pytest.mark.asyncio async def test_copy_file_source_not_found(self, tmp_path): - result = await copy_file( + result = await filesystem_copy_file( source_filename="nonexistent.txt", destination_filename="dest.txt", source_directory=str(tmp_path), diff --git a/tests/tools/local/test_shell.py b/tests/tools/local/test_shell.py index bd1e5d9..07f1499 100644 --- a/tests/tools/local/test_shell.py +++ b/tests/tools/local/test_shell.py @@ -1,12 +1,12 @@ import pytest from src.tools.local.shell import ( - check_command_exists, - get_current_directory, - get_environment_variable, - get_system_info, run_shell_command, - run_shell_script, + shell_check_command_exists, + shell_get_current_directory, + shell_get_environment_variable, + shell_get_system_info, + shell_run_shell_script, ) @@ -52,7 +52,7 @@ async def test_run_with_tail(self): class TestRunShellScript: @pytest.mark.asyncio async def test_run_simple_script(self): - result = await run_shell_script(script="echo 'hello world'") + result = await shell_run_shell_script(script="echo 'hello world'") assert result["success"] is True assert "hello world" in result["data"]["stdout"] @@ -62,19 +62,19 @@ async def test_run_multiline_script(self): VAR="test" echo $VAR """ - result = await run_shell_script(script=script) + result = await shell_run_shell_script(script=script) assert result["success"] is True assert "test" in result["data"]["stdout"] @pytest.mark.asyncio async def test_run_empty_script(self): - result = await run_shell_script(script="") + result = await shell_run_shell_script(script="") assert result["success"] is False assert "empty" in result["error"].lower() @pytest.mark.asyncio async def test_run_script_with_base_dir(self, tmp_path): - result = await run_shell_script(script="pwd", base_dir=str(tmp_path)) + result = await shell_run_shell_script(script="pwd", base_dir=str(tmp_path)) assert result["success"] is True assert str(tmp_path) in result["data"]["stdout"] @@ -82,14 +82,14 @@ async def test_run_script_with_base_dir(self, tmp_path): class TestCheckCommandExists: @pytest.mark.asyncio async def test_command_exists(self): - result = await check_command_exists("echo") + result = await shell_check_command_exists("echo") assert result["success"] is True assert result["data"]["exists"] is True assert result["data"]["path"] is not None @pytest.mark.asyncio async def test_command_not_exists(self): - result = await check_command_exists("nonexistent_command_xyz") + result = await shell_check_command_exists("nonexistent_command_xyz") assert result["success"] is True assert result["data"]["exists"] is False assert result["data"]["path"] is None @@ -98,14 +98,14 @@ async def test_command_not_exists(self): class TestGetEnvironmentVariable: @pytest.mark.asyncio async def test_get_existing_var(self): - result = await get_environment_variable("PATH") + result = await shell_get_environment_variable("PATH") assert result["success"] is True assert result["data"]["is_set"] is True assert result["data"]["value"] is not None @pytest.mark.asyncio async def test_get_nonexistent_var(self): - result = await get_environment_variable("NONEXISTENT_VAR_XYZ_123") + result = await shell_get_environment_variable("NONEXISTENT_VAR_XYZ_123") assert result["success"] is True assert result["data"]["is_set"] is False assert result["data"]["value"] is None @@ -114,7 +114,7 @@ async def test_get_nonexistent_var(self): class TestGetCurrentDirectory: @pytest.mark.asyncio async def test_get_current_directory(self): - result = await get_current_directory() + result = await shell_get_current_directory() assert result["success"] is True assert "current_directory" in result["data"] assert len(result["data"]["current_directory"]) > 0 @@ -123,7 +123,7 @@ async def test_get_current_directory(self): class TestGetSystemInfo: @pytest.mark.asyncio async def test_get_system_info(self): - result = await get_system_info() + result = await shell_get_system_info() assert result["success"] is True assert "os" in result["data"] assert "platform" in result["data"] diff --git a/uv.lock b/uv.lock index 7692d64..d5ef27a 100644 --- a/uv.lock +++ b/uv.lock @@ -962,6 +962,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -989,6 +990,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.8.0" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] [[package]] @@ -2962,6 +2964,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/52/1f2df7e7d1be3d65ddc2936d820d4a3d9777a54f4204f5ca46b8513eff77/typer-0.20.1-py3-none-any.whl", hash = "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8", size = 47381, upload-time = "2025-12-19T16:48:53.679Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"