Merge pull request #4239 from Ttian18/feat/tina-google-analytics-tool

[Integration]: Google Analytics - Website Traffic & Marketing Performance #3727
This commit is contained in:
Bryan @ Aden
2026-02-28 01:50:07 +00:00
committed by GitHub
13 changed files with 1366 additions and 1 deletions
+1
View File
@@ -31,6 +31,7 @@ dependencies = [
"litellm>=1.81.0",
"dnspython>=2.4.0",
"resend>=2.0.0",
"google-analytics-data>=0.18.0",
"framework",
"stripe>=14.3.0",
"arxiv>=2.1.0",
@@ -41,6 +41,7 @@ Credential categories:
- github.py: GitHub API credentials
- hubspot.py: HubSpot CRM credentials
- slack.py: Slack workspace credentials
- google_analytics.py: Google Analytics credentials
- google_maps.py: Google Maps Platform credentials
- calcom.py: Cal.com scheduling API credentials
@@ -63,6 +64,7 @@ from .discord import DISCORD_CREDENTIALS
from .email import EMAIL_CREDENTIALS
from .gcp_vision import GCP_VISION_CREDENTIALS
from .github import GITHUB_CREDENTIALS
from .google_analytics import GOOGLE_ANALYTICS_CREDENTIALS
from .google_calendar import GOOGLE_CALENDAR_CREDENTIALS
from .google_docs import GOOGLE_DOCS_CREDENTIALS
from .google_maps import GOOGLE_MAPS_CREDENTIALS
@@ -100,6 +102,7 @@ CREDENTIAL_SPECS = {
**APOLLO_CREDENTIALS,
**DISCORD_CREDENTIALS,
**GITHUB_CREDENTIALS,
**GOOGLE_ANALYTICS_CREDENTIALS,
**GOOGLE_DOCS_CREDENTIALS,
**GOOGLE_MAPS_CREDENTIALS,
**HUBSPOT_CREDENTIALS,
@@ -145,6 +148,7 @@ __all__ = [
"EMAIL_CREDENTIALS",
"GCP_VISION_CREDENTIALS",
"GITHUB_CREDENTIALS",
"GOOGLE_ANALYTICS_CREDENTIALS",
"GOOGLE_DOCS_CREDENTIALS",
"GOOGLE_MAPS_CREDENTIALS",
"HUBSPOT_CREDENTIALS",
@@ -9,6 +9,7 @@ from .base import CredentialSpec
BIGQUERY_CREDENTIALS = {
"bigquery": CredentialSpec(
env_var="GOOGLE_APPLICATION_CREDENTIALS",
credential_group="google_cloud",
tools=["run_bigquery_query", "describe_dataset"],
required=False, # Falls back to ADC if not set
startup_required=False,
@@ -0,0 +1,41 @@
"""
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",
credential_group="google_cloud",
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",
),
}
+2
View File
@@ -54,6 +54,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 .gmail_tool import register_tools as register_gmail
from .google_analytics_tool import register_tools as register_google_analytics
from .google_docs_tool import register_tools as register_google_docs
from .google_maps_tool import register_tools as register_google_maps
from .http_headers_scanner import register_tools as register_http_headers_scanner
@@ -126,6 +127,7 @@ def register_all_tools(
register_slack(mcp, credentials=credentials)
register_telegram(mcp, credentials=credentials)
register_vision(mcp, credentials=credentials)
register_google_analytics(mcp, credentials=credentials)
register_google_docs(mcp, credentials=credentials)
register_google_maps(mcp, credentials=credentials)
register_account_info(mcp, credentials=credentials)
@@ -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.)
@@ -0,0 +1,5 @@
"""Google Analytics Tool - Query GA4 website traffic and marketing data."""
from .google_analytics_tool import register_tools
__all__ = ["register_tools"]
@@ -0,0 +1,337 @@
"""
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,
)
from google.oauth2.service_account import Credentials
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
creds = Credentials.from_service_account_file(credentials_path)
self._client = BetaAnalyticsDataClient(credentials=creds)
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)
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)
def _format_report_response(
self,
response: Any,
) -> 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,
) -> 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}"}
def _validate_inputs(property_id: str, *, limit: int | None = None) -> dict[str, str] | None:
"""Validate common inputs. Returns an error dict or None."""
if not property_id or not property_id.startswith("properties/"):
return {
"error": "property_id must start with 'properties/' (e.g., 'properties/123456')"
}
if limit is not None and (limit < 1 or limit > 10000):
return {"error": "limit must be between 1 and 10000"}
return None
@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 err := _validate_inputs(property_id, limit=limit):
return err
if not metrics:
return {"error": "metrics list must not be empty"}
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 err := _validate_inputs(property_id):
return err
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 err := _validate_inputs(property_id, limit=limit):
return err
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 err := _validate_inputs(property_id, limit=limit):
return err
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}"}
+1
View File
@@ -0,0 +1 @@
"""Credential-specific tests."""
@@ -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()
+8 -1
View File
@@ -481,7 +481,14 @@ class TestSpecCompleteness:
def test_credential_group_default_empty(self):
"""Specs without a group have empty credential_group."""
for name, spec in CREDENTIAL_SPECS.items():
if name not in ("google_search", "google_cse", "razorpay", "razorpay_secret"):
if name not in (
"google_search",
"google_cse",
"razorpay",
"razorpay_secret",
"google_analytics",
"bigquery",
):
assert spec.credential_group == "", (
f"Credential '{name}' has unexpected credential_group='{spec.credential_group}'"
)
@@ -0,0 +1,771 @@
"""
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_format_report_response(self, mock_client_cls, mock_creds):
"""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)
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_format_report_response_no_dimensions(self, mock_client_cls, mock_creds):
"""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)
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_format_realtime_response(self, mock_client_cls, mock_creds):
"""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)
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_run_report_calls_api(self, mock_client_cls, mock_creds):
"""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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_run_realtime_report_calls_api(self, mock_client_cls, mock_creds):
"""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"
),
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
):
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_successful_report(self, mock_client_cls, mock_creds, 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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_api_error_returns_error_dict(self, mock_client_cls, mock_creds, 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"
),
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
):
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_successful_realtime(self, mock_client_cls, mock_creds, 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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_custom_metrics(self, mock_client_cls, mock_creds, 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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_api_error_returns_error_dict(self, mock_client_cls, mock_creds, 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"
),
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
):
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_correct_dimensions_and_metrics(self, mock_client_cls, mock_creds, 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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_date_range_and_limit_forwarded(self, mock_client_cls, mock_creds, 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"
),
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
):
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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_correct_dimensions_and_metrics(self, mock_client_cls, mock_creds, 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.Credentials")
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
def test_api_error_returns_error_dict(self, mock_client_cls, mock_creds, 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 fastmcp import FastMCP
from aden_tools.tools import register_all_tools
mcp = FastMCP("test-ga-registration")
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
Generated
+18
View File
@@ -963,6 +963,22 @@ 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"
@@ -3477,6 +3493,7 @@ dependencies = [
{ name = "dnspython" },
{ name = "fastmcp" },
{ name = "framework" },
{ name = "google-analytics-data" },
{ name = "httpx" },
{ name = "jsonpath-ng" },
{ name = "litellm" },
@@ -3539,6 +3556,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 = "google-cloud-bigquery", marker = "extra == 'all'", specifier = ">=3.0.0" },
{ name = "google-cloud-bigquery", marker = "extra == 'bigquery'", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.27.0" },