diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..fdc7de5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,37 @@ +name: "CodeQL" + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 6 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + language: [actions] + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore index 693b25a..03f0f9b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,5 @@ system_tests/local_test_setup pylintrc pylintrc.test -.agents/skills \ No newline at end of file +.agents/skills +.stargazer-* \ No newline at end of file diff --git a/analytics_mcp/coordinator.py b/analytics_mcp/coordinator.py index b53516d..85779bf 100644 --- a/analytics_mcp/coordinator.py +++ b/analytics_mcp/coordinator.py @@ -21,7 +21,6 @@ # MCP Server Imports import json import sys -from json import tool from mcp import types as mcp_types # Use alias to avoid conflict from mcp.server.lowlevel import Server @@ -54,6 +53,27 @@ run_conversions_report, _run_conversions_report_description, ) +from analytics_mcp.tools.reporting.audience_exports import ( + create_audience_export, + get_audience_export, + list_audience_exports, + query_audience_export, +) +from analytics_mcp.tools.measurement import ( + validate_event, + send_event, +) +from analytics_mcp.tools.reporting.batch import ( + batch_run_reports, + _batch_run_reports_description, +) +from analytics_mcp.tools.reporting.compatibility import check_compatibility +from analytics_mcp.tools.reporting.pivot import ( + run_pivot_report, + _run_pivot_report_description, + batch_run_pivot_reports, + _batch_run_pivot_reports_description, +) run_report_with_description = FunctionTool(run_report) run_report_with_description.description = _run_report_description() @@ -71,7 +91,23 @@ ) # Instantiate the ADK tools +run_pivot_report_with_description = FunctionTool(run_pivot_report) +run_pivot_report_with_description.description = _run_pivot_report_description() +batch_run_pivot_reports_with_description = FunctionTool(batch_run_pivot_reports) +batch_run_pivot_reports_with_description.description = ( + _batch_run_pivot_reports_description() +) + +batch_run_reports_with_description = FunctionTool(batch_run_reports) +batch_run_reports_with_description.description = ( + _batch_run_reports_description() +) + tools = [ + batch_run_reports_with_description, + FunctionTool(check_compatibility), + run_pivot_report_with_description, + batch_run_pivot_reports_with_description, FunctionTool(get_account_summaries), FunctionTool(list_google_ads_links), FunctionTool(get_property_details), @@ -81,6 +117,12 @@ run_realtime_report_with_description, run_funnel_report_with_description, run_conversions_report_with_description, + FunctionTool(create_audience_export), + FunctionTool(get_audience_export), + FunctionTool(list_audience_exports), + FunctionTool(query_audience_export), + FunctionTool(validate_event), + FunctionTool(send_event), ] tool_map = {t.name: t for t in tools} @@ -140,6 +182,26 @@ def sanitize_mcp_schema_properties(node: dict) -> None: "dimensions", "metrics", ] + elif tool.name == "batch_run_reports": + tool.inputSchema["required"] = [ + "property_id", + "requests", + ] + elif tool.name == "check_compatibility": + tool.inputSchema["required"] = ["property_id"] + elif tool.name == "run_pivot_report": + tool.inputSchema["required"] = [ + "property_id", + "date_ranges", + "dimensions", + "metrics", + "pivots", + ] + elif tool.name == "batch_run_pivot_reports": + tool.inputSchema["required"] = [ + "property_id", + "requests", + ] elif tool.name == "run_realtime_report": tool.inputSchema["required"] = ["property_id", "dimensions", "metrics"] elif tool.name == "run_conversions_report": @@ -150,6 +212,18 @@ def sanitize_mcp_schema_properties(node: dict) -> None: "metrics", "conversion_spec", ] + elif tool.name == "create_audience_export": + tool.inputSchema["required"] = [ + "property_id", + "audience_id", + "dimensions", + ] + elif tool.name in ("validate_event", "send_event"): + tool.inputSchema["required"] = [ + "measurement_id", + "client_id", + "events", + ] @app.list_tools() @@ -158,7 +232,9 @@ async def list_tools() -> list[mcp_types.Tool]: @app.call_tool() -async def call_mcp_tool(name: str, arguments: dict) -> list[mcp_types.Content]: +async def call_mcp_tool( + name: str, arguments: dict +) -> list[mcp_types.Content] | mcp_types.CallToolResult: if name in tool_map: tool = tool_map[name] try: @@ -176,13 +252,21 @@ async def call_mcp_tool(name: str, arguments: dict) -> list[mcp_types.Content]: f"MCP Server: Error executing ADK tool '{name}': {e}", file=sys.stderr, ) - # Return an error message in MCP format + # Return an error message in MCP format. isError=True so MCP + # clients can detect the failure programmatically instead of + # treating the error text as a successful response. error_text = json.dumps( {"error": f"Failed to execute tool '{name}': {str(e)}"} ) - return [mcp_types.TextContent(type="text", text=error_text)] + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text=error_text)], + isError=True, + ) error_text = json.dumps( {"error": f"Tool '{name}' not implemented by this server."} ) - return [mcp_types.TextContent(type="text", text=error_text)] + return mcp_types.CallToolResult( + content=[mcp_types.TextContent(type="text", text=error_text)], + isError=True, + ) diff --git a/analytics_mcp/tools/client.py b/analytics_mcp/tools/client.py index 93d2325..44a156a 100644 --- a/analytics_mcp/tools/client.py +++ b/analytics_mcp/tools/client.py @@ -37,7 +37,7 @@ def _get_package_version_with_fallback(): """ try: return metadata.version("analytics-mcp") - except: + except Exception: return "unknown" diff --git a/analytics_mcp/tools/measurement.py b/analytics_mcp/tools/measurement.py new file mode 100644 index 0000000..5f8d7f7 --- /dev/null +++ b/analytics_mcp/tools/measurement.py @@ -0,0 +1,290 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tools for the Google Analytics Measurement Protocol. + +Unlike the other tools in this server, the Measurement Protocol is a +plain HTTP API (not a gRPC client library) and it WRITES data: events +sent to the collect endpoint are recorded in the property. To keep the +server safe by default: + +- The API secret is only ever read from the + ``ANALYTICS_MCP_MP_API_SECRET`` environment variable, never from tool + arguments, so a model cannot supply or exfiltrate secrets. +- ``send_event`` defaults to a dry run that only validates the payload. + Recording an event requires the explicit ``confirm=True`` argument, + and the payload must pass validation first. +""" + +import asyncio +import os +from typing import Any, Dict, List + +import httpx + +_MP_API_SECRET_ENV_VAR = "ANALYTICS_MCP_MP_API_SECRET" + +_COLLECT_URL = "https://www.google-analytics.com/mp/collect" +_DEBUG_COLLECT_URL = "https://www.google-analytics.com/debug/mp/collect" + +_REQUEST_TIMEOUT_SECONDS = 10.0 + +_MAX_EVENTS_PER_REQUEST = 25 + + +def _get_api_secret() -> str: + """Returns the Measurement Protocol API secret from the environment.""" + api_secret = os.environ.get(_MP_API_SECRET_ENV_VAR, "").strip() + if not api_secret: + raise ValueError( + "No Measurement Protocol API secret is configured. Set the " + f"{_MP_API_SECRET_ENV_VAR} environment variable to an API " + "secret created under the data stream's 'Measurement " + "Protocol API secrets' settings in Google Analytics." + ) + return api_secret + + +def _build_payload( + client_id: str, + events: List[Dict[str, Any]], + user_id: str = None, + timestamp_micros: int = None, + user_properties: Dict[str, Any] = None, + non_personalized_ads: bool = False, +) -> Dict[str, Any]: + """Builds and validates a Measurement Protocol request payload.""" + if not client_id or not str(client_id).strip(): + raise ValueError("client_id must be a non-empty string.") + if not isinstance(events, list) or not events: + raise ValueError("events must contain at least one event.") + if len(events) > _MAX_EVENTS_PER_REQUEST: + raise ValueError( + "events must contain at most " + f"{_MAX_EVENTS_PER_REQUEST} events. Got {len(events)}." + ) + for i, event in enumerate(events): + if not isinstance(event, dict): + raise ValueError(f"Event {i + 1} must be a dictionary.") + if not event.get("name"): + raise ValueError(f"Event {i + 1} is missing required key 'name'.") + + payload = { + "client_id": str(client_id), + "events": events, + } + + if user_id: + payload["user_id"] = user_id + + if timestamp_micros: + payload["timestamp_micros"] = timestamp_micros + + if user_properties: + payload["user_properties"] = user_properties + + if non_personalized_ads: + payload["non_personalized_ads"] = True + + return payload + + +def _post_payload( + url: str, measurement_id: str, payload: Dict[str, Any] +) -> httpx.Response: + """Posts a payload to a Measurement Protocol endpoint.""" + if not measurement_id or not str(measurement_id).strip(): + raise ValueError( + "measurement_id must be a non-empty string, e.g. 'G-XXXXXXX'. " + "Use the list_data_streams tool to find a web stream's " + "measurement ID." + ) + + response = httpx.post( + url, + params={ + "measurement_id": measurement_id, + "api_secret": _get_api_secret(), + }, + json=payload, + timeout=_REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + return response + + +def _validation_messages(response: httpx.Response) -> List[Dict[str, Any]]: + """Extracts validation messages from a debug endpoint response.""" + try: + body = response.json() + except ValueError: + return [] + return body.get("validationMessages", []) + + +async def validate_event( + measurement_id: str, + client_id: str, + events: List[Dict[str, Any]], + user_id: str = None, + timestamp_micros: int = None, + user_properties: Dict[str, Any] = None, + non_personalized_ads: bool = False, +) -> Dict[str, Any]: + """Validates Measurement Protocol events without recording them. + + Sends the events to the Measurement Protocol debug endpoint, which + checks the payload and returns validation messages. Nothing is + recorded in the property, so this is always safe to call. + + Requires the ANALYTICS_MCP_MP_API_SECRET environment variable to be + set to a Measurement Protocol API secret for the data stream. + + Args: + measurement_id: The web data stream's measurement ID, e.g. + 'G-XXXXXXX'. Use the `list_data_streams` tool to find it. + client_id: A unique identifier for the client/user instance, + e.g. the GA client ID from the _ga cookie, or any stable + UUID-like string for server-generated events. + events: A list of 1 to 25 event objects. Each object must + contain a `name` key (e.g. 'tutorial_complete') and may + contain a `params` dict, per + https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events. + user_id: An optional persistent user identifier. + timestamp_micros: Optional Unix epoch microseconds for the + events. Must be within the last 72 hours. + user_properties: Optional user properties dict, e.g. + `{"plan": {"value": "premium"}}`. + non_personalized_ads: Whether the events should be excluded + from ads personalization. + """ + payload = _build_payload( + client_id, + events, + user_id=user_id, + timestamp_micros=timestamp_micros, + user_properties=user_properties, + non_personalized_ads=non_personalized_ads, + ) + + def _sync_call(): + return _post_payload(_DEBUG_COLLECT_URL, measurement_id, payload) + + response = await asyncio.to_thread(_sync_call) + messages = _validation_messages(response) + + return { + "valid": not messages, + "validation_messages": messages, + } + + +async def send_event( + measurement_id: str, + client_id: str, + events: List[Dict[str, Any]], + user_id: str = None, + timestamp_micros: int = None, + user_properties: Dict[str, Any] = None, + non_personalized_ads: bool = False, + confirm: bool = False, +) -> Dict[str, Any]: + """Sends Measurement Protocol events to a Google Analytics property. + + WARNING: with `confirm=True` this WRITES events into the property's + data, which cannot be undone. By default (`confirm=False`) this + tool performs a dry run: the payload is validated against the + debug endpoint and nothing is recorded. Only pass `confirm=True` + after the user has explicitly approved sending the events. + + Even with `confirm=True`, the payload is validated first and the + send is aborted if validation fails. + + Requires the ANALYTICS_MCP_MP_API_SECRET environment variable to be + set to a Measurement Protocol API secret for the data stream. + + Args: + measurement_id: The web data stream's measurement ID, e.g. + 'G-XXXXXXX'. Use the `list_data_streams` tool to find it. + client_id: A unique identifier for the client/user instance, + e.g. the GA client ID from the _ga cookie, or any stable + UUID-like string for server-generated events. + events: A list of 1 to 25 event objects. Each object must + contain a `name` key (e.g. 'tutorial_complete') and may + contain a `params` dict, per + https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events. + user_id: An optional persistent user identifier. + timestamp_micros: Optional Unix epoch microseconds for the + events. Must be within the last 72 hours. + user_properties: Optional user properties dict, e.g. + `{"plan": {"value": "premium"}}`. + non_personalized_ads: Whether the events should be excluded + from ads personalization. + confirm: Must be True to actually record the events. When + False (the default), only validation is performed and a + dry-run result is returned. + """ + validation = await validate_event( + measurement_id, + client_id, + events, + user_id=user_id, + timestamp_micros=timestamp_micros, + user_properties=user_properties, + non_personalized_ads=non_personalized_ads, + ) + + if not confirm: + return { + "sent": False, + "dry_run": True, + "validation": validation, + "message": ( + "Dry run only — no events were recorded. To send these " + "events for real, call send_event again with " + "confirm=True after the user has approved it." + ), + } + + if not validation["valid"]: + return { + "sent": False, + "dry_run": False, + "validation": validation, + "message": ( + "Events were NOT sent because validation failed. Fix " + "the issues in validation_messages and try again." + ), + } + + payload = _build_payload( + client_id, + events, + user_id=user_id, + timestamp_micros=timestamp_micros, + user_properties=user_properties, + non_personalized_ads=non_personalized_ads, + ) + + def _sync_call(): + return _post_payload(_COLLECT_URL, measurement_id, payload) + + await asyncio.to_thread(_sync_call) + + return { + "sent": True, + "dry_run": False, + "events_sent": len(events), + "validation": validation, + } diff --git a/analytics_mcp/tools/reporting/audience_exports.py b/analytics_mcp/tools/reporting/audience_exports.py new file mode 100644 index 0000000..1a56896 --- /dev/null +++ b/analytics_mcp/tools/reporting/audience_exports.py @@ -0,0 +1,212 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tools for creating and querying audience exports using the Data API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +def _construct_audience_export_rn( + property_id: int | str, audience_export: int | str +) -> str: + """Returns an audience export resource name. + + Args: + property_id: The property the export belongs to. + audience_export: Either a numeric audience export ID or a full + resource name of the form + 'properties/{property}/audienceExports/{id}'. + """ + if isinstance(audience_export, str): + audience_export = audience_export.strip() + if audience_export.startswith("properties/"): + return audience_export + + return ( + f"{construct_property_rn(property_id)}/" + f"audienceExports/{audience_export}" + ) + + +async def create_audience_export( + property_id: int | str, + audience_id: int | str, + dimensions: List[str], +) -> Dict[str, Any]: + """Starts an asynchronous audience export job. + + An audience export is a snapshot of the users in an audience at the + time of creation. Creating the export starts a long-running job; + poll its state with `get_audience_export`, and retrieve the user + rows with `query_audience_export` once the state is ACTIVE. + + Note: although this creates an export object, the operation only + reads analytics data and is permitted by the analytics.readonly + scope. Exports are retained for about 72 hours, and creation + charges audience export quota tokens. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + audience_id: The audience to export. Accepted formats are: + - A number (the audience ID) + - A full resource name, e.g. 'properties/1234/audiences/5678' + Use the `list_audiences` tool to discover audience IDs. + dimensions: The dimensions to include for each user, e.g. + `["deviceId"]`. Valid names are listed at + https://developers.google.com/analytics/devguides/reporting/data/v1/audience-list-basics#dimensions. + """ + property_rn = construct_property_rn(property_id) + + if isinstance(audience_id, str) and audience_id.strip().startswith( + "properties/" + ): + audience_rn = audience_id.strip() + else: + audience_rn = f"{property_rn}/audiences/{audience_id}" + + request = data_v1beta.CreateAudienceExportRequest( + parent=property_rn, + audience_export=data_v1beta.AudienceExport( + audience=audience_rn, + dimensions=[ + data_v1beta.AudienceDimension(dimension_name=d) + for d in dimensions + ], + ), + ) + + def _sync_call(): + operation = create_data_api_client().create_audience_export( + request=request + ) + # The operation metadata is the AudienceExport being created, + # including its name and state. Don't block on completion; + # callers poll with get_audience_export. + return operation.metadata + + metadata = await asyncio.to_thread(_sync_call) + + return proto_to_dict(metadata) + + +async def get_audience_export( + property_id: int | str, audience_export: int | str +) -> Dict[str, Any]: + """Returns the configuration and state of an audience export. + + Use this to poll an export created with `create_audience_export`. + When `state` is ACTIVE, the export can be queried with + `query_audience_export`. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + audience_export: The audience export to look up. Accepted + formats are: + - A number (the audience export ID) + - A full resource name, e.g. + 'properties/1234/audienceExports/5678' + """ + request = data_v1beta.GetAudienceExportRequest( + name=_construct_audience_export_rn(property_id, audience_export) + ) + + def _sync_call(): + return create_data_api_client().get_audience_export(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) + + +async def list_audience_exports( + property_id: int | str, +) -> List[Dict[str, Any]]: + """Returns all audience exports for a property. + + Exports are retained for about 72 hours after creation. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + """ + request = data_v1beta.ListAudienceExportsRequest( + parent=construct_property_rn(property_id) + ) + + def _sync_call(): + exports_pager = create_data_api_client().list_audience_exports( + request=request + ) + return [proto_to_dict(export) for export in exports_pager] + + return await asyncio.to_thread(_sync_call) + + +async def query_audience_export( + property_id: int | str, + audience_export: int | str, + offset: int = None, + limit: int = None, +) -> Dict[str, Any]: + """Retrieves the user rows from a completed audience export. + + The export must be in the ACTIVE state; check with + `get_audience_export` first. Each row contains the dimension values + requested when the export was created. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + audience_export: The audience export to query. Accepted formats + are: + - A number (the audience export ID) + - A full resource name, e.g. + 'properties/1234/audienceExports/5678' + offset: The row count of the start row (0-indexed). + limit: The maximum number of rows to return. + """ + request = data_v1beta.QueryAudienceExportRequest( + name=_construct_audience_export_rn(property_id, audience_export) + ) + + if offset is not None: + request.offset = offset + + if limit is not None: + request.limit = limit + + def _sync_call(): + return create_data_api_client().query_audience_export(request=request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/reporting/batch.py b/analytics_mcp/tools/reporting/batch.py new file mode 100644 index 0000000..f8082d5 --- /dev/null +++ b/analytics_mcp/tools/reporting/batch.py @@ -0,0 +1,221 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tools for running batch reports using the Data API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.reporting.metadata import ( + get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, + get_order_bys_hints, +) +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +def _batch_run_reports_description() -> str: + """Returns the description for the `batch_run_reports` tool.""" + return f""" + {batch_run_reports.__doc__} + + ## Hints for arguments + + Here are some hints that outline the expected format and + requirements for arguments. Each object in the `requests` + list uses the same argument formats as the `run_report` tool. + + ### Hints for `dimensions` + + The `dimensions` list must consist solely of either of the + following: + + 1. Standard dimensions defined in the HTML table at + https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#dimensions. + These dimensions are available to *every* property. + 2. Custom dimensions for the `property_id`. Use the + `get_custom_dimensions_and_metrics` tool to retrieve the + list of custom dimensions for a property. + + ### Hints for `metrics` + + The `metrics` list must consist solely of either of the + following: + + 1. Standard metrics defined in the HTML table at + https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#metrics. + These metrics are available to *every* property. + 2. Custom metrics for the `property_id`. Use the + `get_custom_dimensions_and_metrics` tool to retrieve the + list of custom metrics for a property. + + + ### Hints for `date_ranges`: + {get_date_ranges_hints()} + + ### Hints for `dimension_filter`: + {get_dimension_filter_hints()} + + ### Hints for `metric_filter`: + {get_metric_filter_hints()} + + ### Hints for `order_bys`: + {get_order_bys_hints()} + + """ + + +def _build_report_request( + property_rn: str, report: Dict[str, Any] +) -> data_v1beta.RunReportRequest: + """Builds a RunReportRequest proto from a report specification dict. + + Args: + property_rn: The property resource name (e.g. "properties/12345"). + report: A dict with keys matching the `run_report` tool's + parameters: `dimensions`, `metrics`, `date_ranges`, and + optionally `dimension_filter`, `metric_filter`, `order_bys`, + `limit`, `offset`, `currency_code`, `return_property_quota`. + + Returns: + A RunReportRequest proto. + """ + request = data_v1beta.RunReportRequest( + property=property_rn, + dimensions=[ + data_v1beta.Dimension(name=d) for d in report["dimensions"] + ], + metrics=[data_v1beta.Metric(name=m) for m in report["metrics"]], + date_ranges=[data_v1beta.DateRange(dr) for dr in report["date_ranges"]], + return_property_quota=report.get("return_property_quota", False), + ) + + dimension_filter = report.get("dimension_filter") + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) + + metric_filter = report.get("metric_filter") + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + order_bys = report.get("order_bys") + if order_bys: + request.order_bys = [data_v1beta.OrderBy(ob) for ob in order_bys] + + limit = report.get("limit") + if limit: + request.limit = limit + + offset = report.get("offset") + if offset: + request.offset = offset + + currency_code = report.get("currency_code") + if currency_code: + request.currency_code = currency_code + + return request + + +async def batch_run_reports( + property_id: int | str, + requests: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Runs multiple Google Analytics Data API reports in a single request. + + Use this tool instead of calling `run_report` multiple times when you + need data from several reports for the same property. This reduces + latency by combining up to 5 reports into one API call. + + Each object in the `requests` list accepts the same arguments as the + `run_report` tool. + + Note that the reference docs at + https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta + all use camelCase field names, but field names passed to this method + should be in snake_case since the tool is using the protocol buffers + (protobuf) format. The protocol buffers for the Data API are available + at + https://github.com/googleapis/googleapis/tree/master/google/analytics/data/v1beta. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + requests: A list of 1 to 5 report request objects. Each object + must contain the following required keys: + - `dimensions`: A list of dimensions to include in the report. + - `metrics`: A list of metrics to include in the report. + - `date_ranges`: A list of date ranges + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/DateRange) + to include in the report. + + Each object may also contain the following optional keys: + - `dimension_filter`: A Data API FilterExpression + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the dimensions. + - `metric_filter`: A Data API FilterExpression to apply to the + metrics. + - `order_bys`: A list of Data API OrderBy + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/OrderBy) + objects. + - `limit`: The maximum number of rows to return (max 250,000). + - `offset`: The row count of the start row (0-indexed). + - `currency_code`: An ISO4217 currency code (e.g. "USD"). + - `return_property_quota`: Whether to return property quota + information in the response (default: false). + """ + if not isinstance(requests, list): + raise ValueError("requests must be a list.") + if not requests: + raise ValueError("requests must contain at least one report request.") + if len(requests) > 5: + raise ValueError( + "requests must contain at most 5 report requests. " + f"Got {len(requests)}." + ) + + for i, report in enumerate(requests): + if not isinstance(report, dict): + raise ValueError(f"Request {i + 1} must be a dictionary.") + for key in ("dimensions", "metrics", "date_ranges"): + if key not in report: + raise ValueError( + f"Request {i + 1} is missing required key " f"'{key}'." + ) + if not isinstance(report[key], list): + raise ValueError(f"Request {i + 1} '{key}' must be a list.") + + property_rn = construct_property_rn(property_id) + + batch_request = data_v1beta.BatchRunReportsRequest( + property=property_rn, + requests=[_build_report_request(property_rn, r) for r in requests], + ) + + def _sync_call(): + return create_data_api_client().batch_run_reports(batch_request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/reporting/compatibility.py b/analytics_mcp/tools/reporting/compatibility.py new file mode 100644 index 0000000..0a2ab2a --- /dev/null +++ b/analytics_mcp/tools/reporting/compatibility.py @@ -0,0 +1,95 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tools for checking dimension and metric compatibility.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +async def check_compatibility( + property_id: int | str, + dimensions: List[str] = None, + metrics: List[str] = None, + dimension_filter: Dict[str, Any] = None, + metric_filter: Dict[str, Any] = None, + compatibility_filter: str = None, +) -> Dict[str, Any]: + """Checks which dimensions and metrics can be added to a report. + + Use this before `run_report` when combining several dimensions and + metrics, to avoid wasted report requests with incompatible field + combinations. The check is fast and consumes minimal quota. + + The response lists dimensions and metrics with their compatibility: + `COMPATIBLE` fields can be added to a report containing the + requested fields; `INCOMPATIBLE` fields cannot. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + dimensions: The dimensions already in the report, e.g. + `["country", "city"]`. May be empty or omitted. + metrics: The metrics already in the report, e.g. + `["activeUsers"]`. May be empty or omitted. + dimension_filter: A Data API FilterExpression + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) + applied to the dimensions in the planned report. + metric_filter: A Data API FilterExpression applied to the + metrics in the planned report. + compatibility_filter: Optionally restrict the response to a + single compatibility level. One of `"COMPATIBLE"` or + `"INCOMPATIBLE"`. Filtering to `"COMPATIBLE"` is recommended + since the full response can be large. + """ + request = data_v1beta.CheckCompatibilityRequest( + property=construct_property_rn(property_id), + dimensions=[data_v1beta.Dimension(name=d) for d in (dimensions or [])], + metrics=[data_v1beta.Metric(name=m) for m in (metrics or [])], + ) + + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) + + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + if compatibility_filter: + try: + request.compatibility_filter = data_v1beta.Compatibility[ + compatibility_filter.upper() + ] + except KeyError: + raise ValueError( + "compatibility_filter must be 'COMPATIBLE' or " + f"'INCOMPATIBLE'. Got '{compatibility_filter}'." + ) + + def _sync_call(): + return create_data_api_client().check_compatibility(request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/reporting/conversions.py b/analytics_mcp/tools/reporting/conversions.py index 9dbc7d3..380c7cd 100644 --- a/analytics_mcp/tools/reporting/conversions.py +++ b/analytics_mcp/tools/reporting/conversions.py @@ -175,11 +175,11 @@ async def run_conversions_report( data_v1alpha.OrderBy(order_by) for order_by in order_bys ] - if limit: + if limit is not None: request.limit = limit - if offset: + if offset is not None: request.offset = offset - if currency_code: + if currency_code is not None: request.currency_code = currency_code def _sync_call(): diff --git a/analytics_mcp/tools/reporting/core.py b/analytics_mcp/tools/reporting/core.py index e016d7e..7dc2147 100644 --- a/analytics_mcp/tools/reporting/core.py +++ b/analytics_mcp/tools/reporting/core.py @@ -161,11 +161,11 @@ async def run_report( data_v1beta.OrderBy(order_by) for order_by in order_bys ] - if limit: + if limit is not None: request.limit = limit - if offset: + if offset is not None: request.offset = offset - if currency_code: + if currency_code is not None: request.currency_code = currency_code def _sync_call(): diff --git a/analytics_mcp/tools/reporting/pivot.py b/analytics_mcp/tools/reporting/pivot.py new file mode 100644 index 0000000..82a8805 --- /dev/null +++ b/analytics_mcp/tools/reporting/pivot.py @@ -0,0 +1,358 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tools for running pivot reports using the Data API.""" + +import asyncio +from typing import Any, Dict, List + +from analytics_mcp.tools.reporting.metadata import ( + get_date_ranges_hints, + get_dimension_filter_hints, + get_metric_filter_hints, +) +from analytics_mcp.tools.utils import ( + construct_property_rn, + proto_to_dict, + proto_to_json, +) +from analytics_mcp.tools.client import create_data_api_client +from google.analytics import data_v1beta + + +def _get_pivots_hints() -> str: + """Returns hints and examples for pivots arguments.""" + pivot_country = data_v1beta.Pivot( + field_names=["country"], + limit=10, + ) + pivot_device = data_v1beta.Pivot( + field_names=["deviceCategory"], + limit=5, + order_bys=[ + data_v1beta.OrderBy( + metric=data_v1beta.OrderBy.MetricOrderBy( + metric_name="activeUsers" + ), + desc=True, + ) + ], + ) + pivot_aggregated = data_v1beta.Pivot( + field_names=["eventName"], + limit=20, + metric_aggregations=["TOTAL"], + ) + + return f"""Example pivots arguments: + + 1. A single pivot on 'country': + [ {proto_to_json(pivot_country)} ] + + 2. Cross-tabulate 'country' by 'deviceCategory', ordering the + device columns by descending active users: + [ + {proto_to_json(pivot_country)}, + {proto_to_json(pivot_device)} + ] + + 3. A pivot with metric aggregations (totals): + [ {proto_to_json(pivot_aggregated)} ] + + Each pivot's `field_names` must be a subset of the report request's + `dimensions`. A `limit` is required in practice: the product of all + pivots' limits must not exceed 250,000, and each pivot's limit + defaults to a large value if omitted. + """ + + +def _run_pivot_report_description() -> str: + """Returns the description for the `run_pivot_report` tool.""" + return f""" + {run_pivot_report.__doc__} + + ## Hints for arguments + + ### Hints for `dimensions` + + The `dimensions` list must consist solely of either of the + following: + + 1. Standard dimensions defined in the HTML table at + https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#dimensions. + These dimensions are available to *every* property. + 2. Custom dimensions for the `property_id`. Use the + `get_custom_dimensions_and_metrics` tool to retrieve the + list of custom dimensions for a property. + + ### Hints for `metrics` + + The `metrics` list must consist solely of either of the + following: + + 1. Standard metrics defined in the HTML table at + https://developers.google.com/analytics/devguides/reporting/data/v1/api-schema#metrics. + These metrics are available to *every* property. + 2. Custom metrics for the `property_id`. Use the + `get_custom_dimensions_and_metrics` tool to retrieve the + list of custom metrics for a property. + + ### Hints for `pivots`: + {_get_pivots_hints()} + + ### Hints for `date_ranges`: + {get_date_ranges_hints()} + + ### Hints for `dimension_filter`: + {get_dimension_filter_hints()} + + ### Hints for `metric_filter`: + {get_metric_filter_hints()} + + """ + + +async def run_pivot_report( + property_id: int | str, + date_ranges: List[Dict[str, Any]], + dimensions: List[str], + metrics: List[str], + pivots: List[Dict[str, Any]], + dimension_filter: Dict[str, Any] = None, + metric_filter: Dict[str, Any] = None, + currency_code: str = None, + keep_empty_rows: bool = False, + return_property_quota: bool = False, +) -> Dict[str, Any]: + """Runs a Google Analytics Data API pivot report. + + Pivot reports cross-tabulate dimensions, e.g. country x device + category, without client-side post-processing. Use `run_report` + instead for flat, non-pivoted tables. + + Note that the reference docs at + https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta + all use camelCase field names, but field names passed to this method + should be in snake_case since the tool is using the protocol buffers + (protobuf) format. The protocol buffers for the Data API are + available at + https://github.com/googleapis/googleapis/tree/master/google/analytics/data/v1beta. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + date_ranges: A list of date ranges + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/DateRange) + to include in the report. + dimensions: A list of dimensions to include in the report. Every + dimension referenced by a pivot must be listed here. + metrics: A list of metrics to include in the report. + pivots: A list of Data API Pivot + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/properties/runPivotReport#Pivot) + objects describing the visual layout of the report's + dimension columns and rows. Each pivot must contain + `field_names` (a subset of `dimensions`) and should contain a + `limit`. The product of all pivots' limits must not exceed + 250,000. + dimension_filter: A Data API FilterExpression + (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/FilterExpression) + to apply to the dimensions. Must not contain metrics. + metric_filter: A Data API FilterExpression to apply to the + metrics. Must not contain dimensions. + currency_code: An ISO4217 currency code (e.g. "USD"). + keep_empty_rows: Whether to include rows whose metrics are all + zero. + return_property_quota: Whether to return property quota + information in the response. + """ + request = _build_pivot_report_request( + construct_property_rn(property_id), + { + "date_ranges": date_ranges, + "dimensions": dimensions, + "metrics": metrics, + "pivots": pivots, + "dimension_filter": dimension_filter, + "metric_filter": metric_filter, + "currency_code": currency_code, + "keep_empty_rows": keep_empty_rows, + "return_property_quota": return_property_quota, + }, + ) + + def _sync_call(): + return create_data_api_client().run_pivot_report(request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) + + +def _validate_pivot_report_spec(spec: Dict[str, Any], label: str) -> None: + """Validates a pivot report specification dict. + + Args: + spec: The report specification to validate. + label: A label identifying the spec in error messages. + """ + for key in ("dimensions", "metrics", "date_ranges", "pivots"): + if not spec.get(key): + raise ValueError(f"{label} is missing required key '{key}'.") + if not isinstance(spec[key], list): + raise ValueError(f"{label} '{key}' must be a list.") + + for i, pivot in enumerate(spec["pivots"]): + if not isinstance(pivot, dict): + raise ValueError(f"{label} pivot {i + 1} must be a dictionary.") + if not pivot.get("field_names"): + raise ValueError( + f"{label} pivot {i + 1} is missing required key " + "'field_names'." + ) + + +def _build_pivot_report_request( + property_rn: str, spec: Dict[str, Any] +) -> data_v1beta.RunPivotReportRequest: + """Builds a RunPivotReportRequest proto from a specification dict. + + Args: + property_rn: The property resource name (e.g. "properties/12345"). + spec: A dict with keys matching the `run_pivot_report` tool's + parameters: `date_ranges`, `dimensions`, `metrics`, + `pivots`, and optionally `dimension_filter`, + `metric_filter`, `currency_code`, `keep_empty_rows`, + `return_property_quota`. + + Returns: + A RunPivotReportRequest proto. + """ + _validate_pivot_report_spec(spec, "Request") + + request = data_v1beta.RunPivotReportRequest( + property=property_rn, + dimensions=[data_v1beta.Dimension(name=d) for d in spec["dimensions"]], + metrics=[data_v1beta.Metric(name=m) for m in spec["metrics"]], + date_ranges=[data_v1beta.DateRange(dr) for dr in spec["date_ranges"]], + pivots=[data_v1beta.Pivot(p) for p in spec["pivots"]], + keep_empty_rows=spec.get("keep_empty_rows", False), + return_property_quota=spec.get("return_property_quota", False), + ) + + dimension_filter = spec.get("dimension_filter") + if dimension_filter: + request.dimension_filter = data_v1beta.FilterExpression( + dimension_filter + ) + + metric_filter = spec.get("metric_filter") + if metric_filter: + request.metric_filter = data_v1beta.FilterExpression(metric_filter) + + currency_code = spec.get("currency_code") + if currency_code: + request.currency_code = currency_code + + return request + + +def _batch_run_pivot_reports_description() -> str: + """Returns the description for the `batch_run_pivot_reports` tool.""" + return f""" + {batch_run_pivot_reports.__doc__} + + ## Hints for arguments + + Each object in the `requests` list uses the same argument + formats as the `run_pivot_report` tool. See that tool's + description for hints on `dimensions`, `metrics`, `pivots`, + `date_ranges`, and filters. + + ### Hints for `pivots` (per request): + {_get_pivots_hints()} + """ + + +async def batch_run_pivot_reports( + property_id: int | str, + requests: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Runs multiple Google Analytics pivot reports in a single request. + + Use this tool instead of calling `run_pivot_report` multiple times + when you need several pivot reports for the same property. This + reduces latency by combining up to 5 pivot reports into one API + call. + + Each object in the `requests` list accepts the same arguments as + the `run_pivot_report` tool. + + Args: + property_id: The Google Analytics property ID. Accepted formats + are: + - A number + - A string consisting of 'properties/' followed by a number + requests: A list of 1 to 5 pivot report request objects. Each + object must contain the following required keys: + - `date_ranges`: A list of date ranges to include. + - `dimensions`: A list of dimensions to include. + - `metrics`: A list of metrics to include. + - `pivots`: A list of Pivot objects, each with `field_names` + (a subset of the request's `dimensions`) and a `limit`. + + Each object may also contain the following optional keys: + - `dimension_filter`: A Data API FilterExpression to apply to + the dimensions. + - `metric_filter`: A Data API FilterExpression to apply to + the metrics. + - `currency_code`: An ISO4217 currency code (e.g. "USD"). + - `keep_empty_rows`: Whether to include rows whose metrics + are all zero. + - `return_property_quota`: Whether to return property quota + information in the response. + """ + if not isinstance(requests, list): + raise ValueError("requests must be a list.") + if not requests: + raise ValueError( + "requests must contain at least one pivot report request." + ) + if len(requests) > 5: + raise ValueError( + "requests must contain at most 5 pivot report requests. " + f"Got {len(requests)}." + ) + + for i, spec in enumerate(requests): + if not isinstance(spec, dict): + raise ValueError(f"Request {i + 1} must be a dictionary.") + _validate_pivot_report_spec(spec, f"Request {i + 1}") + + property_rn = construct_property_rn(property_id) + + batch_request = data_v1beta.BatchRunPivotReportsRequest( + property=property_rn, + requests=[ + _build_pivot_report_request(property_rn, spec) for spec in requests + ], + ) + + def _sync_call(): + return create_data_api_client().batch_run_pivot_reports(batch_request) + + response = await asyncio.to_thread(_sync_call) + + return proto_to_dict(response) diff --git a/analytics_mcp/tools/reporting/realtime.py b/analytics_mcp/tools/reporting/realtime.py index ee9b1b0..45f41d4 100644 --- a/analytics_mcp/tools/reporting/realtime.py +++ b/analytics_mcp/tools/reporting/realtime.py @@ -153,9 +153,9 @@ async def run_realtime_report( data_v1beta.OrderBy(order_by) for order_by in order_bys ] - if limit: + if limit is not None: request.limit = limit - if offset: + if offset is not None: request.offset = offset def _sync_call(): diff --git a/tests/audience_exports_test.py b/tests/audience_exports_test.py new file mode 100644 index 0000000..336bf3e --- /dev/null +++ b/tests/audience_exports_test.py @@ -0,0 +1,192 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the audience export tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.audience_exports import ( + _construct_audience_export_rn, + create_audience_export, + get_audience_export, + list_audience_exports, + query_audience_export, +) + +_CLIENT_PATH = ( + "analytics_mcp.tools.reporting.audience_exports.create_data_api_client" +) + + +class TestConstructAudienceExportRn(unittest.TestCase): + """Test cases for _construct_audience_export_rn.""" + + def test_numeric_id(self): + self.assertEqual( + _construct_audience_export_rn(12345, 678), + "properties/12345/audienceExports/678", + ) + + def test_full_resource_name_passthrough(self): + self.assertEqual( + _construct_audience_export_rn( + 12345, "properties/999/audienceExports/1" + ), + "properties/999/audienceExports/1", + ) + + def test_invalid_property_raises(self): + with self.assertRaises(ValueError): + _construct_audience_export_rn("bogus", 678) + + +class TestCreateAudienceExport(unittest.TestCase): + """Test cases for create_audience_export.""" + + @patch(_CLIENT_PATH) + def test_builds_request_and_returns_metadata(self, mock_create_client): + """Tests request construction and metadata passthrough.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_operation = MagicMock() + mock_operation.metadata = data_v1beta.AudienceExport( + name="properties/12345/audienceExports/42", + audience="properties/12345/audiences/777", + state=data_v1beta.AudienceExport.State.CREATING, + ) + mock_client.create_audience_export.return_value = mock_operation + + result = asyncio.run( + create_audience_export(12345, 777, dimensions=["deviceId"]) + ) + + request = mock_client.create_audience_export.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual( + request.audience_export.audience, + "properties/12345/audiences/777", + ) + self.assertEqual( + [d.dimension_name for d in request.audience_export.dimensions], + ["deviceId"], + ) + self.assertEqual(result["name"], "properties/12345/audienceExports/42") + self.assertEqual(result["state"], "CREATING") + + @patch(_CLIENT_PATH) + def test_accepts_full_audience_rn(self, mock_create_client): + """Tests that a full audience resource name is passed through.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_operation = MagicMock() + mock_operation.metadata = data_v1beta.AudienceExport() + mock_client.create_audience_export.return_value = mock_operation + + asyncio.run( + create_audience_export( + 12345, + "properties/12345/audiences/888", + dimensions=["deviceId"], + ) + ) + + request = mock_client.create_audience_export.call_args.kwargs["request"] + self.assertEqual( + request.audience_export.audience, + "properties/12345/audiences/888", + ) + + +class TestGetAudienceExport(unittest.TestCase): + """Test cases for get_audience_export.""" + + @patch(_CLIENT_PATH) + def test_returns_export_state(self, mock_create_client): + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.get_audience_export.return_value = ( + data_v1beta.AudienceExport( + name="properties/12345/audienceExports/42", + state=data_v1beta.AudienceExport.State.ACTIVE, + row_count=100, + ) + ) + + result = asyncio.run(get_audience_export(12345, 42)) + + request = mock_client.get_audience_export.call_args.kwargs["request"] + self.assertEqual(request.name, "properties/12345/audienceExports/42") + self.assertEqual(result["state"], "ACTIVE") + self.assertEqual(result["row_count"], 100) + + +class TestListAudienceExports(unittest.TestCase): + """Test cases for list_audience_exports.""" + + @patch(_CLIENT_PATH) + def test_lists_exports(self, mock_create_client): + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.list_audience_exports.return_value = [ + data_v1beta.AudienceExport( + name="properties/12345/audienceExports/1" + ) + ] + + result = asyncio.run(list_audience_exports(12345)) + + request = mock_client.list_audience_exports.call_args.kwargs["request"] + self.assertEqual(request.parent, "properties/12345") + self.assertEqual(len(result), 1) + + +class TestQueryAudienceExport(unittest.TestCase): + """Test cases for query_audience_export.""" + + @patch(_CLIENT_PATH) + def test_queries_rows(self, mock_create_client): + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.query_audience_export.return_value = ( + data_v1beta.QueryAudienceExportResponse( + audience_rows=[ + data_v1beta.AudienceRow( + dimension_values=[ + data_v1beta.AudienceDimensionValue( + value="device-abc" + ) + ] + ) + ], + row_count=1, + ) + ) + + result = asyncio.run(query_audience_export(12345, 42, limit=10)) + + request = mock_client.query_audience_export.call_args.kwargs["request"] + self.assertEqual(request.name, "properties/12345/audienceExports/42") + self.assertEqual(request.limit, 10) + self.assertEqual( + result["audience_rows"][0]["dimension_values"][0]["value"], + "device-abc", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/batch_test.py b/tests/batch_test.py new file mode 100644 index 0000000..3498a82 --- /dev/null +++ b/tests/batch_test.py @@ -0,0 +1,340 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the batch_run_reports tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.batch import ( + batch_run_reports, + _build_report_request, +) + + +class TestBuildReportRequest(unittest.TestCase): + """Test cases for _build_report_request.""" + + def test_required_fields(self): + """Tests that required fields are set correctly.""" + report = { + "dimensions": ["country", "city"], + "metrics": ["activeUsers", "sessions"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + request = _build_report_request("properties/12345", report) + + self.assertIsInstance(request, data_v1beta.RunReportRequest) + self.assertEqual(request.property, "properties/12345") + self.assertEqual(len(request.dimensions), 2) + self.assertEqual(request.dimensions[0].name, "country") + self.assertEqual(request.dimensions[1].name, "city") + self.assertEqual(len(request.metrics), 2) + self.assertEqual(request.metrics[0].name, "activeUsers") + self.assertEqual(request.metrics[1].name, "sessions") + self.assertEqual(len(request.date_ranges), 1) + self.assertFalse(request.return_property_quota) + + def test_optional_fields(self): + """Tests that optional fields are set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "limit": 100, + "offset": 50, + "currency_code": "USD", + "return_property_quota": True, + } + request = _build_report_request("properties/12345", report) + + self.assertEqual(request.limit, 100) + self.assertEqual(request.offset, 50) + self.assertEqual(request.currency_code, "USD") + self.assertTrue(request.return_property_quota) + + def test_dimension_filter(self): + """Tests that dimension_filter is set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "dimension_filter": { + "filter": { + "field_name": "country", + "string_filter": { + "match_type": "EXACT", + "value": "US", + }, + } + }, + } + request = _build_report_request("properties/12345", report) + + self.assertIsNotNone(request.dimension_filter) + + def test_metric_filter(self): + """Tests that metric_filter is set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "metric_filter": { + "filter": { + "field_name": "activeUsers", + "numeric_filter": { + "operation": "GREATER_THAN", + "value": {"int64_value": 10}, + }, + } + }, + } + request = _build_report_request("properties/12345", report) + + self.assertIsNotNone(request.metric_filter) + + def test_order_bys(self): + """Tests that order_bys are set when provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + "order_bys": [ + { + "metric": { + "metric_name": "activeUsers", + }, + "desc": True, + } + ], + } + request = _build_report_request("properties/12345", report) + + self.assertEqual(len(request.order_bys), 1) + + def test_optional_fields_absent(self): + """Tests that optional fields are absent when not provided.""" + report = { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + request = _build_report_request("properties/12345", report) + + self.assertEqual(request.limit, 0) + self.assertEqual(request.offset, 0) + self.assertEqual(request.currency_code, "") + self.assertEqual(len(request.order_bys), 0) + + +class TestBatchRunReports(unittest.TestCase): + """Test cases for batch_run_reports validation.""" + + def test_empty_requests_raises(self): + """Tests that an empty requests list raises ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, [])) + + def test_too_many_requests_raises(self): + """Tests that more than 5 requests raises ValueError.""" + reports = [ + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] * 6 + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_requests_not_a_list_raises(self): + """Tests that a non-list requests value raises ValueError.""" + with self.assertRaises(ValueError): + asyncio.run( + batch_run_reports( + 12345, + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + }, + ) + ) + + def test_non_dict_request_raises(self): + """Tests that a non-dict request raises ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, ["not a dict"])) + + def test_dimensions_not_a_list_raises(self): + """Tests that a non-list dimensions value raises ValueError.""" + reports = [ + { + "dimensions": "country", + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_missing_dimensions_raises(self): + """Tests that a request missing dimensions raises ValueError.""" + reports = [ + { + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_missing_metrics_raises(self): + """Tests that a request missing metrics raises ValueError.""" + reports = [ + { + "dimensions": ["country"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + def test_missing_date_ranges_raises(self): + """Tests that a request missing date_ranges raises ValueError.""" + reports = [ + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + } + ] + + with self.assertRaises(ValueError): + asyncio.run(batch_run_reports(12345, reports)) + + @patch("analytics_mcp.tools.reporting.batch." "create_data_api_client") + def test_api_called_with_correct_request(self, mock_client): + """Tests that the API is called with the correct request.""" + mock_response = MagicMock() + mock_response.__class__ = data_v1beta.BatchRunReportsResponse + mock_client_instance = MagicMock() + mock_client_instance.batch_run_reports.return_value = mock_response + mock_client.return_value = mock_client_instance + + reports = [ + { + "dimensions": ["country"], + "metrics": ["activeUsers"], + "date_ranges": [ + { + "start_date": "2025-01-01", + "end_date": "2025-01-31", + } + ], + }, + { + "dimensions": ["city"], + "metrics": ["sessions"], + "date_ranges": [ + { + "start_date": "2025-02-01", + "end_date": "2025-02-28", + } + ], + }, + ] + + with patch( + "analytics_mcp.tools.reporting.batch.proto_to_dict", + return_value={"reports": []}, + ): + result = asyncio.run(batch_run_reports(12345, reports)) + + mock_client_instance.batch_run_reports.assert_called_once() + call_args = mock_client_instance.batch_run_reports.call_args + batch_request = call_args[0][0] + + self.assertEqual(batch_request.property, "properties/12345") + self.assertEqual(len(batch_request.requests), 2) + self.assertEqual( + batch_request.requests[0].dimensions[0].name, + "country", + ) + self.assertEqual( + batch_request.requests[1].dimensions[0].name, + "city", + ) + self.assertEqual(result, {"reports": []}) diff --git a/tests/compatibility_test.py b/tests/compatibility_test.py new file mode 100644 index 0000000..a586e68 --- /dev/null +++ b/tests/compatibility_test.py @@ -0,0 +1,115 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the check_compatibility tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.compatibility import check_compatibility + + +class TestCheckCompatibility(unittest.TestCase): + """Test cases for check_compatibility.""" + + @patch("analytics_mcp.tools.reporting.compatibility.create_data_api_client") + def test_builds_request(self, mock_create_client): + """Tests that the request proto is built correctly.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.check_compatibility.return_value = ( + data_v1beta.CheckCompatibilityResponse() + ) + + asyncio.run( + check_compatibility( + 12345, + dimensions=["country", "city"], + metrics=["activeUsers"], + ) + ) + + request = mock_client.check_compatibility.call_args.args[0] + self.assertEqual(request.property, "properties/12345") + self.assertEqual( + [d.name for d in request.dimensions], ["country", "city"] + ) + self.assertEqual([m.name for m in request.metrics], ["activeUsers"]) + + @patch("analytics_mcp.tools.reporting.compatibility.create_data_api_client") + def test_compatibility_filter(self, mock_create_client): + """Tests that the compatibility filter enum is resolved.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.check_compatibility.return_value = ( + data_v1beta.CheckCompatibilityResponse() + ) + + asyncio.run( + check_compatibility( + 12345, + dimensions=["country"], + compatibility_filter="compatible", + ) + ) + + request = mock_client.check_compatibility.call_args.args[0] + self.assertEqual( + request.compatibility_filter, + data_v1beta.Compatibility.COMPATIBLE, + ) + + def test_invalid_compatibility_filter_raises(self): + """Tests that an invalid compatibility filter raises.""" + with self.assertRaises(ValueError): + asyncio.run( + check_compatibility(12345, compatibility_filter="bogus") + ) + + @patch("analytics_mcp.tools.reporting.compatibility.create_data_api_client") + def test_converts_response(self, mock_create_client): + """Tests that the proto response is converted to a dict.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.check_compatibility.return_value = ( + data_v1beta.CheckCompatibilityResponse( + dimension_compatibilities=[ + data_v1beta.DimensionCompatibility( + dimension_metadata=data_v1beta.DimensionMetadata( + api_name="country" + ), + compatibility=(data_v1beta.Compatibility.COMPATIBLE), + ) + ] + ) + ) + + result = asyncio.run(check_compatibility(12345)) + + self.assertEqual( + result["dimension_compatibilities"][0]["compatibility"], + "COMPATIBLE", + ) + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(check_compatibility("bogus")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/measurement_test.py b/tests/measurement_test.py new file mode 100644 index 0000000..897933a --- /dev/null +++ b/tests/measurement_test.py @@ -0,0 +1,181 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the Measurement Protocol tools.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from analytics_mcp.tools import measurement +from analytics_mcp.tools.measurement import send_event, validate_event + +_SECRET_ENV = {"ANALYTICS_MCP_MP_API_SECRET": "test-secret"} + +_EVENTS = [{"name": "tutorial_complete", "params": {"step": 5}}] + + +def _mock_response(validation_messages=None): + """Returns a mock httpx response.""" + response = MagicMock() + response.json.return_value = ( + {"validationMessages": validation_messages} + if validation_messages is not None + else {} + ) + response.raise_for_status.return_value = None + return response + + +class TestValidateEvent(unittest.TestCase): + """Test cases for validate_event.""" + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_posts_to_debug_endpoint(self, mock_post): + """Tests that validation hits only the debug endpoint.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + result = asyncio.run(validate_event("G-TEST123", "client-1", _EVENTS)) + + self.assertTrue(result["valid"]) + self.assertEqual(result["validation_messages"], []) + self.assertEqual(mock_post.call_count, 1) + url = mock_post.call_args.args[0] + self.assertEqual(url, measurement._DEBUG_COLLECT_URL) + params = mock_post.call_args.kwargs["params"] + self.assertEqual(params["measurement_id"], "G-TEST123") + self.assertEqual(params["api_secret"], "test-secret") + payload = mock_post.call_args.kwargs["json"] + self.assertEqual(payload["client_id"], "client-1") + self.assertEqual(payload["events"], _EVENTS) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_reports_validation_messages(self, mock_post): + """Tests that validation messages are surfaced.""" + messages = [{"description": "bad event name"}] + mock_post.return_value = _mock_response(validation_messages=messages) + + result = asyncio.run(validate_event("G-TEST123", "client-1", _EVENTS)) + + self.assertFalse(result["valid"]) + self.assertEqual(result["validation_messages"], messages) + + @patch.dict("os.environ", {"ANALYTICS_MCP_MP_API_SECRET": ""}) + def test_missing_secret_raises(self): + """Tests that a missing API secret raises a clear error.""" + with self.assertRaises(ValueError) as ctx: + asyncio.run(validate_event("G-TEST123", "client-1", _EVENTS)) + self.assertIn("ANALYTICS_MCP_MP_API_SECRET", str(ctx.exception)) + + @patch.dict("os.environ", _SECRET_ENV) + def test_too_many_events_raises(self): + """Tests that more than 25 events raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run( + validate_event("G-TEST123", "client-1", [{"name": "e"}] * 26) + ) + + @patch.dict("os.environ", _SECRET_ENV) + def test_event_without_name_raises(self): + """Tests that an event without a name raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run( + validate_event("G-TEST123", "client-1", [{"params": {}}]) + ) + + +class TestSendEvent(unittest.TestCase): + """Test cases for send_event.""" + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_dry_run_by_default(self, mock_post): + """Tests that without confirm=True nothing is recorded.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + result = asyncio.run(send_event("G-TEST123", "client-1", _EVENTS)) + + self.assertFalse(result["sent"]) + self.assertTrue(result["dry_run"]) + self.assertTrue(result["validation"]["valid"]) + # Only the debug endpoint may be called on a dry run. + urls = [call.args[0] for call in mock_post.call_args_list] + self.assertEqual(urls, [measurement._DEBUG_COLLECT_URL]) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_confirm_sends_after_validation(self, mock_post): + """Tests that confirm=True validates then sends.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + result = asyncio.run( + send_event("G-TEST123", "client-1", _EVENTS, confirm=True) + ) + + self.assertTrue(result["sent"]) + self.assertFalse(result["dry_run"]) + self.assertEqual(result["events_sent"], 1) + urls = [call.args[0] for call in mock_post.call_args_list] + self.assertEqual( + urls, + [measurement._DEBUG_COLLECT_URL, measurement._COLLECT_URL], + ) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_confirm_blocked_by_validation_failure(self, mock_post): + """Tests that invalid payloads are never sent, even confirmed.""" + mock_post.return_value = _mock_response( + validation_messages=[{"description": "bad"}] + ) + + result = asyncio.run( + send_event("G-TEST123", "client-1", _EVENTS, confirm=True) + ) + + self.assertFalse(result["sent"]) + urls = [call.args[0] for call in mock_post.call_args_list] + self.assertEqual(urls, [measurement._DEBUG_COLLECT_URL]) + + @patch.dict("os.environ", _SECRET_ENV) + @patch("analytics_mcp.tools.measurement.httpx.post") + def test_optional_fields_in_payload(self, mock_post): + """Tests that optional payload fields are passed through.""" + mock_post.return_value = _mock_response(validation_messages=[]) + + asyncio.run( + send_event( + "G-TEST123", + "client-1", + _EVENTS, + user_id="user-9", + timestamp_micros=1700000000000000, + user_properties={"plan": {"value": "premium"}}, + non_personalized_ads=True, + ) + ) + + payload = mock_post.call_args.kwargs["json"] + self.assertEqual(payload["user_id"], "user-9") + self.assertEqual(payload["timestamp_micros"], 1700000000000000) + self.assertEqual( + payload["user_properties"], {"plan": {"value": "premium"}} + ) + self.assertTrue(payload["non_personalized_ads"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/pivot_test.py b/tests/pivot_test.py new file mode 100644 index 0000000..955e8be --- /dev/null +++ b/tests/pivot_test.py @@ -0,0 +1,214 @@ +# Copyright 2025 Google LLC All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test cases for the run_pivot_report tool.""" + +import asyncio +import unittest +from unittest.mock import MagicMock, patch + +from google.analytics import data_v1beta + +from analytics_mcp.tools.reporting.pivot import ( + _run_pivot_report_description, + run_pivot_report, +) + +_BASE_ARGS = { + "date_ranges": [{"start_date": "7daysAgo", "end_date": "today"}], + "dimensions": ["country", "deviceCategory"], + "metrics": ["activeUsers"], + "pivots": [ + {"field_names": ["country"], "limit": 10}, + {"field_names": ["deviceCategory"], "limit": 5}, + ], +} + + +class TestRunPivotReport(unittest.TestCase): + """Test cases for run_pivot_report.""" + + @patch("analytics_mcp.tools.reporting.pivot.create_data_api_client") + def test_builds_request(self, mock_create_client): + """Tests that the request proto is built correctly.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_pivot_report.return_value = ( + data_v1beta.RunPivotReportResponse() + ) + + asyncio.run(run_pivot_report(12345, **_BASE_ARGS)) + + request = mock_client.run_pivot_report.call_args.args[0] + self.assertIsInstance(request, data_v1beta.RunPivotReportRequest) + self.assertEqual(request.property, "properties/12345") + self.assertEqual( + [d.name for d in request.dimensions], + ["country", "deviceCategory"], + ) + self.assertEqual([m.name for m in request.metrics], ["activeUsers"]) + self.assertEqual(len(request.pivots), 2) + self.assertEqual(list(request.pivots[0].field_names), ["country"]) + self.assertEqual(request.pivots[0].limit, 10) + self.assertEqual( + list(request.pivots[1].field_names), ["deviceCategory"] + ) + self.assertFalse(request.keep_empty_rows) + + @patch("analytics_mcp.tools.reporting.pivot.create_data_api_client") + def test_optional_fields(self, mock_create_client): + """Tests that optional fields are set when provided.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_pivot_report.return_value = ( + data_v1beta.RunPivotReportResponse() + ) + + asyncio.run( + run_pivot_report( + 12345, + **_BASE_ARGS, + dimension_filter={ + "filter": { + "field_name": "country", + "string_filter": {"value": "Japan"}, + } + }, + currency_code="USD", + keep_empty_rows=True, + return_property_quota=True, + ) + ) + + request = mock_client.run_pivot_report.call_args.args[0] + self.assertEqual(request.dimension_filter.filter.field_name, "country") + self.assertEqual(request.currency_code, "USD") + self.assertTrue(request.keep_empty_rows) + self.assertTrue(request.return_property_quota) + + def test_empty_pivots_raises(self): + """Tests that an empty pivots list raises a ValueError.""" + args = dict(_BASE_ARGS, pivots=[]) + with self.assertRaises(ValueError): + asyncio.run(run_pivot_report(12345, **args)) + + def test_pivot_missing_field_names_raises(self): + """Tests that a pivot without field_names raises a ValueError.""" + args = dict(_BASE_ARGS, pivots=[{"limit": 5}]) + with self.assertRaises(ValueError): + asyncio.run(run_pivot_report(12345, **args)) + + @patch("analytics_mcp.tools.reporting.pivot.create_data_api_client") + def test_converts_response(self, mock_create_client): + """Tests that the proto response is converted to a dict.""" + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.run_pivot_report.return_value = ( + data_v1beta.RunPivotReportResponse( + aggregates=[], + rows=[ + data_v1beta.Row( + dimension_values=[ + data_v1beta.DimensionValue(value="Japan") + ], + metric_values=[data_v1beta.MetricValue(value="42")], + ) + ], + ) + ) + + result = asyncio.run(run_pivot_report(12345, **_BASE_ARGS)) + + self.assertEqual( + result["rows"][0]["dimension_values"][0]["value"], "Japan" + ) + self.assertEqual(result["rows"][0]["metric_values"][0]["value"], "42") + + def test_invalid_property_id_raises(self): + """Tests that an invalid property ID raises a ValueError.""" + with self.assertRaises(ValueError): + asyncio.run(run_pivot_report("bogus", **_BASE_ARGS)) + + +class TestDescription(unittest.TestCase): + """Test cases for the tool description.""" + + def test_description_includes_hints(self): + """Tests that the description contains pivot hints.""" + description = _run_pivot_report_description() + self.assertIn("pivots", description) + self.assertIn("field_names", description) + self.assertIn("date_range", description) + + +if __name__ == "__main__": + unittest.main() + + +class TestBatchRunPivotReports(unittest.TestCase): + """Test cases for batch_run_pivot_reports.""" + + @patch("analytics_mcp.tools.reporting.pivot.create_data_api_client") + def test_builds_batch_request(self, mock_create_client): + """Tests that the batch request proto is built correctly.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + mock_client = MagicMock() + mock_create_client.return_value = mock_client + mock_client.batch_run_pivot_reports.return_value = ( + data_v1beta.BatchRunPivotReportsResponse() + ) + + asyncio.run( + batch_run_pivot_reports(12345, requests=[_BASE_ARGS, _BASE_ARGS]) + ) + + request = mock_client.batch_run_pivot_reports.call_args.args[0] + self.assertIsInstance(request, data_v1beta.BatchRunPivotReportsRequest) + self.assertEqual(request.property, "properties/12345") + self.assertEqual(len(request.requests), 2) + self.assertEqual(request.requests[0].property, "properties/12345") + self.assertEqual(len(request.requests[0].pivots), 2) + + def test_empty_requests_raises(self): + """Tests that an empty requests list raises a ValueError.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + with self.assertRaises(ValueError): + asyncio.run(batch_run_pivot_reports(12345, requests=[])) + + def test_too_many_requests_raises(self): + """Tests that more than 5 requests raises a ValueError.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + with self.assertRaises(ValueError): + asyncio.run( + batch_run_pivot_reports(12345, requests=[_BASE_ARGS] * 6) + ) + + def test_request_missing_pivots_raises(self): + """Tests that a request without pivots raises a ValueError.""" + from analytics_mcp.tools.reporting.pivot import ( + batch_run_pivot_reports, + ) + + bad = {k: v for k, v in _BASE_ARGS.items() if k != "pivots"} + with self.assertRaises(ValueError): + asyncio.run(batch_run_pivot_reports(12345, requests=[bad]))