From eebe042ba75ade044a99e3b832516ba49b0c548b Mon Sep 17 00:00:00 2001 From: Zhang <62634883+Ttian18@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:41:08 -0800 Subject: [PATCH] feat(tools): add Google Analytics 4 integration (#3727) Add read-only GA4 Data API v1 tools: ga_run_report, ga_get_realtime, ga_get_top_pages, and ga_get_traffic_sources. Includes credential spec, unit tests, and README. --- tools/pyproject.toml | 1 + tools/src/aden_tools/credentials/__init__.py | 3 + .../credentials/google_analytics.py | 40 + tools/src/aden_tools/tools/__init__.py | 6 + .../tools/google_analytics_tool/README.md | 124 +++ .../tools/google_analytics_tool/__init__.py | 5 + .../google_analytics_tool.py | 344 ++++++++ tools/tests/credentials/__init__.py | 1 + .../test_google_analytics_credentials.py | 53 ++ .../tests/tools/test_google_analytics_tool.py | 744 ++++++++++++++++++ uv.lock | 202 ++++- 11 files changed, 1519 insertions(+), 4 deletions(-) create mode 100644 tools/src/aden_tools/credentials/google_analytics.py create mode 100644 tools/src/aden_tools/tools/google_analytics_tool/README.md create mode 100644 tools/src/aden_tools/tools/google_analytics_tool/__init__.py create mode 100644 tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py create mode 100644 tools/tests/credentials/__init__.py create mode 100644 tools/tests/credentials/test_google_analytics_credentials.py create mode 100644 tools/tests/tools/test_google_analytics_tool.py diff --git a/tools/pyproject.toml b/tools/pyproject.toml index 8c12504aa5..2fab0f9135 100644 --- a/tools/pyproject.toml +++ b/tools/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "playwright-stealth>=1.0.5", "litellm>=1.81.0", "resend>=2.0.0", + "google-analytics-data>=0.18.0", "framework", ] diff --git a/tools/src/aden_tools/credentials/__init__.py b/tools/src/aden_tools/credentials/__init__.py index e10cbee008..25ba137506 100644 --- a/tools/src/aden_tools/credentials/__init__.py +++ b/tools/src/aden_tools/credentials/__init__.py @@ -55,6 +55,7 @@ from .browser import get_aden_auth_url, get_aden_setup_url, open_browser from .email import EMAIL_CREDENTIALS from .github import GITHUB_CREDENTIALS +from .google_analytics import GOOGLE_ANALYTICS_CREDENTIALS from .health_check import HealthCheckResult, check_credential_health from .hubspot import HUBSPOT_CREDENTIALS from .llm import LLM_CREDENTIALS @@ -77,6 +78,7 @@ **GITHUB_CREDENTIALS, **HUBSPOT_CREDENTIALS, **SLACK_CREDENTIALS, + **GOOGLE_ANALYTICS_CREDENTIALS, } __all__ = [ @@ -108,4 +110,5 @@ "HUBSPOT_CREDENTIALS", "SLACK_CREDENTIALS", "APOLLO_CREDENTIALS", + "GOOGLE_ANALYTICS_CREDENTIALS", ] diff --git a/tools/src/aden_tools/credentials/google_analytics.py b/tools/src/aden_tools/credentials/google_analytics.py new file mode 100644 index 0000000000..109dd22a59 --- /dev/null +++ b/tools/src/aden_tools/credentials/google_analytics.py @@ -0,0 +1,40 @@ +""" +Google Analytics credentials. + +Contains credentials for Google Analytics 4 Data API integration. +""" + +from .base import CredentialSpec + +GOOGLE_ANALYTICS_CREDENTIALS = { + "google_analytics": CredentialSpec( + env_var="GOOGLE_APPLICATION_CREDENTIALS", + tools=[ + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + ], + required=True, + startup_required=False, + help_url="https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart-client-libraries", + description="Path to Google Cloud service account JSON key with Analytics read access", + # Auth method support + aden_supported=False, + direct_api_key_supported=True, + api_key_instructions="""To set up Google Analytics credentials: +1. Go to Google Cloud Console > IAM & Admin > Service Accounts +2. Create a service account (e.g., "hive-analytics-reader") +3. Download the JSON key file +4. In Google Analytics, go to Admin > Property > Property Access Management +5. Add the service account email with "Viewer" role +6. Set the env var to the path of the JSON key file: + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json""", + # Health check - GA4 Data API doesn't have a simple health endpoint + health_check_endpoint="", + health_check_method="GET", + # Credential store mapping + credential_id="google_analytics", + credential_key="service_account_key_path", + ), +} diff --git a/tools/src/aden_tools/tools/__init__.py b/tools/src/aden_tools/tools/__init__.py index 36d3495ec1..0c90580699 100644 --- a/tools/src/aden_tools/tools/__init__.py +++ b/tools/src/aden_tools/tools/__init__.py @@ -41,6 +41,7 @@ from .file_system_toolkits.view_file import register_tools as register_view_file from .file_system_toolkits.write_to_file import register_tools as register_write_to_file from .github_tool import register_tools as register_github +from .google_analytics_tool import register_tools as register_google_analytics from .hubspot_tool import register_tools as register_hubspot from .pdf_read_tool import register_tools as register_pdf_read from .runtime_logs_tool import register_tools as register_runtime_logs @@ -79,6 +80,7 @@ def register_all_tools( register_hubspot(mcp, credentials=credentials) register_apollo(mcp, credentials=credentials) register_slack(mcp, credentials=credentials) + register_google_analytics(mcp, credentials=credentials) # Register file system toolkits register_view_file(mcp) @@ -209,6 +211,10 @@ def register_all_tools( "slack_kick_user_from_channel", "slack_delete_file", "slack_get_team_stats", + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", ] diff --git a/tools/src/aden_tools/tools/google_analytics_tool/README.md b/tools/src/aden_tools/tools/google_analytics_tool/README.md new file mode 100644 index 0000000000..81b839bb82 --- /dev/null +++ b/tools/src/aden_tools/tools/google_analytics_tool/README.md @@ -0,0 +1,124 @@ +# Google Analytics Tool + +Query GA4 website traffic and marketing performance data via the Data API v1. + +## Description + +Provides read-only access to Google Analytics 4 (GA4) properties. Use these tools to pull website traffic data, monitor real-time activity, and analyze marketing performance. + +Supports: +- **Custom reports** with any combination of GA4 dimensions and metrics +- **Real-time data** for current website activity +- **Convenience wrappers** for common queries (top pages, traffic sources) + +## Tools + +### `ga_run_report` + +Run a custom GA4 report with flexible dimensions, metrics, and date ranges. + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID (e.g., `"properties/123456"`) | +| `metrics` | list[str] | Yes | - | Metrics to retrieve (e.g., `["sessions", "totalUsers"]`) | +| `dimensions` | list[str] | No | `None` | Dimensions to group by (e.g., `["pagePath", "sessionSource"]`) | +| `start_date` | str | No | `"28daysAgo"` | Start date (e.g., `"2024-01-01"` or `"7daysAgo"`) | +| `end_date` | str | No | `"today"` | End date | +| `limit` | int | No | `100` | Max rows to return (1-10000) | + +### `ga_get_realtime` + +Get real-time analytics data (active users, current pages). + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID | +| `metrics` | list[str] | No | `["activeUsers"]` | Metrics to retrieve | + +### `ga_get_top_pages` + +Get top pages by views and engagement (convenience wrapper). + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID | +| `start_date` | str | No | `"28daysAgo"` | Start date | +| `end_date` | str | No | `"today"` | End date | +| `limit` | int | No | `10` | Max pages to return (1-10000) | + +Returns: `pagePath`, `pageTitle`, `screenPageViews`, `averageSessionDuration`, `bounceRate` + +### `ga_get_traffic_sources` + +Get traffic breakdown by source/medium (convenience wrapper). + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `property_id` | str | Yes | - | GA4 property ID | +| `start_date` | str | No | `"28daysAgo"` | Start date | +| `end_date` | str | No | `"today"` | End date | +| `limit` | int | No | `10` | Max sources to return (1-10000) | + +Returns: `sessionSource`, `sessionMedium`, `sessions`, `totalUsers`, `conversions` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GOOGLE_APPLICATION_CREDENTIALS` | Yes | Path to Google Cloud service account JSON key file | + +## Setup + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) > IAM & Admin > Service Accounts +2. Create a service account (e.g., "hive-analytics-reader") +3. Download the JSON key file +4. Enable the **Google Analytics Data API** in your Google Cloud project +5. In Google Analytics, go to Admin > Property > Property Access Management +6. Add the service account email with **Viewer** role +7. Set the environment variable: + ```bash + export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json + ``` + +## Common GA4 Metrics + +`sessions`, `totalUsers`, `newUsers`, `screenPageViews`, `conversions`, `bounceRate`, `averageSessionDuration`, `engagedSessions` + +## Common GA4 Dimensions + +`pagePath`, `pageTitle`, `sessionSource`, `sessionMedium`, `country`, `deviceCategory`, `date` + +## Example Usage + +```python +# Custom report: sessions by page over the last 7 days +result = ga_run_report( + property_id="properties/123456", + metrics=["sessions", "screenPageViews"], + dimensions=["pagePath"], + start_date="7daysAgo", +) + +# Real-time active users +result = ga_get_realtime(property_id="properties/123456") + +# Top 10 pages this month +result = ga_get_top_pages( + property_id="properties/123456", + start_date="2024-01-01", + end_date="2024-01-31", +) + +# Traffic sources breakdown +result = ga_get_traffic_sources(property_id="properties/123456") +``` + +## Error Handling + +Returns error dicts for common issues: +- `Google Analytics credentials not configured` - No credentials set +- `property_id must start with 'properties/'` - Invalid property ID format +- `metrics list must not be empty` - No metrics provided +- `limit must be between 1 and 10000` - Limit out of bounds +- `Failed to initialize Google Analytics client` - Bad credentials file +- `Google Analytics API error: ...` - API-level errors (permissions, quota, etc.) diff --git a/tools/src/aden_tools/tools/google_analytics_tool/__init__.py b/tools/src/aden_tools/tools/google_analytics_tool/__init__.py new file mode 100644 index 0000000000..fbf3448072 --- /dev/null +++ b/tools/src/aden_tools/tools/google_analytics_tool/__init__.py @@ -0,0 +1,5 @@ +"""Google Analytics Tool - Query GA4 website traffic and marketing data.""" + +from .google_analytics_tool import register_tools + +__all__ = ["register_tools"] diff --git a/tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py b/tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py new file mode 100644 index 0000000000..cf353f245c --- /dev/null +++ b/tools/src/aden_tools/tools/google_analytics_tool/google_analytics_tool.py @@ -0,0 +1,344 @@ +""" +Google Analytics Tool - Query GA4 website traffic and marketing performance data. + +Provides read-only access to Google Analytics 4 via the Data API v1. + +Supports: +- Service account authentication (GOOGLE_APPLICATION_CREDENTIALS) +- Credential store via CredentialStoreAdapter + +API Reference: https://developers.google.com/analytics/devguides/reporting/data/v1 +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Any + +from fastmcp import FastMCP +from google.analytics.data_v1beta import BetaAnalyticsDataClient +from google.analytics.data_v1beta.types import ( + DateRange, + Dimension, + Metric, + MinuteRange, + RunRealtimeReportRequest, + RunReportRequest, +) + +if TYPE_CHECKING: + from aden_tools.credentials import CredentialStoreAdapter + +logger = logging.getLogger(__name__) + + +class _GAClient: + """Internal client wrapping Google Analytics 4 Data API v1beta calls.""" + + def __init__(self, credentials_path: str): + self._credentials_path = credentials_path + # The GA4 client reads GOOGLE_APPLICATION_CREDENTIALS from the environment + # We set it here so the client picks up the correct path + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path + self._client = BetaAnalyticsDataClient() + + def run_report( + self, + property_id: str, + metrics: list[str], + dimensions: list[str] | None = None, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 100, + ) -> dict[str, Any]: + """Run a GA4 report and return structured results.""" + request = RunReportRequest( + property=property_id, + metrics=[Metric(name=m) for m in metrics], + dimensions=[Dimension(name=d) for d in (dimensions or [])], + date_ranges=[DateRange(start_date=start_date, end_date=end_date)], + limit=limit, + ) + + response = self._client.run_report(request) + return self._format_report_response(response, dimensions) + + def run_realtime_report( + self, + property_id: str, + metrics: list[str], + ) -> dict[str, Any]: + """Run a GA4 realtime report.""" + request = RunRealtimeReportRequest( + property=property_id, + metrics=[Metric(name=m) for m in metrics], + minute_ranges=[MinuteRange(start_minutes_ago=29, end_minutes_ago=0)], + ) + + response = self._client.run_realtime_report(request) + return self._format_realtime_response(response, metrics) + + def _format_report_response( + self, + response: Any, + dimensions: list[str] | None, + ) -> dict[str, Any]: + """Format a RunReportResponse into a plain dict.""" + rows = [] + dim_headers = [h.name for h in response.dimension_headers] + metric_headers = [h.name for h in response.metric_headers] + + for row in response.rows: + row_data: dict[str, str] = {} + for i, dim_value in enumerate(row.dimension_values): + row_data[dim_headers[i]] = dim_value.value + for i, metric_value in enumerate(row.metric_values): + row_data[metric_headers[i]] = metric_value.value + rows.append(row_data) + + return { + "row_count": response.row_count, + "rows": rows, + "dimension_headers": dim_headers, + "metric_headers": metric_headers, + } + + def _format_realtime_response( + self, + response: Any, + metrics: list[str], + ) -> dict[str, Any]: + """Format a RunRealtimeReportResponse into a plain dict.""" + rows = [] + metric_headers = [h.name for h in response.metric_headers] + + for row in response.rows: + row_data: dict[str, str] = {} + for i, metric_value in enumerate(row.metric_values): + row_data[metric_headers[i]] = metric_value.value + rows.append(row_data) + + return { + "row_count": response.row_count, + "rows": rows, + "metric_headers": metric_headers, + } + + +def register_tools( + mcp: FastMCP, + credentials: CredentialStoreAdapter | None = None, +) -> None: + """Register Google Analytics tools with the MCP server.""" + + def _get_credentials_path() -> str | None: + """Get GA credentials path from credential store or environment.""" + if credentials is not None: + path = credentials.get("google_analytics") + if path is not None and not isinstance(path, str): + raise TypeError( + f"Expected string from credentials.get('google_analytics'), " + f"got {type(path).__name__}" + ) + return path + return os.getenv("GOOGLE_APPLICATION_CREDENTIALS") + + def _get_client() -> _GAClient | dict[str, str]: + """Get a GA client, or return an error dict if no credentials.""" + creds_path = _get_credentials_path() + if not creds_path: + return { + "error": "Google Analytics credentials not configured", + "help": ( + "Set GOOGLE_APPLICATION_CREDENTIALS environment variable " + "to the path of your service account JSON key file, " + "or configure via credential store" + ), + } + try: + return _GAClient(creds_path) + except Exception as e: + return {"error": f"Failed to initialize Google Analytics client: {e}"} + + @mcp.tool() + def ga_run_report( + property_id: str, + metrics: list[str], + dimensions: list[str] | None = None, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 100, + ) -> dict: + """ + Run a custom Google Analytics 4 report. + + Use this tool to query website traffic data with custom dimensions, + metrics, and date ranges. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + metrics: Metrics to retrieve + (e.g., ["sessions", "totalUsers", "conversions"]) + dimensions: Dimensions to group by + (e.g., ["pagePath", "sessionSource"]) + start_date: Start date (e.g., "2024-01-01" or "28daysAgo") + end_date: End date (e.g., "today") + limit: Max rows to return (1-10000) + + Returns: + Dict with report rows or error + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + if not metrics: + return {"error": "metrics list must not be empty"} + if limit < 1 or limit > 10000: + return {"error": "limit must be between 1 and 10000"} + + try: + return client.run_report( + property_id=property_id, + metrics=metrics, + dimensions=dimensions, + start_date=start_date, + end_date=end_date, + limit=limit, + ) + except Exception as e: + logger.warning("ga_run_report failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} + + @mcp.tool() + def ga_get_realtime( + property_id: str, + metrics: list[str] | None = None, + ) -> dict: + """ + Get real-time Google Analytics data (active users, current pages). + + Use this tool to check current website activity and detect traffic anomalies. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + metrics: Metrics to retrieve (default: ["activeUsers"]) + + Returns: + Dict with real-time data or error + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + + effective_metrics = metrics or ["activeUsers"] + + try: + return client.run_realtime_report( + property_id=property_id, + metrics=effective_metrics, + ) + except Exception as e: + logger.warning("ga_get_realtime failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} + + @mcp.tool() + def ga_get_top_pages( + property_id: str, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 10, + ) -> dict: + """ + Get top pages by views and engagement. + + Convenience wrapper that returns the most-visited pages with + key engagement metrics. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + start_date: Start date (e.g., "2024-01-01" or "28daysAgo") + end_date: End date (e.g., "today") + limit: Max pages to return (1-10000, default 10) + + Returns: + Dict with top pages, their views, avg engagement time, and bounce rate + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + if limit < 1 or limit > 10000: + return {"error": "limit must be between 1 and 10000"} + + try: + return client.run_report( + property_id=property_id, + metrics=["screenPageViews", "averageSessionDuration", "bounceRate"], + dimensions=["pagePath", "pageTitle"], + start_date=start_date, + end_date=end_date, + limit=limit, + ) + except Exception as e: + logger.warning("ga_get_top_pages failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} + + @mcp.tool() + def ga_get_traffic_sources( + property_id: str, + start_date: str = "28daysAgo", + end_date: str = "today", + limit: int = 10, + ) -> dict: + """ + Get traffic breakdown by source/medium. + + Convenience wrapper that shows which channels drive visitors to the site. + + Args: + property_id: GA4 property ID (e.g., "properties/123456") + start_date: Start date (e.g., "2024-01-01" or "28daysAgo") + end_date: End date (e.g., "today") + limit: Max sources to return (1-10000, default 10) + + Returns: + Dict with traffic sources, sessions, users, and conversions per source + """ + client = _get_client() + if isinstance(client, dict): + return client + + if not property_id or not property_id.startswith("properties/"): + return { + "error": "property_id must start with 'properties/' (e.g., 'properties/123456')" + } + if limit < 1 or limit > 10000: + return {"error": "limit must be between 1 and 10000"} + + try: + return client.run_report( + property_id=property_id, + metrics=["sessions", "totalUsers", "conversions"], + dimensions=["sessionSource", "sessionMedium"], + start_date=start_date, + end_date=end_date, + limit=limit, + ) + except Exception as e: + logger.warning("ga_get_traffic_sources failed: %s", e) + return {"error": f"Google Analytics API error: {e}"} diff --git a/tools/tests/credentials/__init__.py b/tools/tests/credentials/__init__.py new file mode 100644 index 0000000000..0a48b62266 --- /dev/null +++ b/tools/tests/credentials/__init__.py @@ -0,0 +1 @@ +"""Credential-specific tests.""" diff --git a/tools/tests/credentials/test_google_analytics_credentials.py b/tools/tests/credentials/test_google_analytics_credentials.py new file mode 100644 index 0000000000..919f1e0f20 --- /dev/null +++ b/tools/tests/credentials/test_google_analytics_credentials.py @@ -0,0 +1,53 @@ +"""Tests for Google Analytics credential spec.""" + +from aden_tools.credentials import CREDENTIAL_SPECS +from aden_tools.credentials.google_analytics import GOOGLE_ANALYTICS_CREDENTIALS + + +class TestGoogleAnalyticsCredentials: + """Tests for the Google Analytics credential specification.""" + + def test_credential_spec_exists(self): + """google_analytics spec exists in the module.""" + assert "google_analytics" in GOOGLE_ANALYTICS_CREDENTIALS + + def test_credential_registered_in_global_specs(self): + """google_analytics spec is merged into CREDENTIAL_SPECS.""" + assert "google_analytics" in CREDENTIAL_SPECS + + def test_env_var(self): + """Spec points to the correct environment variable.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.env_var == "GOOGLE_APPLICATION_CREDENTIALS" + + def test_tools_list(self): + """Spec lists all four GA tool names.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + expected = [ + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + ] + assert spec.tools == expected + + def test_required_flag(self): + """Credential is required.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.required is True + + def test_not_startup_required(self): + """Credential is not required at startup.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.startup_required is False + + def test_help_url_set(self): + """Help URL points to GA4 quickstart docs.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert "developers.google.com" in spec.help_url + + def test_description_set(self): + """Description is non-empty.""" + spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"] + assert spec.description + assert "service account" in spec.description.lower() diff --git a/tools/tests/tools/test_google_analytics_tool.py b/tools/tests/tools/test_google_analytics_tool.py new file mode 100644 index 0000000000..97a6212925 --- /dev/null +++ b/tools/tests/tools/test_google_analytics_tool.py @@ -0,0 +1,744 @@ +""" +Tests for Google Analytics tool. + +Covers: +- _GAClient methods (run_report, run_realtime_report, response formatting) +- Credential retrieval (CredentialStoreAdapter vs env var) +- Input validation for all tool functions +- Error handling (no credentials, API errors, timeouts) +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from aden_tools.tools.google_analytics_tool.google_analytics_tool import ( + _GAClient, + register_tools, +) + +# --------------------------------------------------------------------------- +# Helpers to build mock GA4 API responses +# --------------------------------------------------------------------------- + + +def _make_header(name: str) -> MagicMock: + header = MagicMock() + header.name = name + return header + + +def _make_value(value: str) -> MagicMock: + v = MagicMock() + v.value = value + return v + + +def _make_row(dim_values: list[str], metric_values: list[str]) -> MagicMock: + row = MagicMock() + row.dimension_values = [_make_value(v) for v in dim_values] + row.metric_values = [_make_value(v) for v in metric_values] + return row + + +def _make_report_response( + dim_headers: list[str], + metric_headers: list[str], + rows: list[tuple[list[str], list[str]]], + row_count: int | None = None, +) -> MagicMock: + resp = MagicMock() + resp.dimension_headers = [_make_header(h) for h in dim_headers] + resp.metric_headers = [_make_header(h) for h in metric_headers] + resp.rows = [_make_row(dims, metrics) for dims, metrics in rows] + resp.row_count = row_count if row_count is not None else len(rows) + return resp + + +def _make_realtime_response( + metric_headers: list[str], + rows: list[list[str]], + row_count: int | None = None, +) -> MagicMock: + resp = MagicMock() + resp.dimension_headers = [] + resp.metric_headers = [_make_header(h) for h in metric_headers] + resp.rows = [_make_row([], metrics) for metrics in rows] + resp.row_count = row_count if row_count is not None else len(rows) + return resp + + +# --------------------------------------------------------------------------- +# _GAClient tests +# --------------------------------------------------------------------------- + + +class TestGAClient: + """Tests for the internal _GAClient class.""" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_format_report_response(self, mock_client_cls): + """Report response is formatted into a plain dict.""" + client = _GAClient("/fake/path.json") + + response = _make_report_response( + dim_headers=["pagePath"], + metric_headers=["screenPageViews", "sessions"], + rows=[ + (["/home"], ["1000", "500"]), + (["/about"], ["200", "100"]), + ], + ) + + result = client._format_report_response(response, ["pagePath"]) + + assert result["row_count"] == 2 + assert len(result["rows"]) == 2 + assert result["rows"][0] == { + "pagePath": "/home", + "screenPageViews": "1000", + "sessions": "500", + } + assert result["dimension_headers"] == ["pagePath"] + assert result["metric_headers"] == ["screenPageViews", "sessions"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_format_report_response_no_dimensions(self, mock_client_cls): + """Report with no dimensions still returns valid structure.""" + client = _GAClient("/fake/path.json") + + response = _make_report_response( + dim_headers=[], + metric_headers=["totalUsers"], + rows=[([], ["5000"])], + ) + + result = client._format_report_response(response, None) + + assert result["row_count"] == 1 + assert result["rows"][0] == {"totalUsers": "5000"} + assert result["dimension_headers"] == [] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_format_realtime_response(self, mock_client_cls): + """Realtime response is formatted correctly.""" + client = _GAClient("/fake/path.json") + + response = _make_realtime_response( + metric_headers=["activeUsers"], + rows=[["42"]], + ) + + result = client._format_realtime_response(response, ["activeUsers"]) + + assert result["row_count"] == 1 + assert result["rows"][0] == {"activeUsers": "42"} + assert result["metric_headers"] == ["activeUsers"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_run_report_calls_api(self, mock_client_cls): + """run_report sends correct request to GA4 API.""" + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath"], + metric_headers=["sessions"], + rows=[(["/home"], ["100"])], + ) + + client = _GAClient("/fake/path.json") + result = client.run_report( + property_id="properties/123", + metrics=["sessions"], + dimensions=["pagePath"], + start_date="7daysAgo", + end_date="today", + limit=50, + ) + + mock_api.run_report.assert_called_once() + assert result["row_count"] == 1 + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_run_realtime_report_calls_api(self, mock_client_cls): + """run_realtime_report sends correct request to GA4 API.""" + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.return_value = _make_realtime_response( + metric_headers=["activeUsers"], + rows=[["10"]], + ) + + client = _GAClient("/fake/path.json") + result = client.run_realtime_report( + property_id="properties/123", + metrics=["activeUsers"], + ) + + mock_api.run_realtime_report.assert_called_once() + assert result["rows"][0]["activeUsers"] == "10" + + +# --------------------------------------------------------------------------- +# Credential retrieval tests +# --------------------------------------------------------------------------- + + +class TestCredentialRetrieval: + """Tests for credential resolution in register_tools.""" + + def test_no_credentials_returns_error(self, monkeypatch): + """No credentials configured returns helpful error from tool call.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + register_tools(mcp, credentials=None) + + result = registered_fns["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + ) + assert "error" in result + assert "not configured" in result["error"] + + def test_credentials_from_env(self, monkeypatch): + """Credentials resolved from environment variable.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/key.json") + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + register_tools(mcp, credentials=None) + assert "ga_run_report" in registered_fns + + def test_credentials_from_credential_store(self): + """Credentials resolved from CredentialStoreAdapter.""" + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + cred_manager = MagicMock() + cred_manager.get.return_value = "/path/to/key.json" + + register_tools(mcp, credentials=cred_manager) + assert "ga_run_report" in registered_fns + + +# --------------------------------------------------------------------------- +# ga_run_report tests +# --------------------------------------------------------------------------- + + +class TestGaRunReport: + """Tests for ga_run_report tool function.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_empty_metrics_returns_error(self, ga_tools_with_creds): + """Empty metrics list returns validation error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="properties/123", + metrics=[], + ) + assert "error" in result + assert "metrics" in result["error"].lower() + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID without 'properties/' prefix returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="123456", + metrics=["sessions"], + ) + assert "error" in result + assert "properties/" in result["error"] + + def test_empty_property_id_returns_error(self, ga_tools_with_creds): + """Empty property ID returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="", + metrics=["sessions"], + ) + assert "error" in result + + def test_limit_too_low_returns_error(self, ga_tools_with_creds): + """Limit of 0 returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + limit=0, + ) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_limit_too_high_returns_error(self, ga_tools_with_creds): + """Limit above 10000 returns error.""" + result = ga_tools_with_creds["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + limit=10001, + ) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error with help message.""" + result = ga_tools["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + ) + assert "error" in result + assert "not configured" in result["error"] + assert "help" in result + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_successful_report(self, mock_client_cls, monkeypatch): + """Successful report returns formatted data.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath"], + metric_headers=["sessions"], + rows=[(["/home"], ["500"])], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + dimensions=["pagePath"], + ) + + assert result["row_count"] == 1 + assert result["rows"][0]["pagePath"] == "/home" + assert result["rows"][0]["sessions"] == "500" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_api_error_returns_error_dict(self, mock_client_cls, monkeypatch): + """API exception is caught and returned as error dict.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.side_effect = Exception("Permission denied") + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_run_report"]( + property_id="properties/123", + metrics=["sessions"], + ) + + assert "error" in result + assert "Permission denied" in result["error"] + + +# --------------------------------------------------------------------------- +# ga_get_realtime tests +# --------------------------------------------------------------------------- + + +class TestGaGetRealtime: + """Tests for ga_get_realtime tool function.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID without 'properties/' prefix returns error.""" + result = ga_tools_with_creds["ga_get_realtime"](property_id="123456") + assert "error" in result + assert "properties/" in result["error"] + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error.""" + result = ga_tools["ga_get_realtime"](property_id="properties/123") + assert "error" in result + assert "not configured" in result["error"] + + def test_default_metrics(self, ga_tools): + """Default metrics is ['activeUsers'] when none provided.""" + # We can't easily test the default without mocking, but we can + # verify it doesn't crash with None metrics + result = ga_tools["ga_get_realtime"](property_id="properties/123", metrics=None) + assert "error" in result # No credentials, but no crash + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_successful_realtime(self, mock_client_cls, monkeypatch): + """Successful realtime report returns formatted data.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.return_value = _make_realtime_response( + metric_headers=["activeUsers"], + rows=[["42"]], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_realtime"](property_id="properties/123") + + assert result["row_count"] == 1 + assert result["rows"][0]["activeUsers"] == "42" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_custom_metrics(self, mock_client_cls, monkeypatch): + """Custom metrics are passed through to the API.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.return_value = _make_realtime_response( + metric_headers=["activeUsers", "screenPageViews"], + rows=[["10", "25"]], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_realtime"]( + property_id="properties/123", + metrics=["activeUsers", "screenPageViews"], + ) + + assert result["rows"][0]["activeUsers"] == "10" + assert result["rows"][0]["screenPageViews"] == "25" + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_api_error_returns_error_dict(self, mock_client_cls, monkeypatch): + """API exception is caught and returned as error dict.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_realtime_report.side_effect = Exception("Quota exceeded") + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_realtime"](property_id="properties/123") + + assert "error" in result + assert "Quota exceeded" in result["error"] + + +# --------------------------------------------------------------------------- +# ga_get_top_pages tests +# --------------------------------------------------------------------------- + + +class TestGaGetTopPages: + """Tests for ga_get_top_pages convenience wrapper.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID validation works.""" + result = ga_tools_with_creds["ga_get_top_pages"](property_id="bad-id") + assert "error" in result + assert "properties/" in result["error"] + + def test_limit_validation(self, ga_tools_with_creds): + """Limit bounds are checked.""" + result = ga_tools_with_creds["ga_get_top_pages"](property_id="properties/123", limit=0) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error.""" + result = ga_tools["ga_get_top_pages"](property_id="properties/123") + assert "error" in result + assert "not configured" in result["error"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_correct_dimensions_and_metrics(self, mock_client_cls, monkeypatch): + """Sends pagePath, pageTitle dimensions and page-related metrics.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath", "pageTitle"], + metric_headers=["screenPageViews", "averageSessionDuration", "bounceRate"], + rows=[(["/home", "Home Page"], ["1000", "120.5", "0.45"])], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_top_pages"](property_id="properties/123") + + assert result["row_count"] == 1 + assert result["rows"][0]["pagePath"] == "/home" + assert result["rows"][0]["pageTitle"] == "Home Page" + assert result["dimension_headers"] == ["pagePath", "pageTitle"] + assert "screenPageViews" in result["metric_headers"] + assert "averageSessionDuration" in result["metric_headers"] + assert "bounceRate" in result["metric_headers"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_date_range_and_limit_forwarded(self, mock_client_cls, monkeypatch): + """Custom date range and limit are passed to the API.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["pagePath", "pageTitle"], + metric_headers=["screenPageViews", "averageSessionDuration", "bounceRate"], + rows=[], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + fns["ga_get_top_pages"]( + property_id="properties/123", + start_date="2024-01-01", + end_date="2024-01-31", + limit=5, + ) + + # Verify the API was called (the request object is constructed internally) + mock_api.run_report.assert_called_once() + + +# --------------------------------------------------------------------------- +# ga_get_traffic_sources tests +# --------------------------------------------------------------------------- + + +class TestGaGetTrafficSources: + """Tests for ga_get_traffic_sources convenience wrapper.""" + + @pytest.fixture + def ga_tools(self, monkeypatch): + """Register GA tools without credentials.""" + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + return fns + + @pytest.fixture + def ga_tools_with_creds(self, monkeypatch): + """Register GA tools with credentials set (for input validation tests).""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + with patch( + "aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient" + ): + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + yield fns + + def test_invalid_property_id_returns_error(self, ga_tools_with_creds): + """Property ID validation works.""" + result = ga_tools_with_creds["ga_get_traffic_sources"](property_id="bad-id") + assert "error" in result + assert "properties/" in result["error"] + + def test_limit_validation(self, ga_tools_with_creds): + """Limit bounds are checked.""" + result = ga_tools_with_creds["ga_get_traffic_sources"]( + property_id="properties/123", limit=10001 + ) + assert "error" in result + assert "limit" in result["error"].lower() + + def test_no_credentials_returns_error(self, ga_tools): + """No credentials returns error.""" + result = ga_tools["ga_get_traffic_sources"](property_id="properties/123") + assert "error" in result + assert "not configured" in result["error"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_correct_dimensions_and_metrics(self, mock_client_cls, monkeypatch): + """Sends sessionSource, sessionMedium dimensions and traffic metrics.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.return_value = _make_report_response( + dim_headers=["sessionSource", "sessionMedium"], + metric_headers=["sessions", "totalUsers", "conversions"], + rows=[ + (["google", "organic"], ["500", "400", "10"]), + (["direct", "(none)"], ["200", "180", "5"]), + ], + ) + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_traffic_sources"](property_id="properties/123") + + assert result["row_count"] == 2 + assert result["rows"][0]["sessionSource"] == "google" + assert result["rows"][0]["sessionMedium"] == "organic" + assert result["dimension_headers"] == ["sessionSource", "sessionMedium"] + assert "sessions" in result["metric_headers"] + assert "totalUsers" in result["metric_headers"] + assert "conversions" in result["metric_headers"] + + @patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient") + def test_api_error_returns_error_dict(self, mock_client_cls, monkeypatch): + """API exception is caught and returned as error dict.""" + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json") + + mock_api = MagicMock() + mock_client_cls.return_value = mock_api + mock_api.run_report.side_effect = Exception("Service unavailable") + + mcp = MagicMock() + fns = {} + mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn + register_tools(mcp, credentials=None) + + result = fns["ga_get_traffic_sources"](property_id="properties/123") + + assert "error" in result + assert "Service unavailable" in result["error"] + + +# --------------------------------------------------------------------------- +# Tool registration tests +# --------------------------------------------------------------------------- + + +class TestToolRegistration: + """Tests for tool registration in register_all_tools.""" + + def test_register_tools_registers_all_four_tools(self): + """register_tools registers exactly 4 GA tool functions.""" + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + register_tools(mcp, credentials=None) + + expected_tools = { + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + } + assert set(registered_fns.keys()) == expected_tools + + def test_register_all_tools_includes_ga_tools(self): + """register_all_tools return list includes all GA tool names.""" + from aden_tools.tools import register_all_tools + + mcp = MagicMock() + mcp.tool.return_value = lambda fn: fn + + result = register_all_tools(mcp, credentials=None) + + for tool_name in [ + "ga_run_report", + "ga_get_realtime", + "ga_get_top_pages", + "ga_get_traffic_sources", + ]: + assert tool_name in result, f"{tool_name} missing from register_all_tools" + + def test_credentials_passed_through(self): + """Credential store adapter is passed to register_tools.""" + mcp = MagicMock() + registered_fns = {} + mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn + + cred_manager = MagicMock() + cred_manager.get.return_value = "/fake/path.json" + + register_tools(mcp, credentials=cred_manager) + + assert len(registered_fns) == 4 diff --git a/uv.lock b/uv.lock index 948a51af08..801def9bbd 100644 --- a/uv.lock +++ b/uv.lock @@ -5,9 +5,12 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version < '3.13' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version < '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [manifest] @@ -754,7 +757,7 @@ wheels = [ [[package]] name = "framework" -version = "0.1.0" +version = "0.4.2" source = { editable = "core" } dependencies = [ { name = "anthropic" }, @@ -918,6 +921,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, ] +[[package]] +name = "google-analytics-data" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/ec/a3ace1e5c6308f79810d24bfa29971bc1d57e3dd114409a5b58619dcaebb/google_analytics_data-0.20.0.tar.gz", hash = "sha256:00b26c813d3153b2f0e0229f3080f10079313cf922dc286c3114a3b33ad51190", size = 225381, upload-time = "2026-01-15T13:14:56.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/9d/84f043f125dc3b8285ed5e68b983bc6fa90abb22129847f98e51fff720a4/google_analytics_data-0.20.0-py3-none-any.whl", hash = "sha256:2b2e7fd1e801897073b8501127c7fb5f12df69fc2c0fb46c264813a51292ea78", size = 192755, upload-time = "2026-01-09T14:52:34.837Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + [[package]] name = "greenlet" version = "3.3.1" @@ -970,6 +1037,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, ] +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -2143,6 +2275,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.3.0" @@ -2184,6 +2343,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2886,6 +3066,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.15.0" @@ -3101,6 +3293,7 @@ dependencies = [ { name = "diff-match-patch" }, { name = "fastmcp" }, { name = "framework" }, + { name = "google-analytics-data" }, { name = "httpx" }, { name = "jsonpath-ng" }, { name = "litellm" }, @@ -3149,6 +3342,7 @@ requires-dist = [ { name = "duckdb", marker = "extra == 'sql'", specifier = ">=1.0.0" }, { name = "fastmcp", specifier = ">=2.0.0" }, { name = "framework", editable = "core" }, + { name = "google-analytics-data", specifier = ">=0.18.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "jsonpath-ng", specifier = ">=1.6.0" }, { name = "litellm", specifier = ">=1.81.0" },