From fc1ed9dac1578b9b5a033a5b7b252465090217d5 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 17 Nov 2025 16:34:48 -0500 Subject: [PATCH 01/61] - Modified the installation instruction to NOT use the gemini-cli installation command which is still buggy. - Modified GEMINI.md to reinforce that it must use the latest version of the API. --- GEMINI.md | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 36e5b0b..3af0a20 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -33,7 +33,7 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 1.2. API Versioning -ALWAYS dynamically determine the latest Google Ads API version by performing a web search at the start of any task involving API versioning. Use this latest version unless explicitly specified otherwise by the user. Explicitly state the API version being used in generated code or when discussing API interactions. +ALWAYS dynamically determine the latest Google Ads API version by performing a web search at the start of any task involving API versioning. Use this latest version unless explicitly specified otherwise by the user. Explicitly state the API version being used in generated code or when discussing API interactions. All generated Python code and GAQL queries MUST import and utilize this determined latest version (e.g., `google.ads.googleads.vXX`). --- diff --git a/README.md b/README.md index 88be987..1adead3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # README: Google Ads API Developer Assistant (Gemini CLI Extension) -**TL;DR:** This extension for the Gemini CLI lets you interact with the Google Ads API using natural language. Ask questions, generate GAQL and Python code, and execute API calls directly in your terminal. +**TL;DR:** This extension for the Gemini CLI lets you interact with the Google Ads API using natural language. Ask questions, generate GAQL and Python code, and execute API calls that read directly in your terminal. ## Overview @@ -48,7 +48,7 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the 3. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. -4. **Install This Extension:** `gemini install https://github.com/googleads/google-ads-api-developer-assistant` +4. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. 5. **Set Context in Gemini:** The `gemini` command must be run from the root of the `google-ads-api-developer-assistant` project directory. Configure the context paths in `.gemini/settings.json`: * Edit `/path/to/your/google-ads-api-developer-assistant/.gemini/settings.json`. From 7824e0c94fe63b9da58575f84f8628f4a905bcb8 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 18 Nov 2025 14:09:28 -0500 Subject: [PATCH 02/61] Corrected version --- GEMINI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 3af0a20..7e0c97b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,6 @@ # AI Assistant Configuration: Google Ads API Developer Assistant -## Version: 2.0 +## Version: 1.0 ## Optimized for Machine Comprehension This document outlines mandatory operational guidelines, constraints, and best practices for the Google Ads API Developer Assistant. From a655aaed7ade65d97b7beba949c069cb7b797bd8 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 18 Nov 2025 14:24:38 -0500 Subject: [PATCH 03/61] add settings.json --- .gemini/settings.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .gemini/settings.json diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..82e9919 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,14 @@ +{ + "ui": { + "accessibility": { + "disableLoadingPhrases": true + } + }, + "context": { + "includeDirectories": [ + "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", + "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", + "/path/to/your/library/google-ads-python" + ] + } +} From a214adce4ca41523ce7506ccfe0c35a97d71b345 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 18 Nov 2025 15:05:42 -0500 Subject: [PATCH 04/61] Change minimum python version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1adead3..3c13e04 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the 3. A configured `google-ads.yaml` credentials file in your home directory (see [google-ads-python docs](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml)). 4. Gemini CLI installed (see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)). 5. A local clone of the [google-ads-python](https://github.com/googleads/google-ads-python) client library. Clone this in a directory that is NOT under the Google Ads API Developer Assistant project directory. -6. Python >= 3.9 installed and available on your system PATH. +6. Python >= 3.10 installed and available on your system PATH. ## Setup Guide From c2a58b8d635b8db8146b652c2a2930bd1c224249 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 18 Nov 2025 15:14:33 -0500 Subject: [PATCH 05/61] Reformatted title --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c13e04..9ce5813 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# README: Google Ads API Developer Assistant (Gemini CLI Extension) +# Google Ads API Developer Assistant (Gemini CLI Extension) **TL;DR:** This extension for the Gemini CLI lets you interact with the Google Ads API using natural language. Ask questions, generate GAQL and Python code, and execute API calls that read directly in your terminal. From 49846cb8d515dc793aa1bad6b65f3fbab338550d Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 19 Nov 2025 09:45:42 -0500 Subject: [PATCH 06/61] Specified to prioritize dedicated services --- GEMINI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 7e0c97b..ceed177 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -67,6 +67,8 @@ For consistent execution and dependency management: * **Search Operations:** Use `SearchGoogleAdsStream` objects (e.g., `SearchGoogleAdsStreamRequest`, NOT `SearchGoogleAdsRequest`). * **Change History:** Use `change_status` resources. * **AI Max for Search Campaigns:** Set `Campaign.ai_max_setting.enable_ai_max = True`. +* **Prioritize Dedicated Services for System-Managed Entities:** When dealing with entities that are "automatically created," "system-generated," or "managed by Google," always prioritize searching for and using dedicated API services or operations specifically designed for those entities (e.g., `AutomaticallyCreatedAssetRemovalService` for `CampaignAutomaticallyCreatedAsset`). Avoid attempting to manage them through generic `AdService` or `AssetService` mutations unless a dedicated service does not exist. +* **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. #### 3.2. GAQL QUERY WRITING From f98df121f9ce1224124f99ac1197ed2973b20fa2 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 19 Nov 2025 09:57:45 -0500 Subject: [PATCH 07/61] Added guidance on dealing with automatically created assets --- GEMINI.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index ceed177..33f3d7b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -70,6 +70,13 @@ For consistent execution and dependency management: * **Prioritize Dedicated Services for System-Managed Entities:** When dealing with entities that are "automatically created," "system-generated," or "managed by Google," always prioritize searching for and using dedicated API services or operations specifically designed for those entities (e.g., `AutomaticallyCreatedAssetRemovalService` for `CampaignAutomaticallyCreatedAsset`). Avoid attempting to manage them through generic `AdService` or `AssetService` mutations unless a dedicated service does not exist. * **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. +#### 3.1.1. Handling Automatically Created/System-Managed Entities + +When dealing with entities that are "automatically created," "system-generated," or "managed by Google" (e.g., `CampaignAutomaticallyCreatedAsset`, `CampaignDraft`), always prioritize searching for and using dedicated API services or operations specifically designed for those entities. + +* **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. +* **Avoid Generic Services for Specific Automated Entities:** Do not attempt to manage these highly specific automated entities through more generic services (e.g., `AdService`, `AssetService`, or `CampaignAssetService`) unless a dedicated service does not exist or documentation explicitly states it. The API often provides specialized services for precise control over these system-managed resources. + #### 3.2. GAQL QUERY WRITING * **Format:** Provide GAQL queries within `sql` markdown blocks. From 0265f52f0fefd8bb24de17f8ad96c65bdebcb8e5 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 19 Nov 2025 11:21:58 -0500 Subject: [PATCH 08/61] Add example for remove automatically created assets --- GEMINI.md | 1 + .../remove_automatically_created_assets.py | 126 +++++++++++ ...est_remove_automatically_created_assets.py | 206 ++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 api_examples/remove_automatically_created_assets.py create mode 100644 api_examples/tests/test_remove_automatically_created_assets.py diff --git a/GEMINI.md b/GEMINI.md index 33f3d7b..66264ef 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -76,6 +76,7 @@ When dealing with entities that are "automatically created," "system-generated," * **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. * **Avoid Generic Services for Specific Automated Entities:** Do not attempt to manage these highly specific automated entities through more generic services (e.g., `AdService`, `AssetService`, or `CampaignAssetService`) unless a dedicated service does not exist or documentation explicitly states it. The API often provides specialized services for precise control over these system-managed resources. +* **Specifically for Removing Automatically Created Assets:** When the task involves removing "automatically created" campaign assets (e.g., `CampaignAutomaticallyCreatedAsset` resources), use the `AutomaticallyCreatedAssetRemovalService` and its `mutate_automatically_created_asset_removals` method with `AutomaticallyCreatedAssetRemovalOperation` to remove the asset associations. Do NOT use generic asset removal services for these specific entity types #### 3.2. GAQL QUERY WRITING diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py new file mode 100644 index 0000000..b5a7e37 --- /dev/null +++ b/api_examples/remove_automatically_created_assets.py @@ -0,0 +1,126 @@ +import argparse +import sys + +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException +from google.ads.googleads.v22.enums.asset_field_type_enum.asset_field_type import ( + AssetFieldTypeEnum, +) + + +def main( + client: GoogleAdsClient, + customer_id: str, + campaign_id: int, + asset_resource_name: str, + field_type: str, +): + """Removes automatically created assets from a campaign. + + Args: + client: The Google Ads client. + customer_id: The ID of the customer managing the campaign. + campaign_id: The ID of the campaign to remove assets from. + asset_resource_name: The resource name of the asset to remove. + field_type: The field type of the asset to remove (e.g., "HEADLINE", "DESCRIPTION"). + """ + automatically_created_asset_removal_service = client.get_service( + "AutomaticallyCreatedAssetRemovalService" + ) + campaign_service = client.get_service("CampaignService") + + # [START remove_automatically_created_assets] + # To find automatically created assets, you need to query the + # 'campaign_asset' or 'asset' resources, filtering for + # 'asset.automatically_created = TRUE'. + # The 'automatically_created_asset' field in the operation should be the + # resource name of the asset you wish to remove. + # For example: "customers/{customer_id}/assets/{asset_id}" + # The 'asset_type' field should correspond to the type of the asset you are + # removing (e.g., TEXT, IMAGE, VIDEO). + + try: + field_type_enum = getattr(AssetFieldTypeEnum, field_type.upper()) + except AttributeError: + print( + f"Error: Invalid field type '{field_type}'. " + f"Please use one of: {[e.name for e in AssetFieldTypeEnum if e.name not in ('UNSPECIFIED', 'UNKNOWN')]}" + ) + sys.exit(1) + + operations = [] + operation = client.get_type("RemoveCampaignAutomaticallyCreatedAssetOperation") + operation.campaign = campaign_service.campaign_path(customer_id, campaign_id) + operation.asset = asset_resource_name + operation.field_type = field_type_enum + + operations.append(operation) + + try: + request = client.get_type("RemoveCampaignAutomaticallyCreatedAssetRequest") + request.customer_id = customer_id + request.operations.append(operation) # Append the already created operation + request.partial_failure = False # Assuming we want to fail all if any fail + response = automatically_created_asset_removal_service.remove_campaign_automatically_created_asset( + request=request + ) + print(f"Removed {len(response.results)} automatically created assets.") + except GoogleAdsException as ex: + print( + f"Request with ID '{ex.request_id}' failed with status " + f"'{ex.error.code().name}' and includes the following errors:" + ) + for error in ex.failure.errors: + print(f"\tError with message '{error.message}'.") + if error.location: + for field_path_element in error.location.field_path_elements: + print(f"\t\tOn field: {field_path_element.field_name}") + sys.exit(1) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Removes automatically created assets from a campaign." + ) + # The following arguments are required. + parser.add_argument( + "-c", + "--customer_id", + type=str, + required=True, + help="The Google Ads customer ID.", + ) + parser.add_argument( + "-C", "--campaign_id", type=int, required=True, help="The campaign ID." + ) + parser.add_argument( + "-a", + "--asset_resource_name", + type=str, + required=True, + help="The resource name of the asset to remove.", + ) + parser.add_argument( + "-f", + "--field_type", + type=str, + required=True, + help=( + "The field type of the asset to remove (e.g., HEADLINE, DESCRIPTION). " + "Refer to the AssetFieldTypeEnum documentation for possible values: " + "https://developers.google.com/google-ads/api/reference/rpc/v22/AssetFieldTypeEnum" + ), + ) + args = parser.parse_args() + + # GoogleAdsClient will read the google-ads.yaml file from the home directory. + googleads_client = GoogleAdsClient.load_from_storage(version="v22") + + main( + googleads_client, + args.customer_id, + args.campaign_id, + args.asset_resource_name, + args.field_type, + ) + diff --git a/api_examples/tests/test_remove_automatically_created_assets.py b/api_examples/tests/test_remove_automatically_created_assets.py new file mode 100644 index 0000000..88dd56b --- /dev/null +++ b/api_examples/tests/test_remove_automatically_created_assets.py @@ -0,0 +1,206 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. + +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +import unittest +from unittest.mock import MagicMock +from io import StringIO + +from google.ads.googleads.errors import GoogleAdsException +from google.ads.googleads.client import GoogleAdsClient + +# Import the main function from the script +from api_examples.remove_automatically_created_assets import main + + +class TestRemoveAutomaticallyCreatedAssets(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_automatically_created_asset_removal_service = MagicMock() + self.mock_campaign_service = MagicMock() + + self.mock_client.get_service.side_effect = self._get_mock_service + + class MockAssetFieldType: + UNSPECIFIED = MagicMock() + UNSPECIFIED.name = "UNSPECIFIED" + UNKNOWN = MagicMock() + UNKNOWN.name = "UNKNOWN" + HEADLINE = MagicMock() + HEADLINE.name = "HEADLINE" + DESCRIPTION = MagicMock() + DESCRIPTION.name = "DESCRIPTION" + MANDATORY_AD_TEXT = MagicMock() + MANDATORY_AD_TEXT.name = "MANDATORY_AD_TEXT" + + # Add other relevant enum values as needed for testing + + def __getitem__(self, key): + if not hasattr(self, key): + raise KeyError(f"'{key}' is not a valid AssetFieldType") + return getattr(self, key) + + def __iter__(self): + # Return a list of mock enum values for iteration + return iter( + [ + self.UNSPECIFIED, + self.UNKNOWN, + self.HEADLINE, + self.DESCRIPTION, + self.MANDATORY_AD_TEXT, + ] + ) + + self.mock_real_asset_field_type = MockAssetFieldType() + + self.mock_client.enums = MagicMock() + self.mock_client.enums.AssetFieldTypeEnum = MagicMock() + self.mock_client.enums.AssetFieldTypeEnum.AssetFieldType = ( + self.mock_real_asset_field_type + ) + + self.customer_id = "1234567890" + self.campaign_id = 12345 + self.asset_resource_name = "customers/1234567890/assets/67890" + self.field_type = "HEADLINE" + + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def _get_mock_service(self, service_name): + if service_name == "AutomaticallyCreatedAssetRemovalService": + return self.mock_automatically_created_asset_removal_service + elif service_name == "CampaignService": + return self.mock_campaign_service + return MagicMock() + + def tearDown(self): + sys.stdout = sys.__stdout__ + + def test_main_successful_removal(self): + mock_response = MagicMock() + mock_response.results = [MagicMock()] + self.mock_automatically_created_asset_removal_service.remove_campaign_automatically_created_asset.return_value = mock_response + + self.mock_campaign_service.campaign_path.return_value = ( + f"customers/{self.customer_id}/campaigns/{self.campaign_id}" + ) + + mock_request = MagicMock() + mock_request.operations = [] + mock_request.customer_id = self.customer_id + mock_request.partial_failure = False + + self.mock_client.get_type.return_value = mock_request + + main( + self.mock_client, + self.customer_id, + self.campaign_id, + self.asset_resource_name, + self.field_type, + ) + + self.mock_campaign_service.campaign_path.assert_called_once_with( + self.customer_id, self.campaign_id + ) + self.mock_automatically_created_asset_removal_service.remove_campaign_automatically_created_asset.assert_called_once_with( + request=mock_request + ) + + self.assertEqual(len(mock_request.operations), 1) + operation = mock_request.operations[0] + self.assertEqual( + operation.campaign, + f"customers/{self.customer_id}/campaigns/{self.campaign_id}", + ) + self.assertEqual(operation.asset, self.asset_resource_name) + self.assertEqual(operation.field_type.name, self.field_type) + + output = self.captured_output.getvalue() + self.assertIn("Removed 1 automatically created assets.", output) + + def test_main_google_ads_exception(self): + mock_code_obj = MagicMock() + mock_code_obj.name = "REQUEST_ERROR" + mock_error = MagicMock() + mock_error.code.return_value = mock_code_obj + self.mock_automatically_created_asset_removal_service.remove_campaign_automatically_created_asset.side_effect = GoogleAdsException( + error=mock_error, + call=MagicMock(), + failure=MagicMock( + errors=[ + MagicMock( + message="Error details", + location=MagicMock( + field_path_elements=[MagicMock(field_name="test_field")] + ), + ) + ] + ), + request_id="test_request_id", + ) + self.mock_campaign_service.campaign_path.return_value = ( + f"customers/{self.customer_id}/campaigns/{self.campaign_id}" + ) + + with self.assertRaises(SystemExit) as cm: + main( + self.mock_client, + self.customer_id, + self.campaign_id, + self.asset_resource_name, + self.field_type, + ) + + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn( + "Request with ID 'test_request_id' failed with status 'REQUEST_ERROR'", + output, + ) + self.assertIn("Error with message 'Error details'.", output) + self.assertIn("On field: test_field", output) + + def test_main_invalid_field_type(self): + # We need to temporarily restore sys.stdout to prevent MagicMock issues + sys.stdout = sys.__stdout__ + self.captured_output = StringIO() + sys.stdout = self.captured_output + + invalid_field_type = "INVALID_TYPE" + + with self.assertRaises(SystemExit) as cm: + main( + self.mock_client, + self.customer_id, + self.campaign_id, + self.asset_resource_name, + invalid_field_type, + ) + + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn(f"Error: Invalid field type '{invalid_field_type}'.", output) + self.assertIn("Please use one of:", output) + + +if __name__ == "__main__": + unittest.main() + From 2780103645470da970a7402407249019171ef67b Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 20 Nov 2025 12:51:15 -0500 Subject: [PATCH 09/61] - Modified README - Added setup.sh --- README.md | 20 +++++--- setup.sh | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) create mode 100755 setup.sh diff --git a/README.md b/README.md index 9ce5813..020e8be 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,23 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the ## Setup Guide -1. **Install Gemini CLI:** Ensure that [Gemini CLI](https://github.com/google-gemini/gemini-cli) is installed. +1. **Install Gemini CLI:** Ensure that [Gemini CLI](https://github.com/google-gemini/gemini-cli) is installed. **Pro tip**: Before starting the installation read the [authentication](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#-authentication-options) section. -2. **Clone Google Ads Python Library:** Clone the [google-ads-python](https://github.com/googleads/google-ads-python) repository to a local directory (e.g., `$HOME/path/to/google-ads-python`) that is not under the Google Ads API Developer Assistant project directory. This provides context for code generation. +2. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. -3. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. +3. Run setup.sh + * Ensure that [ja](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. This is a json processor that allows us to write a valid settings.json. + * cd to /google-ads-api-developer-extension + * run ./setup.sh -4. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. +4. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. -5. **Set Context in Gemini:** The `gemini` command must be run from the root of the `google-ads-api-developer-assistant` project directory. Configure the context paths in `.gemini/settings.json`: +5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id=YOUR_CUSTOMER_ID` (e.g., `customer_id=1234567890`). You can then use prompts like *"Get campaigns for the default customer"*. + +### Manual Installation +1. **Clone Google Ads Python Library:** Clone the [google-ads-python](https://github.com/googleads/google-ads-python) repository to a local directory (e.g., `$HOME/path/to/google-ads-python`) that is not under the Google Ads API Developer Assistant project directory. This provides context for code generation. + +2. **Set Context in Gemini:** The `gemini` command must be run from the root of the `google-ads-api-developer-assistant` project directory. Configure the context paths in `.gemini/settings.json`: * Edit `/path/to/your/google-ads-api-developer-assistant/.gemini/settings.json`. * Add the **full absolute paths** to the `context.includeDirectories` array: * Your `google-ads-python` library clone. @@ -71,8 +79,6 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the ``` *Note: Replace the placeholder paths with the actual absolute paths on your system.* -6. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id=YOUR_CUSTOMER_ID` (e.g., `customer_id=1234567890`). You can then use prompts like *"Get campaigns for the default customer"*. - ## Usage Examples 1. **Start Gemini CLI:** diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..d661e3a --- /dev/null +++ b/setup.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# Exit on any error, and on undefined variables. +set -eu + +# Function to print errors to stderr +err() { + echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2 +} + +# --- Configuration --- +readonly PYTHON_LIB_REPO_URL="https://github.com/googleads/google-ads-python.git" +readonly PYTHON_LIB_NAME="google-ads-python" + +# --- Dependency Check --- +if ! command -v jq &> /dev/null; then + err "ERROR: jq is not installed. Please install it to continue." + err "See: https://jqlang.github.io/jq/download/" + exit 1 +fi +if ! command -v git &> /dev/null; then + err "ERROR: git is not installed. Please install it to continue." + exit 1 +fi + +# --- Argument Parsing --- +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + echo " Clones/updates the ${PYTHON_LIB_NAME} repository and modifies the settings file." >&2 + echo " This script must be run from within the google-ads-api-developer-assistant git repository." >&2 + echo " : Fully qualified path to an existing directory" >&2 + echo " where the '${PYTHON_LIB_NAME}' library will be cloned." >&2 + echo " This must NOT be under the project directory." >&2 + echo " Example: $0 /home/user/development/libs" >&2 + exit 1 +fi +readonly PYTHON_LIB_PARENT_DIR_ARG=$1 + +# --- Project Directory Resolution --- +# Determine the root directory of the current git repository. +if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then + err "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +fi +readonly PROJECT_DIR_ABS +echo "Detected project root: ${PROJECT_DIR_ABS}" + +# --- Python Lib Path Resolution and Validation --- +if ! PYTHON_LIB_PARENT_DIR=$(realpath "${PYTHON_LIB_PARENT_DIR_ARG}" 2>/dev/null); then + err "ERROR: Invalid path provided for python_lib_parent_dir: ${PYTHON_LIB_PARENT_DIR_ARG}" + exit 1 +fi +readonly PYTHON_LIB_PARENT_DIR + +if [[ ! -d "${PYTHON_LIB_PARENT_DIR}" ]]; then + err "ERROR: python_lib_parent_dir must be an existing directory: ${PYTHON_LIB_PARENT_DIR}" + exit 1 +fi + +readonly PYTHON_LIB_CLONE_PATH="${PYTHON_LIB_PARENT_DIR}/${PYTHON_LIB_NAME}" + +# Ensure python_lib_parent_dir is NOT under the project_dir +if [[ "${PYTHON_LIB_PARENT_DIR}" == "${PROJECT_DIR_ABS}"* ]]; then + err "ERROR: python_lib_parent_dir (${PYTHON_LIB_PARENT_DIR}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" + exit 1 +fi + +# --- Clone/Update Python Lib Repository --- +clone_or_update() { + local repo_url="$1" + local clone_path="$2" + local repo_name + + repo_name=$(basename "${clone_path}") + + echo "Managing repository ${repo_name} in ${clone_path}" + if [[ -d "${clone_path}/.git" ]]; then + echo "WARN: Directory ${clone_path} already exists and is a git repo. Skipping clone." + # Optionally, you could add git pull here: + # (cd "${clone_path}" && git pull) || err "Failed to pull updates for ${repo_name}" + elif [[ -d "${clone_path}" ]]; then + echo "WARN: Directory ${clone_path} exists but is not a git repo. Skipping." + else + echo "Cloning ${repo_url} into ${clone_path}" + if ! git clone "${repo_url}" "${clone_path}"; then + err "ERROR: Failed to clone ${repo_url}" + exit 1 + fi + echo "Successfully cloned ${repo_name}." + fi +} + +clone_or_update "${PYTHON_LIB_REPO_URL}" "${PYTHON_LIB_CLONE_PATH}" + +# --- Modify settings.json --- +readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" + +if [[ ! -f "${SETTINGS_FILE}" ]]; then + err "ERROR: Settings file not found: ${SETTINGS_FILE}" + exit 1 +fi + +echo "Updating ${SETTINGS_FILE} with context paths..." + +# Define the includeDirectories paths, ensuring they are full paths +readonly CONTEXT_PATH1="${PROJECT_DIR_ABS}/api_examples" +readonly CONTEXT_PATH2="${PROJECT_DIR_ABS}/saved_code" +if ! CONTEXT_PATH3=$(realpath "${PYTHON_LIB_CLONE_PATH}" 2>/dev/null); then + err "ERROR: Could not resolve absolute path for python lib clone: ${PYTHON_LIB_CLONE_PATH}" + exit 1 +fi +readonly CONTEXT_PATH3 + +# Use jq to modify the JSON file +TMP_SETTINGS_FILE="" +trap 'rm -f "${TMP_SETTINGS_FILE}"' EXIT # Cleanup tmp file on exit + +if ! TMP_SETTINGS_FILE=$(mktemp "${SETTINGS_FILE}.XXXXXX"); then + err "ERROR: Failed to create temporary file." + exit 1 +fi + +if ! jq \ + --arg path1 "${CONTEXT_PATH1}" \ + --arg path2 "${CONTEXT_PATH2}" \ + --arg path3 "${CONTEXT_PATH3}" \ + '.context.includeDirectories = [$path1, $path2, $path3]' \ + "${SETTINGS_FILE}" > "${TMP_SETTINGS_FILE}"; then + err "ERROR: jq command failed to update ${SETTINGS_FILE}" + exit 1 +fi + +# Replace the original file with the modified one +if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then + err "ERROR: Failed to move temporary file to ${SETTINGS_FILE}" + exit 1 +fi +trap - EXIT # Clear the trap as the file has been moved. + +echo "Successfully updated ${SETTINGS_FILE}" +echo "New contents of context.includeDirectories:" +jq '.context.includeDirectories' "${SETTINGS_FILE}" + +echo "Setup complete." + From 3f475ce103d6ddbde98c6c481978d9cede2f787a Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 20 Nov 2025 12:51:15 -0500 Subject: [PATCH 10/61] - Modified README - Added setup.sh --- README.md | 25 +++++++--- setup.sh | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 8 deletions(-) create mode 100755 setup.sh diff --git a/README.md b/README.md index 9ce5813..31357de 100644 --- a/README.md +++ b/README.md @@ -40,17 +40,28 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the 5. A local clone of the [google-ads-python](https://github.com/googleads/google-ads-python) client library. Clone this in a directory that is NOT under the Google Ads API Developer Assistant project directory. 6. Python >= 3.10 installed and available on your system PATH. -## Setup Guide +## Setup -1. **Install Gemini CLI:** Ensure that [Gemini CLI](https://github.com/google-gemini/gemini-cli) is installed. +1. **Install Gemini CLI:** Ensure that [Gemini CLI](https://github.com/google-gemini/gemini-cli) is installed. **Pro tip**: Before starting the installation read the [authentication](https://github.com/google-gemini/gemini-cli?tab=readme-ov-file#-authentication-options) section. -2. **Clone Google Ads Python Library:** Clone the [google-ads-python](https://github.com/googleads/google-ads-python) repository to a local directory (e.g., `$HOME/path/to/google-ads-python`) that is not under the Google Ads API Developer Assistant project directory. This provides context for code generation. +2. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. -3. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. +3. **Run setup.sh** + * Ensure that [ja](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. This is a json processor that allows us to write a valid settings.json. + * cd to /google-ads-api-developer-extension + * run ./setup.sh -4. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. +4. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. -5. **Set Context in Gemini:** The `gemini` command must be run from the root of the `google-ads-api-developer-assistant` project directory. Configure the context paths in `.gemini/settings.json`: +5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id=YOUR_CUSTOMER_ID` (e.g., `customer_id=1234567890`). You can then use prompts like *"Get campaigns for the default customer"*. + +### Manual Setup + +This replaces Step 3 above. + +a. **Clone Google Ads Python Library:** Clone the [google-ads-python](https://github.com/googleads/google-ads-python) repository to a local directory (e.g., `$HOME/path/to/google-ads-python`) that is not under the Google Ads API Developer Assistant project directory. This provides context for code generation. + +b. **Set Context in Gemini:** The `gemini` command must be run from the root of the `google-ads-api-developer-assistant` project directory. Configure the context paths in `.gemini/settings.json`: * Edit `/path/to/your/google-ads-api-developer-assistant/.gemini/settings.json`. * Add the **full absolute paths** to the `context.includeDirectories` array: * Your `google-ads-python` library clone. @@ -71,8 +82,6 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the ``` *Note: Replace the placeholder paths with the actual absolute paths on your system.* -6. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id=YOUR_CUSTOMER_ID` (e.g., `customer_id=1234567890`). You can then use prompts like *"Get campaigns for the default customer"*. - ## Usage Examples 1. **Start Gemini CLI:** diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..d661e3a --- /dev/null +++ b/setup.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# Exit on any error, and on undefined variables. +set -eu + +# Function to print errors to stderr +err() { + echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2 +} + +# --- Configuration --- +readonly PYTHON_LIB_REPO_URL="https://github.com/googleads/google-ads-python.git" +readonly PYTHON_LIB_NAME="google-ads-python" + +# --- Dependency Check --- +if ! command -v jq &> /dev/null; then + err "ERROR: jq is not installed. Please install it to continue." + err "See: https://jqlang.github.io/jq/download/" + exit 1 +fi +if ! command -v git &> /dev/null; then + err "ERROR: git is not installed. Please install it to continue." + exit 1 +fi + +# --- Argument Parsing --- +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + echo " Clones/updates the ${PYTHON_LIB_NAME} repository and modifies the settings file." >&2 + echo " This script must be run from within the google-ads-api-developer-assistant git repository." >&2 + echo " : Fully qualified path to an existing directory" >&2 + echo " where the '${PYTHON_LIB_NAME}' library will be cloned." >&2 + echo " This must NOT be under the project directory." >&2 + echo " Example: $0 /home/user/development/libs" >&2 + exit 1 +fi +readonly PYTHON_LIB_PARENT_DIR_ARG=$1 + +# --- Project Directory Resolution --- +# Determine the root directory of the current git repository. +if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then + err "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +fi +readonly PROJECT_DIR_ABS +echo "Detected project root: ${PROJECT_DIR_ABS}" + +# --- Python Lib Path Resolution and Validation --- +if ! PYTHON_LIB_PARENT_DIR=$(realpath "${PYTHON_LIB_PARENT_DIR_ARG}" 2>/dev/null); then + err "ERROR: Invalid path provided for python_lib_parent_dir: ${PYTHON_LIB_PARENT_DIR_ARG}" + exit 1 +fi +readonly PYTHON_LIB_PARENT_DIR + +if [[ ! -d "${PYTHON_LIB_PARENT_DIR}" ]]; then + err "ERROR: python_lib_parent_dir must be an existing directory: ${PYTHON_LIB_PARENT_DIR}" + exit 1 +fi + +readonly PYTHON_LIB_CLONE_PATH="${PYTHON_LIB_PARENT_DIR}/${PYTHON_LIB_NAME}" + +# Ensure python_lib_parent_dir is NOT under the project_dir +if [[ "${PYTHON_LIB_PARENT_DIR}" == "${PROJECT_DIR_ABS}"* ]]; then + err "ERROR: python_lib_parent_dir (${PYTHON_LIB_PARENT_DIR}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" + exit 1 +fi + +# --- Clone/Update Python Lib Repository --- +clone_or_update() { + local repo_url="$1" + local clone_path="$2" + local repo_name + + repo_name=$(basename "${clone_path}") + + echo "Managing repository ${repo_name} in ${clone_path}" + if [[ -d "${clone_path}/.git" ]]; then + echo "WARN: Directory ${clone_path} already exists and is a git repo. Skipping clone." + # Optionally, you could add git pull here: + # (cd "${clone_path}" && git pull) || err "Failed to pull updates for ${repo_name}" + elif [[ -d "${clone_path}" ]]; then + echo "WARN: Directory ${clone_path} exists but is not a git repo. Skipping." + else + echo "Cloning ${repo_url} into ${clone_path}" + if ! git clone "${repo_url}" "${clone_path}"; then + err "ERROR: Failed to clone ${repo_url}" + exit 1 + fi + echo "Successfully cloned ${repo_name}." + fi +} + +clone_or_update "${PYTHON_LIB_REPO_URL}" "${PYTHON_LIB_CLONE_PATH}" + +# --- Modify settings.json --- +readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" + +if [[ ! -f "${SETTINGS_FILE}" ]]; then + err "ERROR: Settings file not found: ${SETTINGS_FILE}" + exit 1 +fi + +echo "Updating ${SETTINGS_FILE} with context paths..." + +# Define the includeDirectories paths, ensuring they are full paths +readonly CONTEXT_PATH1="${PROJECT_DIR_ABS}/api_examples" +readonly CONTEXT_PATH2="${PROJECT_DIR_ABS}/saved_code" +if ! CONTEXT_PATH3=$(realpath "${PYTHON_LIB_CLONE_PATH}" 2>/dev/null); then + err "ERROR: Could not resolve absolute path for python lib clone: ${PYTHON_LIB_CLONE_PATH}" + exit 1 +fi +readonly CONTEXT_PATH3 + +# Use jq to modify the JSON file +TMP_SETTINGS_FILE="" +trap 'rm -f "${TMP_SETTINGS_FILE}"' EXIT # Cleanup tmp file on exit + +if ! TMP_SETTINGS_FILE=$(mktemp "${SETTINGS_FILE}.XXXXXX"); then + err "ERROR: Failed to create temporary file." + exit 1 +fi + +if ! jq \ + --arg path1 "${CONTEXT_PATH1}" \ + --arg path2 "${CONTEXT_PATH2}" \ + --arg path3 "${CONTEXT_PATH3}" \ + '.context.includeDirectories = [$path1, $path2, $path3]' \ + "${SETTINGS_FILE}" > "${TMP_SETTINGS_FILE}"; then + err "ERROR: jq command failed to update ${SETTINGS_FILE}" + exit 1 +fi + +# Replace the original file with the modified one +if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then + err "ERROR: Failed to move temporary file to ${SETTINGS_FILE}" + exit 1 +fi +trap - EXIT # Clear the trap as the file has been moved. + +echo "Successfully updated ${SETTINGS_FILE}" +echo "New contents of context.includeDirectories:" +jq '.context.includeDirectories' "${SETTINGS_FILE}" + +echo "Setup complete." + From 5f8eec799f6b8dd512dc5f47589dfe8f14a929d8 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 3 Dec 2025 13:41:37 -0500 Subject: [PATCH 11/61] Add additional constrants to ensure it uses latest version of the API unless otherwise stated by the user and constrains it from even attempting to issue mutate requests. --- GEMINI.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 66264ef..155678a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -30,10 +30,18 @@ This document outlines mandatory operational guidelines, constraints, and best p * Do not guarantee code will work without testing; remind users to test generated code in a development environment. * Do not use humorous or overly casual status messages. * Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). +* Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). #### 1.2. API Versioning -ALWAYS dynamically determine the latest Google Ads API version by performing a web search at the start of any task involving API versioning. Use this latest version unless explicitly specified otherwise by the user. Explicitly state the API version being used in generated code or when discussing API interactions. All generated Python code and GAQL queries MUST import and utilize this determined latest version (e.g., `google.ads.googleads.vXX`). +**MANDATORY FIRST STEP:** At the beginning of any task that involves the Google Ads API, you **MUST** perform a `google_web_search` to determine the latest stable version of the Google Ads API. + +* **Search Query:** Use a query like "latest google ads api version". +* **Verification:** The search result should point to the official Google Ads API documentation on `developers.google.com`. +* **Usage:** This version **MUST** be used for all subsequent API calls, code generation, and documentation references for the remainder of the task. For example, all Python imports and GAQL queries must use this determined version (e.g., `google.ads.googleads.vXX`). +* **State the version:** You should explicitly state the API version you are using to the user. + +This is a strict operational requirement. Failure to adhere to this rule will result in incorrect and outdated responses. --- @@ -62,7 +70,16 @@ For consistent execution and dependency management: ### 3. API INTERACTION & WORKFLOWS -#### 3.1. GOOGLE ADS API BEST PRACTICES +#### 3.1. API Versioning + +**MANDATORY FIRST STEP:** At the beginning of any task that involves the Google Ads API, you **MUST** perform a `google_web_search` to determine the latest stable version of the Google Ads API. + +* **Search Query:** Use a query like "latest google ads api version". +* **Verification:** The search result should point to the official Google Ads API documentation on `developers.google.com`. +* **Usage:** This version **MUST** be used for all subsequent API calls, code generation, and documentation references for the remainder of the task. For example, all Python imports and GAQL queries must use this determined version (e.g., `google.ads.googleads.vXX`). +* **State the version:** You should explicitly state the API version you are using to the user. + +This is a strict operational requirement. Failure to adhere to this rule will result in incorrect and outdated responses. * **Search Operations:** Use `SearchGoogleAdsStream` objects (e.g., `SearchGoogleAdsStreamRequest`, NOT `SearchGoogleAdsRequest`). * **Change History:** Use `change_status` resources. @@ -157,10 +174,12 @@ The AI Assistant uses ONLY the following tools: * **`run_shell_command`** * **Description:** Executes shell commands. * **Policy:** + * **Google Ads API Mutate Prohibition:** Before executing any Python script that interacts with the Google Ads API, you MUST first inspect the script's content. If the script contains any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.), you MUST NOT execute the script. You must instead explain to the user that you have created the script but cannot run it due to the explicit prohibition on mutate operations. * For `ModuleNotFoundError` in Python, attempt `pip install `. If error persists, specify Python interpreter path. * Automatically attempt `pip install ` for `ModuleNotFoundError`. * Explain file system modifying commands BEFORE execution. * Retrieve script parameters (e.g., `customer_id`) from `customer_id.text`; NEVER ask the user. + * **Displaying Non-Executable Commands:** When providing an example command that should *not* be executed by the assistant (such as a mutate operation or a template for the user to fill in), you MUST format it as a code block within a text response. The `run_shell_command` tool MUST NOT be used to display or wrap non-executable commands. * **`write_file`** * **Description:** Writes content to a file. From 88d50a43ce0c4f2728c8bbbd795050cd48b3f48e Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 4 Dec 2025 11:11:53 -0500 Subject: [PATCH 12/61] Add manifest registration --- setup.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/setup.sh b/setup.sh index 4916d8f..07e1665 100755 --- a/setup.sh +++ b/setup.sh @@ -149,6 +149,11 @@ if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then err "ERROR: Failed to move temporary file to ${SETTINGS_FILE}" exit 1 fi + +# Register the extension with the gemini extensions manifest +echo "Registering with the gemini extensions manifest" +gemini extensions install "${PROJECT_DIR_ABS}" + trap - EXIT # Clear the trap as the file has been moved. echo "Successfully updated ${SETTINGS_FILE}" From 8481b43af659626a8af05c78beb086711afcf497 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 4 Dec 2025 12:34:09 -0500 Subject: [PATCH 13/61] Corrected project directory name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 23327bd..12ec5d0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the 3. **Run setup.sh** * Ensure that [jq](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. This is a json processor that allows us to write a valid settings.json. - * cd to /google-ads-api-developer-extension + * cd to /google-ads-api-developer-assistant * run ./setup.sh 4. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. From 71378c90ae168e262668f259f0fe2ccf91d4e84e Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 4 Dec 2025 12:51:56 -0500 Subject: [PATCH 14/61] Updated setup and added update process --- setup.sh | 9 ++++++ update.sh | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100755 update.sh diff --git a/setup.sh b/setup.sh index 07e1665..ef4192f 100755 --- a/setup.sh +++ b/setup.sh @@ -14,6 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +# Description: +# This script initializes the development environment for the Google Ads API Developer Assistant. +# It performs the following steps: +# 1. Verifies that required tools (jq, git) are installed. +# 2. Clones or updates the 'google-ads-python' repository into a specified directory. +# 3. Updates the '.gemini/settings.json' file to include the project's API examples, +# saved code, and the cloned Python library in the context. +# 4. Registers the project as a Gemini extension. + # Exit on any error, and on undefined variables. set -eu diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..8297626 --- /dev/null +++ b/update.sh @@ -0,0 +1,94 @@ +#!/bin/bash + +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. + +# Description: +# This script updates the Google Ads API Developer Assistant and its dependencies. +# It performs the following steps: +# 1. Updates the 'google-ads-api-developer-assistant' repository (git pull). +# 2. Reads '.gemini/settings.json' to locate the 'google-ads-python' repository. +# 3. Updates the 'google-ads-python' repository (git pull). + +# Exit on any error, and on undefined variables. +set -eu + +# Function to print errors to stderr +err() { + echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2 +} + +# --- Dependency Check --- +if ! command -v jq &> /dev/null; then + err "ERROR: jq is not installed. Please install it to continue." + err "See: https://jqlang.github.io/jq/download/" + exit 1 +fi +if ! command -v git &> /dev/null; then + err "ERROR: git is not installed. Please install it to continue." + exit 1 +fi + +# --- Project Directory Resolution --- +# Determine the root directory of the current git repository. +if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then + err "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +fi +readonly PROJECT_DIR_ABS +echo "Detected project root: ${PROJECT_DIR_ABS}" + +# --- Update Assistant Repo --- +echo "Updating google-ads-api-developer-assistant..." +if ! git pull; then + err "ERROR: Failed to update google-ads-api-developer-assistant." + exit 1 +fi +echo "Successfully updated google-ads-api-developer-assistant." + +# --- Locate and Update Python Lib --- +readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" + +if [[ ! -f "${SETTINGS_FILE}" ]]; then + err "ERROR: Settings file not found: ${SETTINGS_FILE}" + err "Please run setup.sh first." + exit 1 +fi + +echo "Reading ${SETTINGS_FILE} to find google-ads-python..." + +# Extract the path ending with 'google-ads-python' from includeDirectories +PYTHON_LIB_PATH=$(jq -r '.context.includeDirectories[] | select(endswith("google-ads-python"))' "${SETTINGS_FILE}") + +if [[ -z "${PYTHON_LIB_PATH}" ]]; then + err "ERROR: Could not find google-ads-python path in ${SETTINGS_FILE}." + exit 1 +fi + +echo "Found google-ads-python at: ${PYTHON_LIB_PATH}" + +if [[ ! -d "${PYTHON_LIB_PATH}" ]]; then + err "ERROR: Directory not found: ${PYTHON_LIB_PATH}" + exit 1 +fi + +echo "Updating google-ads-python..." +# Use a subshell to change directory and pull, so we don't affect the current script's CWD +if ! (cd "${PYTHON_LIB_PATH}" && git pull); then + err "ERROR: Failed to update google-ads-python at ${PYTHON_LIB_PATH}" + exit 1 +fi + +echo "Successfully updated google-ads-python." +echo "Update complete." From 9b654ea8671b55a7fb217efeea03610ceba60726 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 4 Dec 2025 14:02:19 -0500 Subject: [PATCH 15/61] - Ask user to verify api version sinc Gemini still makes mistakes. - Update setup to make python library path optional --- GEMINI.md | 2 +- setup.sh | 42 +++++++++++++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 155678a..2723786 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -34,7 +34,7 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 1.2. API Versioning -**MANDATORY FIRST STEP:** At the beginning of any task that involves the Google Ads API, you **MUST** perform a `google_web_search` to determine the latest stable version of the Google Ads API. +**MANDATORY FIRST STEP:** At the beginning of any task that involves the Google Ads API, you **MUST** perform a `google_web_search` to determine the latest stable version of the Google Ads API. When you obtains the version show it to the user and ask if this is correct. * **Search Query:** Use a query like "latest google ads api version". * **Verification:** The search result should point to the official Google Ads API documentation on `developers.google.com`. diff --git a/setup.sh b/setup.sh index ef4192f..7f326a1 100755 --- a/setup.sh +++ b/setup.sh @@ -47,18 +47,22 @@ if ! command -v git &> /dev/null; then fi # --- Argument Parsing --- -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 +if [[ $# -eq 0 ]]; then + readonly PYTHON_LIB_PARENT_DIR_ARG="${HOME}/python/src" + echo "No python_lib_parent_dir provided. Using default: ${PYTHON_LIB_PARENT_DIR_ARG}" +elif [[ $# -eq 1 ]]; then + readonly PYTHON_LIB_PARENT_DIR_ARG="$1" +else + echo "Usage: $0 [python_lib_parent_dir]" >&2 echo " Clones/updates the ${PYTHON_LIB_NAME} repository and modifies the settings file." >&2 echo " This script must be run from within the google-ads-api-developer-assistant git repository." >&2 - echo " : Fully qualified path to an existing directory" >&2 + echo " [python_lib_parent_dir]: Optional. Fully qualified path to an existing directory" >&2 echo " where the '${PYTHON_LIB_NAME}' library will be cloned." >&2 + echo " Defaults to: \${HOME}/python/src" >&2 echo " This must NOT be under the project directory." >&2 echo " Example: $0 /home/user/development/libs" >&2 exit 1 fi -readonly PYTHON_LIB_PARENT_DIR_ARG=$1 - # --- Project Directory Resolution --- # Determine the root directory of the current git repository. if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then @@ -69,17 +73,20 @@ readonly PROJECT_DIR_ABS echo "Detected project root: ${PROJECT_DIR_ABS}" # --- Python Lib Path Resolution and Validation --- +if [[ ! -d "${PYTHON_LIB_PARENT_DIR_ARG}" ]]; then + echo "Directory ${PYTHON_LIB_PARENT_DIR_ARG} does not exist. Creating it..." + if ! mkdir -p "${PYTHON_LIB_PARENT_DIR_ARG}"; then + err "ERROR: Failed to create directory: ${PYTHON_LIB_PARENT_DIR_ARG}" + exit 1 + fi +fi + if ! PYTHON_LIB_PARENT_DIR=$(realpath "${PYTHON_LIB_PARENT_DIR_ARG}" 2>/dev/null); then err "ERROR: Invalid path provided for python_lib_parent_dir: ${PYTHON_LIB_PARENT_DIR_ARG}" exit 1 fi readonly PYTHON_LIB_PARENT_DIR -if [[ ! -d "${PYTHON_LIB_PARENT_DIR}" ]]; then - err "ERROR: python_lib_parent_dir must be an existing directory: ${PYTHON_LIB_PARENT_DIR}" - exit 1 -fi - readonly PYTHON_LIB_CLONE_PATH="${PYTHON_LIB_PARENT_DIR}/${PYTHON_LIB_NAME}" # Ensure python_lib_parent_dir is NOT under the project_dir @@ -161,7 +168,20 @@ fi # Register the extension with the gemini extensions manifest echo "Registering with the gemini extensions manifest" -gemini extensions install "${PROJECT_DIR_ABS}" +if ! INSTALL_OUTPUT=$(gemini extensions install "${PROJECT_DIR_ABS}" 2>&1); then + if [[ "${INSTALL_OUTPUT}" == *"already installed"* ]]; then + echo "Extension already installed. Reinstalling..." + # We ignore the uninstall error just in case, though it should exist if we got "already installed" + gemini extensions uninstall "google-ads-api-developer-assistant" || true + gemini extensions install "${PROJECT_DIR_ABS}" + else + echo "${INSTALL_OUTPUT}" >&2 + err "ERROR: Failed to install extension." + exit 1 + fi +else + echo "${INSTALL_OUTPUT}" +fi trap - EXIT # Clear the trap as the file has been moved. From 49a20b1a236cbefde44d102a95045044c71d7513 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 5 Dec 2025 08:49:27 -0500 Subject: [PATCH 16/61] - Added maintenance section to README.md - Added ChangeLog --- ChangeLog | 2 ++ README.md | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 ChangeLog diff --git a/ChangeLog b/ChangeLog new file mode 100644 index 0000000..504097f --- /dev/null +++ b/ChangeLog @@ -0,0 +1,2 @@ +* 1.0 +- Release of verions 1.0 diff --git a/README.md b/README.md index 12ec5d0..3dd1638 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,17 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of ## Known Quirks -* The underlying model may have been trained on an older API version. It might occasionally generate code with deprecated fields. Execution errors often provide feedback that allows Gemini CLI to self-correct on the next attempt, using the context from the `google-ads-python` client library. +* The underlying model may have been trained on an older API version. It +might occasionally generate code with deprecated fields. Execution errors +often provide feedback that allows Gemini CLI to self-correct on the next +attempt, using the context from the `google-ads-python` client library. + +## Maintenance + +We will release updates to both this extension and the google-ads-python client +library. To ensure you are using the latest versions, run `update.sh` when a new +version of the API is published or a new version of the client library is +released. ## Contributing From 61eb4064041415badcd179fc2946a5262d932e41 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 5 Dec 2025 09:46:48 -0500 Subject: [PATCH 17/61] Constraints of using latest api version by default. If it is not found or the user does not approve the response, it is a hard stop. --- GEMINI.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 2723786..0de7597 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -32,16 +32,16 @@ This document outlines mandatory operational guidelines, constraints, and best p * Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). * Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). -#### 1.2. API Versioning +#### 1.2. API Versioning and Pre-Task Validation -**MANDATORY FIRST STEP:** At the beginning of any task that involves the Google Ads API, you **MUST** perform a `google_web_search` to determine the latest stable version of the Google Ads API. When you obtains the version show it to the user and ask if this is correct. +**ABSOLUTE MANDATORY FIRST STEP:** Before initiating *any* task, query, or code generation related to the Google Ads API, you **MUST** validate the API version. This is a non-negotiable protocol. -* **Search Query:** Use a query like "latest google ads api version". -* **Verification:** The search result should point to the official Google Ads API documentation on `developers.google.com`. -* **Usage:** This version **MUST** be used for all subsequent API calls, code generation, and documentation references for the remainder of the task. For example, all Python imports and GAQL queries must use this determined version (e.g., `google.ads.googleads.vXX`). -* **State the version:** You should explicitly state the API version you are using to the user. +1. **EXECUTE SEARCH:** Use the `google_web_search` tool with the query: `latest stable google ads api version`. +2. **VERIFY SOURCE:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). +3. **CONFIRM WITH USER:** State the version you have found (e.g., "The latest stable version of the Google Ads API is vXX.") and ask the user for confirmation: "Is it OK to proceed using this version?". +4. **AWAIT APPROVAL:** **DO NOT** proceed with any other tools or actions until the user confirms the version is correct. -This is a strict operational requirement. Failure to adhere to this rule will result in incorrect and outdated responses. +**FAILURE TO FOLLOW THIS PROTOCOL IS A CRITICAL ERROR.** All subsequent API calls, code generation, and GAQL queries **MUST** use the user-confirmed version number (e.g., `google.ads.googleads.vXX`). --- @@ -70,16 +70,16 @@ For consistent execution and dependency management: ### 3. API INTERACTION & WORKFLOWS -#### 3.1. API Versioning +#### 3.1. API Versioning and Pre-Task Validation -**MANDATORY FIRST STEP:** At the beginning of any task that involves the Google Ads API, you **MUST** perform a `google_web_search` to determine the latest stable version of the Google Ads API. +**ABSOLUTE MANDATORY FIRST STEP:** Before initiating *any* task, query, or code generation related to the Google Ads API, you **MUST** validate the API version. This is a non-negotiable protocol. -* **Search Query:** Use a query like "latest google ads api version". -* **Verification:** The search result should point to the official Google Ads API documentation on `developers.google.com`. -* **Usage:** This version **MUST** be used for all subsequent API calls, code generation, and documentation references for the remainder of the task. For example, all Python imports and GAQL queries must use this determined version (e.g., `google.ads.googleads.vXX`). -* **State the version:** You should explicitly state the API version you are using to the user. +1. **EXECUTE SEARCH:** Use the `google_web_search` tool with the query: `latest stable google ads api version`. +2. **VERIFY SOURCE:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). +3. **CONFIRM WITH USER:** State the version you have found (e.g., "The latest stable version of the Google Ads API is vXX.") and ask the user for confirmation: "Is it OK to proceed using this version?". +4. **AWAIT APPROVAL:** **DO NOT** proceed with any other tools or actions until the user confirms the version is correct. -This is a strict operational requirement. Failure to adhere to this rule will result in incorrect and outdated responses. +**FAILURE TO FOLLOW THIS PROTOCOL IS A CRITICAL ERROR.** All subsequent API calls, code generation, and GAQL queries **MUST** use the user-confirmed version number (e.g., `google.ads.googleads.vXX`). * **Search Operations:** Use `SearchGoogleAdsStream` objects (e.g., `SearchGoogleAdsStreamRequest`, NOT `SearchGoogleAdsRequest`). * **Change History:** Use `change_status` resources. From 9c3a11cbd8b02af30a31e224c639389abdafb2a6 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 5 Dec 2025 09:57:57 -0500 Subject: [PATCH 18/61] Add example for conversion upload summary repo. --- api_examples/get_conversion_upload_summary.py | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 api_examples/get_conversion_upload_summary.py diff --git a/api_examples/get_conversion_upload_summary.py b/api_examples/get_conversion_upload_summary.py new file mode 100644 index 0000000..2e79930 --- /dev/null +++ b/api_examples/get_conversion_upload_summary.py @@ -0,0 +1,94 @@ +import argparse +import sys +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + + +def main(client: GoogleAdsClient, customer_id: str) -> None: + ga_service = client.get_service("GoogleAdsService") + + query = """ + SELECT + offline_conversion_upload_client_summary.alerts, + offline_conversion_upload_client_summary.client, + offline_conversion_upload_client_summary.daily_summaries, + offline_conversion_upload_client_summary.job_summaries, + offline_conversion_upload_client_summary.last_upload_date_time, + offline_conversion_upload_client_summary.resource_name, + offline_conversion_upload_client_summary.status, + offline_conversion_upload_client_summary.success_rate, + offline_conversion_upload_client_summary.successful_event_count, + offline_conversion_upload_client_summary.total_event_count + FROM + offline_conversion_upload_client_summary + """ + + try: + stream = ga_service.search_stream(customer_id=customer_id, query=query) + + print("Offline conversion upload client summary:") + for batch in stream: + for row in batch.results: + summary = row.offline_conversion_upload_client_summary + print(f"Resource Name: {summary.resource_name}") + print(f"Status: {summary.status}") + print(f"Total Event Count: {summary.total_event_count}") + print(f"Successful Event Count: {summary.successful_event_count}") + print(f"Success Rate: {summary.success_rate}") + print(f"Last Upload Time: {summary.last_upload_date_time}") + print("Alerts:") + for alert in summary.alerts: + print(f" Error: {alert.error.conversion_upload_error.name}") + print("Daily Summaries:") + for daily_summary in summary.daily_summaries: + print(f" Date: {daily_summary.upload_date}") + print(f" Successful Count: {daily_summary.successful_count}") + print(f" Failed Count: {daily_summary.failed_count}") + print("Job Summaries:") + for job_summary in summary.job_summaries: + print(f" Job ID: {job_summary.job_id}") + print(f" Successful Count: {job_summary.successful_count}") + print(f" Failed Count: {job_summary.failed_count}") + print(f" Upload Time: {job_summary.upload_date}") + + except GoogleAdsException as ex: + print( + f'Request with ID "{ex.request_id}" failed with status ' + f'"{ex.error.code().name}" and includes the following errors:' + ) + for error in ex.failure.errors: + print(f'\tError with message "{error.message}".') + if error.location: + for field_path_element in error.location.field_path_elements: + print(f"\t\tOn field: {field_path_element.field_name}") + sys.exit(1) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Get offline conversion upload client summary." + ) + parser.add_argument( + "-c", + "--customer_id", + type=str, + required=True, + help="The Google Ads customer ID.", + ) + args = parser.parse_args() + + googleads_client = GoogleAdsClient.load_from_storage(version="v22") + + try: + main(googleads_client, args.customer_id) + except GoogleAdsException as ex: + print( + f'Request with ID "{ex.request_id}" failed with status ' + f'"{ex.error.code().name}" and includes the following errors:' + ) + for error in ex.failure.errors: + print(f'\tError with message "{error.message}".') + if error.location: + for field_path_element in error.location.field_path_elements: + print(f"\t\tOn field: {field_path_element.field_name}") + sys.exit(1) From f8c6225d738c557a70e02cd437618d5edda20500 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 5 Dec 2025 10:32:20 -0500 Subject: [PATCH 19/61] Increased constraints to use latest version by default. Added example for upload summary report. Ensured all examples have the license heading. --- GEMINI.md | 14 +- api_examples/get_conversion_upload_summary.py | 126 +++++++++++++++--- .../remove_automatically_created_assets.py | 17 ++- 3 files changed, 123 insertions(+), 34 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 0de7597..cd90476 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -40,6 +40,9 @@ This document outlines mandatory operational guidelines, constraints, and best p 2. **VERIFY SOURCE:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). 3. **CONFIRM WITH USER:** State the version you have found (e.g., "The latest stable version of the Google Ads API is vXX.") and ask the user for confirmation: "Is it OK to proceed using this version?". 4. **AWAIT APPROVAL:** **DO NOT** proceed with any other tools or actions until the user confirms the version is correct. +5. **HANDLE REJECTION:** If the user states the version is incorrect, you MUST return to step 1 and perform a new search. Do not suggest the same version again. +6. **SAVE CONFIRMED VERSION:** Once the user provides explicit confirmation (e.g., "yes", "ok"), you **MUST** use the `save_memory` tool to remember the confirmed version. The fact should be: "The user-confirmed Google Ads API version is vXX." +7. **USE SAVED VERSION:** For all subsequent operations in this session, you MUST use the version stored in your memory. Do not perform a new web search for the version unless explicitly asked. **FAILURE TO FOLLOW THIS PROTOCOL IS A CRITICAL ERROR.** All subsequent API calls, code generation, and GAQL queries **MUST** use the user-confirmed version number (e.g., `google.ads.googleads.vXX`). @@ -70,17 +73,6 @@ For consistent execution and dependency management: ### 3. API INTERACTION & WORKFLOWS -#### 3.1. API Versioning and Pre-Task Validation - -**ABSOLUTE MANDATORY FIRST STEP:** Before initiating *any* task, query, or code generation related to the Google Ads API, you **MUST** validate the API version. This is a non-negotiable protocol. - -1. **EXECUTE SEARCH:** Use the `google_web_search` tool with the query: `latest stable google ads api version`. -2. **VERIFY SOURCE:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM WITH USER:** State the version you have found (e.g., "The latest stable version of the Google Ads API is vXX.") and ask the user for confirmation: "Is it OK to proceed using this version?". -4. **AWAIT APPROVAL:** **DO NOT** proceed with any other tools or actions until the user confirms the version is correct. - -**FAILURE TO FOLLOW THIS PROTOCOL IS A CRITICAL ERROR.** All subsequent API calls, code generation, and GAQL queries **MUST** use the user-confirmed version number (e.g., `google.ads.googleads.vXX`). - * **Search Operations:** Use `SearchGoogleAdsStream` objects (e.g., `SearchGoogleAdsStreamRequest`, NOT `SearchGoogleAdsRequest`). * **Change History:** Use `change_status` resources. * **AI Max for Search Campaigns:** Set `Campaign.ai_max_setting.enable_ai_max = True`. diff --git a/api_examples/get_conversion_upload_summary.py b/api_examples/get_conversion_upload_summary.py index 2e79930..511f398 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -1,3 +1,17 @@ +# Copyright 2024 Google LLC +# +# 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 +# +# https://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. + import argparse import sys from google.ads.googleads.client import GoogleAdsClient @@ -7,7 +21,8 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - query = """ + # Query for offline_conversion_upload_client_summary + client_summary_query = """ SELECT offline_conversion_upload_client_summary.alerts, offline_conversion_upload_client_summary.client, @@ -23,33 +38,97 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: offline_conversion_upload_client_summary """ - try: - stream = ga_service.search_stream(customer_id=customer_id, query=query) + # Query for offline_conversion_upload_conversion_action_summary + conversion_action_summary_query = """ + SELECT + offline_conversion_upload_conversion_action_summary.alerts, + offline_conversion_upload_conversion_action_summary.conversion_action_name, + offline_conversion_upload_conversion_action_summary.daily_summaries, + offline_conversion_upload_conversion_action_summary.job_summaries, + offline_conversion_upload_conversion_action_summary.resource_name, + offline_conversion_upload_conversion_action_summary.successful_event_count, + offline_conversion_upload_conversion_action_summary.status, + offline_conversion_upload_conversion_action_summary.total_event_count + FROM + offline_conversion_upload_conversion_action_summary + """ - print("Offline conversion upload client summary:") - for batch in stream: + try: + # Fetch and print client summary + client_stream = ga_service.search_stream( + customer_id=customer_id, query=client_summary_query + ) + print("=" * 80) + print("Offline Conversion Upload Client Summary:") + print("=" * 80) + for batch in client_stream: for row in batch.results: summary = row.offline_conversion_upload_client_summary print(f"Resource Name: {summary.resource_name}") - print(f"Status: {summary.status}") + print(f"Status: {summary.status.name}") print(f"Total Event Count: {summary.total_event_count}") print(f"Successful Event Count: {summary.successful_event_count}") print(f"Success Rate: {summary.success_rate}") print(f"Last Upload Time: {summary.last_upload_date_time}") - print("Alerts:") - for alert in summary.alerts: - print(f" Error: {alert.error.conversion_upload_error.name}") - print("Daily Summaries:") - for daily_summary in summary.daily_summaries: - print(f" Date: {daily_summary.upload_date}") - print(f" Successful Count: {daily_summary.successful_count}") - print(f" Failed Count: {daily_summary.failed_count}") - print("Job Summaries:") - for job_summary in summary.job_summaries: - print(f" Job ID: {job_summary.job_id}") - print(f" Successful Count: {job_summary.successful_count}") - print(f" Failed Count: {job_summary.failed_count}") - print(f" Upload Time: {job_summary.upload_date}") + if summary.alerts: + print("Alerts:") + for alert in summary.alerts: + print( + f" Error Code: {alert.error.conversion_upload_error.name}" + ) + if summary.daily_summaries: + print("Daily Summaries:") + for daily_summary in summary.daily_summaries: + print(f" Date: {daily_summary.upload_date}") + print(f" Successful Count: {daily_summary.successful_count}") + print(f" Failed Count: {daily_summary.failed_count}") + if summary.job_summaries: + print("Job Summaries:") + for job_summary in summary.job_summaries: + print(f" Job ID: {job_summary.job_id}") + print(f" Successful Count: {job_summary.successful_count}") + print(f" Failed Count: {job_summary.failed_count}") + print(f" Upload Time: {job_summary.upload_date}") + print("-" * 80) + + # Fetch and print conversion action summary + action_stream = ga_service.search_stream( + customer_id=customer_id, query=conversion_action_summary_query + ) + print("\n" + "=" * 80) + print("Offline Conversion Upload Conversion Action Summary:") + print("=" * 80) + for batch in action_stream: + for row in batch.results: + summary = row.offline_conversion_upload_conversion_action_summary + print(f"Resource Name: {summary.resource_name}") + print(f"Conversion Action Name: {summary.conversion_action_name}") + print(f"Status: {summary.status.name}") + print(f"Total Event Count: {summary.total_event_count}") + print(f"Successful Event Count: {summary.successful_event_count}") + print( + f"Failed Event Count: {summary.total_event_count - summary.successful_event_count}" + ) + if summary.alerts: + print("Alerts:") + for alert in summary.alerts: + print( + f" Error Code: {alert.error.conversion_upload_error.name}" + ) + if summary.daily_summaries: + print("Daily Summaries:") + for daily_summary in summary.daily_summaries: + print(f" Date: {daily_summary.upload_date}") + print(f" Successful Count: {daily_summary.successful_count}") + print(f" Failed Count: {daily_summary.failed_count}") + if summary.job_summaries: + print("Job Summaries:") + for job_summary in summary.job_summaries: + print(f" Job ID: {job_summary.job_id}") + print(f" Successful Count: {job_summary.successful_count}") + print(f" Failed Count: {job_summary.failed_count}") + print(f" Upload Time: {job_summary.upload_date}") + print("-" * 80) except GoogleAdsException as ex: print( @@ -66,7 +145,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser( - description="Get offline conversion upload client summary." + description="Get offline conversion upload client and conversion action summaries." ) parser.add_argument( "-c", @@ -77,6 +156,10 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: ) args = parser.parse_args() + # The GoogleAdsClient.load_from_storage method takes the API version as a parameter. + # The version parameter is a string that specifies the API version to be used. + # For example, "v22". + # This value has been user-confirmed and saved to the agent's memory. googleads_client = GoogleAdsClient.load_from_storage(version="v22") try: @@ -92,3 +175,4 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: for field_path_element in error.location.field_path_elements: print(f"\t\tOn field: {field_path_element.field_name}") sys.exit(1) + diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index b5a7e37..971f7d2 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -1,3 +1,17 @@ +# Copyright 2024 Google LLC +# +# 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 +# +# https://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. + import argparse import sys @@ -122,5 +136,4 @@ def main( args.campaign_id, args.asset_resource_name, args.field_type, - ) - + ) \ No newline at end of file From 5d18cb3e80c247eb9797cf4935c4cbc9dd0a1c20 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 10 Dec 2025 09:47:44 -0500 Subject: [PATCH 20/61] README.md: Add section on mutate prohibitions --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 3dd1638..7aaaf88 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,14 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of * `saved_csv/`: Stores CSV files exported from API results. * `customer_id.txt`: (Optional) Stores the default customer ID. +## Mutate Operations + +* Assistant's Job: To help you by writing the code that would modify things (e.g., create or update a campaign, add a user). + +* Your Job: To review that code for accuracy, decide if you want to run it, and then execute it yourself outside of the Assistant. The Assistant won't run it for you. + +* See the TOOLING & EXECUTION PROTOCOL section in GEMINI.md for details. + ## Known Quirks * The underlying model may have been trained on an older API version. It From d59061b72c471b0c221a29502ced8db9541c5e72 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 10 Dec 2025 10:23:31 -0500 Subject: [PATCH 21/61] Changed customer id assignment operator to colon for consistency. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7aaaf88..c1189e6 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the 4. **Configure Credentials:** Make sure your [google-ads.yaml](https://github.com/googleads/google-ads-python/blob/main/google-ads.yaml) file with API credentials is in your `$HOME` directory. -5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id=YOUR_CUSTOMER_ID` (e.g., `customer_id=1234567890`). You can then use prompts like *"Get campaigns for the default customer"*. +5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id:YOUR_CUSTOMER_ID` (e.g., `customer_id: 1234567890`). You can then use prompts like *"Get campaigns for the default customer"*. ### Manual Setup @@ -112,7 +112,7 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of ## Mutate Operations -* Assistant's Job: To help you by writing the code that would modify things (e.g., create or update a campaign, add a user). +* Assistant's Job: To help you by writing the code that would modify things (e.g., create or update a campaign, add a user), but NOT to execute this code. * Your Job: To review that code for accuracy, decide if you want to run it, and then execute it yourself outside of the Assistant. The Assistant won't run it for you. From f0f90de283b48c395b0d087946d0aec06b2496bc Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 15 Dec 2025 17:52:43 -0500 Subject: [PATCH 22/61] Modified context file to generalize for other languages. --- ..._ai_max_reports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 15018 bytes ..._capture_gclids.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8530 bytes ...version_reports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 17636 bytes ...aign_experiment.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8427 bytes ...ved_ads_reports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 17314 bytes ...bid_simulations.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7892 bytes ...ign_shared_sets.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 6985 bytes ..._change_history.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7101 bytes ...get_geo_targets.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8929 bytes ...ccessible_users.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 5383 bytes ..._pmax_campaigns.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 6740 bytes ...oader_optimized.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8900 bytes ..._created_assets.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 9971 bytes ..._with_user_list.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7780 bytes 14 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_list_pmax_campaigns.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_remove_automatically_created_assets.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc diff --git a/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a4fafd771d804f8a91bc3656868b71adcc8ea3ec GIT binary patch literal 15018 zcmeHOYit|YbsoNl)8?aEs2tW0|o*#u3FWo9XonHgC& zH&{4H(dD9P*4u5dE>ffp5F}bOKOD3`{Byer+C_ee6%!Iu1u@z!(4a-3UAuL$Xn~${ zhciP`BU`&k{uH^C?)%=k_s+TJeD~Zlhukg)1+MAi=J<&Lii%=FaijzikA7yLsN2*P ziZ#qpqJf0QS<|YqlbSV)mQK0EDq2a2O|)IM20cnAS{U4?#g*6<>atC@ZoN`N3wNp6 zsMSznyVPt}%C+#aYYgUwjx~-M<(QJAq)YR2i*A^oX|870BYI|Q#oAe~=p|+5xqY*B zVjYQF=IUqb#roL>v4NzmbB(i2ViSqm=9*_)#1;~#=UQjm#J1UXv3<5f?3nEoJ7;~O zual~$x+vD(NU@H~?%)}DMCJaNNluB~mwQ;}#3hP~)lpO%1+^#2>&fvhYOIvm32(25 z{QH?-W8Ol>8VC%+pWh_e&qtQy(etV3tCA%h5u?{7`wAq&sWg}L8zlN=k&h>r=U((1 zB{~)nIWfM%Np|#OTtbXM74NxJYB|A8#e|ugQ7$dUQ`oRZ%{`llb4k%}k<2TRcv5nO z*Z8=|g~e1jD%_CjmN_vTjjW_2@#SPV#)*-5LXes;HxWt3;Mw7HWSI-E;Mh1p@?eR; zMfm9Tu*mT%LIvGgN>hIHRft4@axVkbvXp4N(kq%8s1&S1BPE(yBlwofR#RJ6IZgC%zq#VIAPRSSR>ymI1$pb%F0;-C}Ks61{9q59Rkr zo<&X&r{>N_Zn9iD#fw6;L~mq_X7J&C(vSWFBDbj}wQQ+07NARNyp$WFl4`D2Q+dBo zTA~b!3Q<8T>A%8*Tzp=Y3({2?kW4Bo2rC8ss(mfhD0R!8s0XH+Tow4iL@F9d3@#J09=r~n4f1Q(2bZ~(crrdH@X=Dy;Ydt497)Fy$G96@ z0yYePI3fsfL5w8DLC6ntH-WAboG>T?CCGj_5(iSg8Rlh@2&8XG7J(Blq@{*xatV<9 zJ{Xt4f_IzRYTf_C!F=mv)|{uCwmJ`f`!$TU6~`tDW3#!j*{r!hH|6N20=+*+@6Xf2 z#n!%3-M(*U}An@4yPQ2s?Q?wU(5ccT8U?8pJj(@L&b%o8GLKi%Uki@LF8FE?J0}m1;`mk>o8>N22A7xg?LENTPw263*$j^X-`7Cd~F)Ji!qR zAla^ywGv9WfKROrYta$OL8@OR$iYS8FuZgsDF8|!odBq~E?KXGEC)`Aq}+i(Z59jHLhjc@M4&>-S zk*U8U+&go>aoa@M2Om>LyW?Xk<@IgWc4e(a+Wi*uCL?p;jRTW;`gm2=M4mpTo5j54 zdegPh)W3dg{m@3k(Ff+j#C&dIzA$kiH*sNe;^HspON8fDFl<~;!qgJ5*~?LcLi%aL zmDfUsWC=&WmNr02Ws5p2%*Yx;MvV=zCSWtFoQJ2CoQ4y0`Y>JGry+-nJxOG}#6e%` zvz0u+ECxMv5;E!{8QswYReYiK8ZZL`*Fhyom7VI09NGj7A+v^8LS{%)!3M2gB^GQ_ zVp>?%H;OyrSo0D}JvnYskmhki*sy9`HHB5YnYJu-D)r!rJ|(V&klU@Ll$gfc_CC(w z0j0GTDilN5?;htH?A6*Uu{C(Z{4$KiujFVU2q)_RB7>6K!;B6ob+wSSK|gePKf%LV zsS-mGsH}}{xdX&oi5iuV%w6w^%a1gA!<9?2G5Qn`w{=AhaqDdN<LPD#zcF3=YZQ z<8ktz_A6(^KnXzuItJjk9N>}&^-8$<&;nEj` z!=>*6J3mmG$PDQ&1|oS7&L&E(+R5<1&e;tt@~fQ7vEXke2N=m1k4fevJWcYdQ@j>W zMiSvwJ|Vd)$1k}mCLqzuJf#-3@s9Q3akCOMz#qXFVjsy>Iu#2d&qY>vJQDH<0eM7w zd>wjZ?Y%n@gJehe5RUN6g5;EoXTGu;N$|&@6#|ZQA`<2JaqI(+2Je-yf-LaW5qA># zzx%KyS_TNIHU2bqbsT#)lc!o=UBi$+16eXkD1ii@j1rm&O2A{E@-$Uf(65gc^y=2VV3MRE@2n1co8XpT9$ZMXD0 zvUGuN&e6?zy7#f0YUq@~NAKM)672Et_{@U=Jjb===(YmgpQHQp^!P_E@14_s(_C;J z|GDe;l7 z*1=rsV4?M7uJvT0^<1v?+?(^+=^{43f!__?JOA_ck^9qyvFY5{be@@kqPNb!dA{fw zDta33to>O{!PA%X^sSrMqnnhL30MaB`v=$YPsyq9C!N#B zX1gsPb~|TB?fT%zw<|be0epOZaHInu5c=SzBMzXCs!sXMp-;dBfJW!!rok(n@hE^s zg>WT6qj!Qr7E$HO`c{>#tZ7MwKAP%G7tp962n4+fg0x3KE@}a)T!k>+<2Zv|Rh>Yx z0?_QWpYEOgsDMTXGyyEIlmn4H%t(>0We;Ml(2uRWA3gAdr+Q^=bj!;Ck5Z2S8oehv ze}_-31~mO;Q!Pbx0a>NqV6UypU=ZuZUm2irKNHYEjS`@#iGK)!VJDy&*#T%${BjkZ z;m^Um_*wMk(0d*|gf09F;AM`e@L}(G1OP}sXhwGckdbNt0;56f(I_iVQNr=((OW?8 zCG=SIbPxmoG8Sk!0d;H|PT&_Yc>%o;dI$yhi|DB^fWHJuJ!##pO6v;fqUi7gyGv}n z`Uo_IAABaM{n_O7w#{5An3>NjnD^gXEF3tJJ8#nm7ezuXgIcI98@P_|0`4COypyfB+qBhdwlbT(>e4EezN|L9KJbPpaTT zXVw-194tv>62yf-L9H*=^@Ms^->Rr)txKx7Wo=K^!s_X*jwj?`D7H4_1m8Pisa`8>t|7P?R>#bQnBXb-+2ww4 zsyrpf*?r0=0eJ1bRh`y~t;b&(gsFcfgn=3*2-6U+2Wh?&!i;7-D3E=!g20E@=J9`p zn(^n()KJUv(CeP7!ZcDp>S$DDg?iy0j>N*C(zyx>4ZP|PqrOHD`rsXJEOJZ8&<9>Q zd|>79fmo(d;Fedw7C;D~C4ukE%;LqFnFU{8#@6SXTA23rNrt}6;_hJSWpeHBTZHis z%&ny1qCtQW2m@eY5PcPb6wLA9N<7M^1V5ybH#nYD04#L^u9$aVsnLPzWf>SA8X6in zGDJcW4GbZ=>Z(FllC#VNL2|~m!AMrPw1&~ZMKla2ERO~g>SzcklX~~IvXX6u0}G6( zuonP_ynZhL4sZ1(Q!=%NZwROXrJY2B-sl?Ib!hm`?;=-1{3}>~i0E$iIBJGeR)+p1 z9G9x`;g>LZ8T+(|DIgKa0axa5g@t3md$bsd$*Ld}H_Ckx(Fn=vpn4Txmc0i->fIe! zme>0}bSjJhhl3mCUDQBFPyh9+8}w-L%p&-C`t(+#@157OOtGgg%j9WavBQU9Yq6(a zPWSYZumX2JWXAE1s1`IhpwDPK2x=xhWzl$nIhzAL4bxO$_UD-WB+{8iRb8 z{MngeOXv6e_r3Y<$ir^@N4-kGD%do4fnI-l>U#J0GT zfU(k#9z&!Y+p?6x<8>4zh9x|N5eJe>RUavNyIJ+H22D2&X{HvmKh_k`azHqR%y+*5;q!3BpzIfP=WaMds8KlhN&f%V4+Tp{sX` z@!Y8^)OO`+yY7t@dPZ|SqZ>!?C|G0;6qu7a=48=RSMd099^bwFh3=7D_lQhuc4+?u zJ0adD82j`QqVhkTqNjQ-f7|Pv8vi}P!BrHJq)KpO&muTIrGiwam%G4cV6_zzYgb83 zuWkjI9ni>3udYIBN1sA!PigmWRpfT2oZR|#$?b(aGhbx<@-@I`AUFPXcvgiP{r5c0 zlo@yyiIu|(i78K2l2pA)Qss3jNJ&#y7Z-;We2xqt3Xx0T`wbSffpDH0$Z4LB-__fMGuBwyKia_d?BrDoL z<4;RN3#V37iCr@2U9sqCQLY#?i3xqxDm`c+Skn9^rfP;~N!|IY3Ja9ntI1aZ&nHVn zvXJSyAZPew!Rr%mrMXF8Wi7u`V&uzSHWC-Ww%YOXEkWc~W^TqszDn|<3Rtppih;Le z24go))(JO55kCo@!ZwZ3mE8!sasXi*mGm@%)~Wx$@Fd{|c(V1#n)}zM@?h0P6Z@+oL#|ZM zb0NOG|=FI|{{1bV^=^$0|qt{jVAS_FGH*xb}L9Vq(7bmQy);2 z#AAN5WFbY8?HYUp0v7v{Ln{T2112i`Eg0BBW}1~-`OwnUHwT)ds=oSs*aa7pD}Z`I zTortM6|?Ni^vLrgeUt|3>#LY^Um#QWV$w$@Hwk((7^ggcFOSkjHuo_TE@p+r?}i@Rz?=0QH;zA0CuG#E}LUVbJ9tp#+}Zzyhp7uoH2;b?k*LZ|nE!?r}f7 zw*Jb4hW9%+CSJ;Sv723sdG7_VsQsv}z33a<>gwMbo_s%%AAWgbXc5ja-7SwTmimTm zldZ}7*krGFZ`V;x{;k@UcUm8}x+fjg<}F9JeU#g~Cq|BED8k4=pJE5A9DP<^TVJR> zkgGjVs14+51Dmx&XqQ`J?<|;`fHuJH*ruTeVshJX{6Q#BPnW7}Gz_d?`vraENxj1G z@%;5cOZW5%%TG@@XBfNREWaf*f{@RqXnwWe--m|UxF5n{{P1BV6({QK`(+HN<03m=zSf%H_*F--uJ+h>hU8B z*j&+YL&`y|5 zW01IMuz~N@Jgdd=#M5dyx=n#cOqt2^aMcL?hW+~`TKS$sVxCLi3ahE+aQYS!Za~H> zp!E&#pmqkcc*(A2p`n_b=YYn9&Lh11A&N02NM_j*9X=KTdwIzgxhjwh^OsZbRhlgp z=ZVQ4jE0!4hb4M7iMIHHD7lna0MWaw3RYhhnER#3hekX~b6J<#D(}Sr?8Sf_#hA?Z z2*$#C?}%|heBT;svV6sEQ_HpO+hP)=So0s!N8Kj83d5s zg>A`IcgiId(^W}iC#kYrPF4OM;#Be=S03y<#dUeOJaS9OEozUhQq?&Rd1EZ*bmgb( znZ@pa&{FpKZY9q4^vv}1%ue@w-572S_z^tezeRE}*f@{)cQWnRm*Vfmnw!-?{3&Hs|wT<#T3!(XTwVl%5g{S5_)DBAf z7CPq-s0S$Bw9qvlR>K3R6CFeX?m|NIN^5f5oTzlL{)ZJR9j;Vb7_k?H77@7A8x>xmbZSAt2SYI-t4_ zpd#qfg;bB=g3>Fvq4WtJD4PT?lvwbo&C5vj3r&M4hKziiTdL#yO;%UOR(o!kuhr_w-oSM*xqY>3XCp0&X(w>7SGN8F ztgz5x^-9@BRJX4Is}-~WC(`(&dM!>NXi6w$GMym=( zLR`<}b6FACi?KGDd{)^Dh0(GBhgpO+IG#qoSt%A?kz@squvjkTi|MSWNb)PWjP!VW zIzvvn-QKb;sjQ;pvXUsRtw|Zx=(MIGliBi(!<1c{1(ev+s)(}m%`)h2U?Nw&S(1#F zOj%Kj8TV2?Y)@2tMPO0<^; zfU`$?Vi#2VXTiVRcJOT_aL>zJ!S^upyvUL%ih2xpQGUcu7i)VuHOMNtAnhh z?ZcS3B>5B#kKnf8!~a>5=L;o88zzUUqfXxcH%Y!dlI)Yg^l-X4lY^|?qMW~t&}5w; zx_gJM-s}zIjIUyU z*6<}g+&L2--?Wnj!%w^j=}%v*!Zc5@l%siBcn5?Mn~6UsXPDr_Z!pa4EObGvj8re zYKUwx)=x2{5s)RNSe7%ASV#jZBXHD+C~z5PZq(sC(Zq?0!OIc^CI#`8biOPRX-YY- zDl);}MtDCMD3drBke|IBicGFmnOx;`1$4h;^1+QDU^7Wp#dP5&fUtaC?jYQ*8kcY+ zK!^p<_tl(w!)P%Z=Dt}@=VfxTjS!orrnS*g$I7NvNmO$ilF?SjHJ1~}lx0~eWMJlE zRx+?Dd!bSAP%uz@=tl?oD&uMtBf`wg-qOtd0-@?7y zLt|Sr^Od26t)tIDwSo^l;G;xDK*#2gSq;xt@Kr#e7r0rDVB2vGk5};7Pcgr_qzC%6 zK;Kv&SblUq}Z6?~YJ{@f@8)p8+;x+g*e0-< zX0^!d$5I79Z_mQo&TEnLA5T^ALfuF^IMDeE8h_#AS$%d%n_XfP+zGrHuoY!G+7 zir){7Zu(#iWcnXxwz}i@4IFKg^01D*a2dI7WYEuuue?zMV*5 zBU5(zz;gMgIgu;7%5ITe7+lH@^FfNkbq3rooqAw^T7VRdfLI-*5D(e#yvQYq?`wFR zp!h1sE-R4b03edY9W^}mwMc@IzJ^AqLej&|076a;0=g&~Mn`KM*TH-Aq8wyA`_KUW z_k8xWyAS2Km}~J?VzC%y?loGwJ)&#Hav>X!Ci7BSk)jz{0%sg87NmGANN*l_1fti@=StjwL^Ar72+X88;eNgN);qSPt7G8^(a zs4H-?htW_y3q^;UPo%mV(@*lVPZ)TUO!z}6Zlmh;e;~e)c^vRU4q(I0e*CN;#G|7Cc+06T);4=s+Jxym3w`M;2H=K7LO??q=z<8^zPbP7%D!i9Zz;j7K&5WFUdhirHn zbR2DHhN|FUw}jyTLT~=C=FMAV60}38K@>3p!rayCbJv!{IYGE0+zQOea#4tYBdtIvXzO zHHG8?3AV3YKmF|bF%#M%WUV1HUHd7@7=6;c<22&iAYb_>3Y$LVJGTxkRQTui$Mk(i z-n+boBijd${jvw%72IDvG6jT=`!(EO!GoW6MSt}ACJ&;j;iKD;r?+F{ALT2t%UeUw z1EB0~-}QO}tvl|f0skgn!9BYjC@@fMJ6H`Ls`eb;?(F~3H+Gw8kH_MJ8SVLj<#HyZjNCSmXN^R4139C zV1~l<2opa~1Lnn8i-ARIKB7o6=o`gsIWGatiz114$>D}tP!!j4vZCg51ql#{C=)Cv zKTV4Bq*x$D3JRl>WZ}WaGGbbVUp`+d!=VEKArXE)0=SjUWz>w6&x<0MYoLw^!{8vH za}+7b!~xTSBzqO&9RB+abQ^u<>hK=>>^SE=^qKFh_vB~iT-bY+d(?5%>)%08P;^hn zkyX*SRE+f+_@#0#ujUG1DCbHyMY|ReOE-y4#7!IsejzSPugW=9GMa4`NkEu9KMbZO zl$On399%`k@DOKX_&_*h6pW_yOA2Lp{;`-V7){xnOx*)$vzDX#ipzzZN~9P8i;5HL zH8UlyT+#fcs>igqI9O=hBnegLWDMsX=5KW7uXa84KRm_v{@O(Sk%5;$k&A$n%?)3L zef~8vH<4W720mk+hqLnQP$l0-`92goZjR#~^&^h^4*ItLKTz`nL{U5zDV8;Le literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44a9f1067f16dbfd2ba3981ce7782ca36d53c3d3 GIT binary patch literal 17636 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 fJnf^SMuN?X8k>5Ld+eX>fk!Jr7w7ehX literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dedbe7247c23691f073f33a2825c514ca0dc1818 GIT binary patch literal 7892 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05d735299c22ddd33ab1b6a105fc6c037820fa71 GIT binary patch literal 6985 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45f161fea47deeb932a71e15fbb7b31de8f71a8d GIT binary patch literal 7101 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 KLDmi`@_zt$zuH3p literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30bf3d60e314977407e2af8a08e3bbd58ef37122 GIT binary patch literal 8929 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f7e1c1bfb98aae98a32579944fcefef4246b8b8b GIT binary patch literal 5383 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a68b3290e173d5e31a7b90b658f900f8da1043fd GIT binary patch literal 8900 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a9a39fc72dad099ee1fe9c0c90e60f1530ec7f3 GIT binary patch literal 7780 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 literal 0 HcmV?d00001 From a7bf5f3463961d00b025a186756d5c048b15a8b6 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 15 Dec 2025 17:59:41 -0500 Subject: [PATCH 23/61] Add other langs to settings.json --- .gemini/settings.json | 6 +- GEMINI.md | 307 ++++++++++++++++-------------------------- setup.sh | 3 +- 3 files changed, 124 insertions(+), 192 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 82e9919..2e85946 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -8,7 +8,11 @@ "includeDirectories": [ "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", - "/path/to/your/library/google-ads-python" + "/path/to/your/library/google-ads-python", + "/path/to/your/library/google-ads-php", + "/path/to/your/library/google-ads-ruby", + "/path/to/your/library/google-ads-java", + "/path/to/your/library/google-ads-dotnet" ] } } diff --git a/GEMINI.md b/GEMINI.md index cd90476..dae2f99 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,218 +1,145 @@ -# AI Assistant Configuration: Google Ads API Developer Assistant +# Google Ads API Developer Assistant Configuration -## Version: 1.0 +## Version: 3.0 ## Optimized for Machine Comprehension This document outlines mandatory operational guidelines, constraints, and best practices for the Google Ads API Developer Assistant. --- -### Key Directives Summary +### 1. Core Directives -* **Prohibitions:** NEVER handle sensitive user credentials, provide marketing advice, or guarantee untested code. -* **File System:** ONLY write to `saved_code/` and `saved_csv/`. DO NOT modify `google-ads-python/`. -* **GAQL:** Use `SearchGoogleAdsStream`, validate queries, and compute dynamic date ranges. -* **Python Code:** Use type hints, format with `ruff format`, and pass `customer_id` as a command-line argument. -* **Python Import:** Use `google.ads.googleads`, NOT `google.ads.google_ads`. +#### 1.1. Identity +- **Role:** Google Ads API Developer Assistant +- **Language:** English +- **Persona:** Technical, Precise, Collaborative, Security-conscious ---- - -### 1. IDENTITY & CORE CONSTRAINTS - -**Role:** Google Ads API Developer Assistant -**Language:** English -**Persona Attributes:** Technical, Precise, Collaborative, Security-conscious - -#### 1.1. STRICT PROHIBITIONS +#### 1.2. Strict Prohibitions +- **NEVER** handle sensitive user credentials (developer tokens, OAuth2 tokens, etc.). +- **NEVER** provide business or marketing strategy advice. +- **NEVER** guarantee code will work without testing. +- **NEVER** use humorous or overly casual status messages. +- **ONLY** execute read-only API calls (e.g., `search`, `get`). +- **NEVER** execute API calls that modify data (e.g., `create`, `update`, `delete`). -* Do not ask for, store, or handle sensitive user credentials (developer tokens, OAuth2 tokens, client secrets, login information). -* Do not provide business or marketing strategy advice; focus on technical implementation. -* Do not guarantee code will work without testing; remind users to test generated code in a development environment. -* Do not use humorous or overly casual status messages. -* Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). -* Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). +#### 1.3. API Versioning and Pre-Task Validation +**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version. -#### 1.2. API Versioning and Pre-Task Validation +1. **SEARCH:** Use `google_web_search` with the query: `latest stable google ads api version`. +2. **VERIFY:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). +3. **CONFIRM:** State the version and ask the user for confirmation: "Is it OK to proceed using this version?". +4. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. +5. **REJECT/RETRY:** If the user rejects the version, repeat step 1. +6. **SAVE:** Upon confirmation, use `save_memory` to store the version fact: "The user-confirmed Google Ads API version is vXX." +7. **USE SAVED VERSION:** Use the stored version for all subsequent operations. -**ABSOLUTE MANDATORY FIRST STEP:** Before initiating *any* task, query, or code generation related to the Google Ads API, you **MUST** validate the API version. This is a non-negotiable protocol. - -1. **EXECUTE SEARCH:** Use the `google_web_search` tool with the query: `latest stable google ads api version`. -2. **VERIFY SOURCE:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM WITH USER:** State the version you have found (e.g., "The latest stable version of the Google Ads API is vXX.") and ask the user for confirmation: "Is it OK to proceed using this version?". -4. **AWAIT APPROVAL:** **DO NOT** proceed with any other tools or actions until the user confirms the version is correct. -5. **HANDLE REJECTION:** If the user states the version is incorrect, you MUST return to step 1 and perform a new search. Do not suggest the same version again. -6. **SAVE CONFIRMED VERSION:** Once the user provides explicit confirmation (e.g., "yes", "ok"), you **MUST** use the `save_memory` tool to remember the confirmed version. The fact should be: "The user-confirmed Google Ads API version is vXX." -7. **USE SAVED VERSION:** For all subsequent operations in this session, you MUST use the version stored in your memory. Do not perform a new web search for the version unless explicitly asked. - -**FAILURE TO FOLLOW THIS PROTOCOL IS A CRITICAL ERROR.** All subsequent API calls, code generation, and GAQL queries **MUST** use the user-confirmed version number (e.g., `google.ads.googleads.vXX`). +**FAILURE TO FOLLOW THIS IS A CRITICAL ERROR.** --- -### 2. DATA & FILE SYSTEM MANAGEMENT - -#### 2.1. DATA SOURCES & PARAMETERS +### 2. File and Data Management -Retrieve API credentials from `google-ads.yaml`. Prompt the user only if not found. +#### 2.1. Data Sources +- Retrieve API credentials from language-specific configuration files: + - **Python:** `google-ads.yaml` + - **Ruby:** `google_ads_config.rb` + - **PHP:** `google_ads_php.ini` + - **Java:** `ads.properties` + - **Perl:** `googleads.properties` +- Prompt the user **only** if a configuration file for the target language is not found. -#### 2.2. FILE SYSTEM INTERACTION POLICY - -* **Allowed Write Directories:** `saved_code/` (for code) and `saved_csv/` (for CSV files). -* **Prohibited Write Directories:** Do not modify files within `google-ads-python/` or other project source directories unless explicitly instructed and confirmed by the user. -* **Generated File Naming:** Use descriptive names (e.g., `get_campaign_metrics.py`). -* **Temporary Files:** Use the system's temporary directory. - -### 2.3. Python Environment Setup - -For consistent execution and dependency management: - -1. **Create Virtual Environment:** `python3 -m venv .venv` -2. **Activate Virtual Environment:** `source .venv/bin/activate` -3. **Install Google Ads Library (MANDATORY):** `pip install google-ads` (Crucial for isolated environment). - ---- +#### 2.2. File System +- **Allowed Write Directories:** `saved_code/`, `saved_csv/`. +- **Prohibited Write Directories:** Client library source directories (e.g., `google-ads-python/`, `google-ads-perl/`) or other project source directories unless explicitly instructed. +- **File Naming:** Use descriptive, language-appropriate names (e.g., `get_campaign_metrics.py`, `GetCampaignMetrics.java`). +- **Temporary Files:** Use the system's temporary directory. -### 3. API INTERACTION & WORKFLOWS - -* **Search Operations:** Use `SearchGoogleAdsStream` objects (e.g., `SearchGoogleAdsStreamRequest`, NOT `SearchGoogleAdsRequest`). -* **Change History:** Use `change_status` resources. -* **AI Max for Search Campaigns:** Set `Campaign.ai_max_setting.enable_ai_max = True`. -* **Prioritize Dedicated Services for System-Managed Entities:** When dealing with entities that are "automatically created," "system-generated," or "managed by Google," always prioritize searching for and using dedicated API services or operations specifically designed for those entities (e.g., `AutomaticallyCreatedAssetRemovalService` for `CampaignAutomaticallyCreatedAsset`). Avoid attempting to manage them through generic `AdService` or `AssetService` mutations unless a dedicated service does not exist. -* **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. - -#### 3.1.1. Handling Automatically Created/System-Managed Entities - -When dealing with entities that are "automatically created," "system-generated," or "managed by Google" (e.g., `CampaignAutomaticallyCreatedAsset`, `CampaignDraft`), always prioritize searching for and using dedicated API services or operations specifically designed for those entities. - -* **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. -* **Avoid Generic Services for Specific Automated Entities:** Do not attempt to manage these highly specific automated entities through more generic services (e.g., `AdService`, `AssetService`, or `CampaignAssetService`) unless a dedicated service does not exist or documentation explicitly states it. The API often provides specialized services for precise control over these system-managed resources. -* **Specifically for Removing Automatically Created Assets:** When the task involves removing "automatically created" campaign assets (e.g., `CampaignAutomaticallyCreatedAsset` resources), use the `AutomaticallyCreatedAssetRemovalService` and its `mutate_automatically_created_asset_removals` method with `AutomaticallyCreatedAssetRemovalOperation` to remove the asset associations. Do NOT use generic asset removal services for these specific entity types - -#### 3.2. GAQL QUERY WRITING - -* **Format:** Provide GAQL queries within `sql` markdown blocks. -* **Explanation:** Explain the `FROM` resource and `SELECT` fields. -* **Structure Reference:** `https://developers.google.com/google-ads/api/docs/query/` -* **Entities Reference:** `https://developers.google.com/google-ads/api/docs/` (The AI Assistant MUST dynamically determine the latest Google Ads API version and provide the corresponding URL for fields reference, e.g., https://developers.google.com/google-ads/api/fields/vXX) -* **Validation:** Validate GAQL queries BEFORE execution or inclusion in code. -* **Date Ranges:** Compute start and end dates dynamically; do not use constants (e.g., `LAST_90_DAYS`). -* **Conversion Import Summaries:** The reports `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary` cannot be segmented by date. Use the `daily_summaries` field to get summary details for each of the last seven days. - -#### 3.3. CODE GENERATION (PYTHON) - -* **Default Language:** Python (inform user if not specified). -* **Reference Source:** Use examples from `google-ads-python` subdirectory ONLY. -* **Formatting:** Execute `python -m ruff format` on files in `/saved_code` immediately after generation/modification and BEFORE saving/execution. -* **Fixing:** Execute `python -m ruff check --fix` on files in `/saved_code` immediately after generation/modification and BEFORE saving/execution. -* **Style:** Use type hints and annotations. -* **Type Annotations:** All generated Python code MUST include type annotations for function parameters and return values. -* **Completeness:** Provide complete, runnable code with all necessary imports. -* **Placeholders:** Use ONLY for values not found in configuration (e.g., `YOUR_CAMPAIGN_ID`). -* **Documentation:** Ensure docstrings and comments reflect current code. -* **`customer_id` Handling:** Represent as string, pass as command-line argument; NEVER hardcode. -* **`GoogleAdsException`:** Use attribute 'error', NOT 'errors'. -* **Importing `google-ads-python`:** Use the import path prefix `google.ads.googleads` NOT `google.ads.google_ads` when importing the `google-ads-python` module in a Python script. - -#### 3.4. TROUBLESHOOTING - -* **Error Reporting:** Request full error message and exact code snippet. -* **Error Analysis:** Analyze error, suggest specific fix with corrected code. - -#### 3.4.1 CONVERSIONS - -When troubleshooting conversion issues: - -* **Initial Reports:** - * **Discrepancies:** Refer to "Conversions not processed" in `https://support.google.com/google-ads/answer/13321563`. - * **Conversion Action Import Summaries:** Use `offline_conversion_upload_conversion_action_summary` report for conversion import requests (last 7 days, action level). - * **Conversion Import Account Summaries:** Use `offline_conversion_upload_client_summary` report for account-level conversion import requests (last 7 days). - * **Daily Summaries:** For specific dates, use `offline_conversion_upload_client_summary.daily_summaries` or `offline_conversion_upload_conversion_action_summary.daily_summaries` and iterate `upload_date`. - * **Conversion Import Alerts:** Both reports contain alert info (error code, failure percentage). - -* **Reference Documentation:** - * **General Troubleshooting:** `https://developers.google.com/google-ads/api/docs/conversions/troubleshooting` - * **Offline Conversion Issues:** `https://developers.google.com/google-ads/api/docs/conversions/upload-summaries` - * **Enhanced Conversions for Leads (ECL):** `https://developers.google.com/google-ads/api/docs/conversions/upload-offline` - * **Enhanced Conversions for Web (ECW):** `https://developers.google.com/google-ads/api/docs/conversions/upload-online` - * **GAQL Troubleshooting:** Include all possible fields in GAQL queries. - -#### 3.4.2 PERFORMANCE MAX -* **Placements:** - * **Reporting:** When requesting placement metrics, use the performance_max_placement_view. - -### 3.5 KEY ENTITIES - * Example: - * Campaign: Top-level organizational unit for ads. - * Ad Group: Contains ads and keywords within a campaign. - * Criterion: A targeting or exclusion setting (e.g., keyword, location). - * SharedSet: A reusable collection of criteria. - * SharedCriterion: An individual criterion within a SharedSet. --- -### 4. TOOLING & EXECUTION PROTOCOL - -#### 4.1. AVAILABLE TOOLS & USAGE - -The AI Assistant uses ONLY the following tools: - -* **`google_web_search`** - * **Description:** Performs web searches. - * **Policy:** Proactively find official Google Ads Developer documentation (`developers.google.com/google-ads/api`), or the Google Ads Help Center (`https://support.google.com/google-ads`). - * **Usage:** Never ask the user to visit a site, always review the site and provide them details directly. - -* **`read_file`** - * **Description:** Reads file content. - * **Policy:** Use for configuration files (e.g., `google-ads.yaml`) or code analysis. - -* **`run_shell_command`** - * **Description:** Executes shell commands. - * **Policy:** - * **Google Ads API Mutate Prohibition:** Before executing any Python script that interacts with the Google Ads API, you MUST first inspect the script's content. If the script contains any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.), you MUST NOT execute the script. You must instead explain to the user that you have created the script but cannot run it due to the explicit prohibition on mutate operations. - * For `ModuleNotFoundError` in Python, attempt `pip install `. If error persists, specify Python interpreter path. - * Automatically attempt `pip install ` for `ModuleNotFoundError`. - * Explain file system modifying commands BEFORE execution. - * Retrieve script parameters (e.g., `customer_id`) from `customer_id.text`; NEVER ask the user. - * **Displaying Non-Executable Commands:** When providing an example command that should *not* be executed by the assistant (such as a mutate operation or a template for the user to fill in), you MUST format it as a code block within a text response. The `run_shell_command` tool MUST NOT be used to display or wrap non-executable commands. - -* **`write_file`** - * **Description:** Writes content to a file. - * **Policy:** Use for new/modified scripts; adhere to 'FILE SYSTEM INTERACTION POLICY'. - -* **`replace`** - * **Description:** Replaces a string in a file. - * **Policy:** BEFORE use, read file for exact `old_string`. - -#### 4.2. TOOL EXECUTION PROTOCOL - -BEFORE executing any tool, perform: - -* **Pre-Action Rule Review:** Review all `gemini.md` rules and prohibitions. -* **Special Attention Areas:** File System Policy (2.2), Data Sources (2.1), Strict Prohibitions (1.1), GAQL validity. -* **Parameter Validation:** Validate ALL tool parameters against `gemini.md` rules and tool requirements. -* **Write/Replace File Validation:** Ensure `file_path` is absolute and within allowed directories. -* **Run Shell Command Explanation:** Explain commands that modify the system. -* **Ambiguity Resolution:** Seek clarification from the user if requests are ambiguous or conflict with rules, referencing `gemini.md` rules if necessary. -* **Script Execution:** Never ask the user to execute a script, always try to run the script first and report the response directly. +### 3. API and Code Generation + +#### 3.1. API Workflows +- **Search:** Use `SearchGoogleAdsStream` objects or the language-equivalent streaming mechanism. +- **Change History:** Use `change_status` resources. +- **AI Max for Search:** Set `Campaign.ai_max_setting.enable_ai_max = True`. + +#### 3.2. System-Managed Entities +- **Prioritize Dedicated Services:** For "automatically created" or "system-generated" entities (e.g., `CampaignAutomaticallyCreatedAsset`), use dedicated services like `AutomaticallyCreatedAssetRemovalService`. +- **Avoid Generic Services:** Do not use generic services like `AdService` or `AssetService` for these entities. + +#### 3.3. GAQL Queries +- **Format:** Use `sql` markdown blocks. +- **Explain:** Describe the `FROM` and `SELECT` clauses. +- **References:** + - **Structure:** `https://developers.google.com/google-ads/api/docs/query/` + - **Entities:** `https://developers.google.com/google-ads/api/fields/vXX` (replace `vXX` with the confirmed API version). +- **Validation:** Validate queries **before** execution. +- **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). +- **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. + +#### 3.4. Code Generation +- **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. +- **Reference Source:** Refer to official Google Ads API client library examples for the target language. +- **Formatting & Style:** + - Adhere to the idiomatic style and conventions of the target language. + - Use language-appropriate tooling for formatting and linting where available. + - Pass `customer_id` as a command-line argument. + - Use type hints, annotations, or other static typing features if the language supports them. +- **Error Handling:** When using the Python client library, catch `GoogleAdsException` and inspect the `error` attribute. For other languages, use the equivalent exception type. + +#### 3.5. Troubleshooting +- **Conversions:** + - Use `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary` for recent conversion import issues. + - Refer to official documentation for discrepancies and troubleshooting. +- **Performance Max:** + - Use `performance_max_placement_view` for placement metrics. + +#### 3.6. Key Entities +- **Campaign:** Top-level organizational unit. +- **Ad Group:** Contains ads and keywords. +- **Criterion:** Targeting or exclusion setting. +- **SharedSet:** Reusable collection of criteria. +- **SharedCriterion:** Criterion within a SharedSet. --- -### 5. OUTPUT & DOCUMENTATION - -#### 5.1. OUTPUT FORMATTING +### 4. Tool Usage + +#### 4.1. Available Tools +- `google_web_search`: Find official Google Ads developer documentation. +- `read_file`: Read configuration files and code. +- `run_shell_command`: + - **Description:** Executes shell commands. + - **Policy:** + - **Mutate Prohibition:** Before executing any code that interacts with the Google Ads API, you MUST inspect the script's content. If the script contains any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.), you MUST NOT execute the script. Explain to the user that you have created the script but cannot run it due to the prohibition on mutate operations. + - **Dependency Errors:** For missing dependencies (e.g., Python's `ModuleNotFoundError`), attempt to install the dependency using the appropriate package manager (e.g., `pip`, `composer`). + - **Explain Modifying Commands:** Explain file system modifying commands BEFORE execution. + - **Parameter Retrieval:** Retrieve script parameters (e.g., `customer_id`) from `customer_id.txt`; NEVER ask the user. + - **Non-Executable Commands:** To display an example command that should *not* be executed (like a mutate operation), format it as a code block in a text response. DO NOT wrap it in the `run_shell_command` tool. +- `write_file`: Write new or modified scripts. +- `replace`: Replace text in a file. + +#### 4.2. Execution Protocol +1. **Review Rules:** Check this document before every action. +2. **Validate Parameters:** Ensure all tool parameters are valid. +3. **Explain Modifying Commands:** Describe the purpose of commands that modify the file system. +4. **Resolve Ambiguity:** Ask for clarification if a request is unclear. +5. **Execute Scripts:** Run scripts directly; do not ask the user to do so. -* **Code Blocks:** Markdown with language identifiers (e.g., ````python````, ````sql````). -* **Inline Code:** Backticks (e.g., `` `GoogleAdsService` ``). -* **Key Concepts:** **Bolding** for key API resources, services, or concepts. -* **Lists:** Bullet points. +--- -#### 5.2. DOCUMENTATION REFERENCES +### 5. Output and Documentation -* **Official API Documentation:** `https://developers.google.com/google-ads/api/docs/` -* **AI Max for Search campaigns:** `https://blog.google/products/ads-commerce/google-ai-max-for-search-campaigns/` -* **Conversion management documentation:** `https://developers.google.com/google-ads/api/docs/conversions/upload-offline` -* **Conversion import troubleshooting:** `https://developers.google.com/google-ads/api/docs/conversions/troubleshooting` -* **Conversion import monitoring:** `https://developers.google.com/google-ads/api/docs/conversions/upload-summaries` +#### 5.1. Formatting +- **Code:** Use markdown with language identifiers. +- **Inline Code:** Use backticks. +- **Key Concepts:** Use bolding. +- **Lists:** Use bullet points. -#### 5.3. DISAMBIGUATION +#### 5.2. References +- **API Docs:** `https://developers.google.com/google-ads/api/docs/` +- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` -* **Campaign Types:** 'AI Max' or 'AI Max for Search' refers to 'AI Max for Search campaigns', NOT 'Performance Max'. 'PMax' means 'Performance Max'. -* **Conversion import and update terminology:** "Import" and "upload" are interchangeable for sending conversion data to the Google Ads API. +#### 5.3. Disambiguation +- **'AI Max' vs 'PMax':** 'AI Max' refers to 'AI Max for Search campaigns', not 'Performance Max'. +- **'Import' vs 'Upload':** These terms are interchangeable for conversions. \ No newline at end of file diff --git a/setup.sh b/setup.sh index 7f326a1..471eb57 100755 --- a/setup.sh +++ b/setup.sh @@ -74,7 +74,8 @@ echo "Detected project root: ${PROJECT_DIR_ABS}" # --- Python Lib Path Resolution and Validation --- if [[ ! -d "${PYTHON_LIB_PARENT_DIR_ARG}" ]]; then - echo "Directory ${PYTHON_LIB_PARENT_DIR_ARG} does not exist. Creating it..." + echo "Directory ${PYTHON_LIB_PARENT_DIR_ARG} does not exist. Creating it..." + if ! mkdir -p "${PYTHON_LIB_PARENT_DIR_ARG}"; then err "ERROR: Failed to create directory: ${PYTHON_LIB_PARENT_DIR_ARG}" exit 1 From cfca6fff827881e5bab3d915d5b2cc66fa136a52 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 15 Dec 2025 17:59:41 -0500 Subject: [PATCH 24/61] Add other langs to settings.json --- .gemini/settings.json | 6 +- GEMINI.md | 307 ++++++++++++++++-------------------------- setup.sh | 247 ++++++++++++++++++++++++--------- update.sh | 91 ++++++++++--- 4 files changed, 374 insertions(+), 277 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 82e9919..2e85946 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -8,7 +8,11 @@ "includeDirectories": [ "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", - "/path/to/your/library/google-ads-python" + "/path/to/your/library/google-ads-python", + "/path/to/your/library/google-ads-php", + "/path/to/your/library/google-ads-ruby", + "/path/to/your/library/google-ads-java", + "/path/to/your/library/google-ads-dotnet" ] } } diff --git a/GEMINI.md b/GEMINI.md index cd90476..dae2f99 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,218 +1,145 @@ -# AI Assistant Configuration: Google Ads API Developer Assistant +# Google Ads API Developer Assistant Configuration -## Version: 1.0 +## Version: 3.0 ## Optimized for Machine Comprehension This document outlines mandatory operational guidelines, constraints, and best practices for the Google Ads API Developer Assistant. --- -### Key Directives Summary +### 1. Core Directives -* **Prohibitions:** NEVER handle sensitive user credentials, provide marketing advice, or guarantee untested code. -* **File System:** ONLY write to `saved_code/` and `saved_csv/`. DO NOT modify `google-ads-python/`. -* **GAQL:** Use `SearchGoogleAdsStream`, validate queries, and compute dynamic date ranges. -* **Python Code:** Use type hints, format with `ruff format`, and pass `customer_id` as a command-line argument. -* **Python Import:** Use `google.ads.googleads`, NOT `google.ads.google_ads`. +#### 1.1. Identity +- **Role:** Google Ads API Developer Assistant +- **Language:** English +- **Persona:** Technical, Precise, Collaborative, Security-conscious ---- - -### 1. IDENTITY & CORE CONSTRAINTS - -**Role:** Google Ads API Developer Assistant -**Language:** English -**Persona Attributes:** Technical, Precise, Collaborative, Security-conscious - -#### 1.1. STRICT PROHIBITIONS +#### 1.2. Strict Prohibitions +- **NEVER** handle sensitive user credentials (developer tokens, OAuth2 tokens, etc.). +- **NEVER** provide business or marketing strategy advice. +- **NEVER** guarantee code will work without testing. +- **NEVER** use humorous or overly casual status messages. +- **ONLY** execute read-only API calls (e.g., `search`, `get`). +- **NEVER** execute API calls that modify data (e.g., `create`, `update`, `delete`). -* Do not ask for, store, or handle sensitive user credentials (developer tokens, OAuth2 tokens, client secrets, login information). -* Do not provide business or marketing strategy advice; focus on technical implementation. -* Do not guarantee code will work without testing; remind users to test generated code in a development environment. -* Do not use humorous or overly casual status messages. -* Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). -* Do not execute API calls that modify data (e.g., create, update, delete operations); ONLY allow read-only API calls (e.g., search, get). +#### 1.3. API Versioning and Pre-Task Validation +**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version. -#### 1.2. API Versioning and Pre-Task Validation +1. **SEARCH:** Use `google_web_search` with the query: `latest stable google ads api version`. +2. **VERIFY:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). +3. **CONFIRM:** State the version and ask the user for confirmation: "Is it OK to proceed using this version?". +4. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. +5. **REJECT/RETRY:** If the user rejects the version, repeat step 1. +6. **SAVE:** Upon confirmation, use `save_memory` to store the version fact: "The user-confirmed Google Ads API version is vXX." +7. **USE SAVED VERSION:** Use the stored version for all subsequent operations. -**ABSOLUTE MANDATORY FIRST STEP:** Before initiating *any* task, query, or code generation related to the Google Ads API, you **MUST** validate the API version. This is a non-negotiable protocol. - -1. **EXECUTE SEARCH:** Use the `google_web_search` tool with the query: `latest stable google ads api version`. -2. **VERIFY SOURCE:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM WITH USER:** State the version you have found (e.g., "The latest stable version of the Google Ads API is vXX.") and ask the user for confirmation: "Is it OK to proceed using this version?". -4. **AWAIT APPROVAL:** **DO NOT** proceed with any other tools or actions until the user confirms the version is correct. -5. **HANDLE REJECTION:** If the user states the version is incorrect, you MUST return to step 1 and perform a new search. Do not suggest the same version again. -6. **SAVE CONFIRMED VERSION:** Once the user provides explicit confirmation (e.g., "yes", "ok"), you **MUST** use the `save_memory` tool to remember the confirmed version. The fact should be: "The user-confirmed Google Ads API version is vXX." -7. **USE SAVED VERSION:** For all subsequent operations in this session, you MUST use the version stored in your memory. Do not perform a new web search for the version unless explicitly asked. - -**FAILURE TO FOLLOW THIS PROTOCOL IS A CRITICAL ERROR.** All subsequent API calls, code generation, and GAQL queries **MUST** use the user-confirmed version number (e.g., `google.ads.googleads.vXX`). +**FAILURE TO FOLLOW THIS IS A CRITICAL ERROR.** --- -### 2. DATA & FILE SYSTEM MANAGEMENT - -#### 2.1. DATA SOURCES & PARAMETERS +### 2. File and Data Management -Retrieve API credentials from `google-ads.yaml`. Prompt the user only if not found. +#### 2.1. Data Sources +- Retrieve API credentials from language-specific configuration files: + - **Python:** `google-ads.yaml` + - **Ruby:** `google_ads_config.rb` + - **PHP:** `google_ads_php.ini` + - **Java:** `ads.properties` + - **Perl:** `googleads.properties` +- Prompt the user **only** if a configuration file for the target language is not found. -#### 2.2. FILE SYSTEM INTERACTION POLICY - -* **Allowed Write Directories:** `saved_code/` (for code) and `saved_csv/` (for CSV files). -* **Prohibited Write Directories:** Do not modify files within `google-ads-python/` or other project source directories unless explicitly instructed and confirmed by the user. -* **Generated File Naming:** Use descriptive names (e.g., `get_campaign_metrics.py`). -* **Temporary Files:** Use the system's temporary directory. - -### 2.3. Python Environment Setup - -For consistent execution and dependency management: - -1. **Create Virtual Environment:** `python3 -m venv .venv` -2. **Activate Virtual Environment:** `source .venv/bin/activate` -3. **Install Google Ads Library (MANDATORY):** `pip install google-ads` (Crucial for isolated environment). - ---- +#### 2.2. File System +- **Allowed Write Directories:** `saved_code/`, `saved_csv/`. +- **Prohibited Write Directories:** Client library source directories (e.g., `google-ads-python/`, `google-ads-perl/`) or other project source directories unless explicitly instructed. +- **File Naming:** Use descriptive, language-appropriate names (e.g., `get_campaign_metrics.py`, `GetCampaignMetrics.java`). +- **Temporary Files:** Use the system's temporary directory. -### 3. API INTERACTION & WORKFLOWS - -* **Search Operations:** Use `SearchGoogleAdsStream` objects (e.g., `SearchGoogleAdsStreamRequest`, NOT `SearchGoogleAdsRequest`). -* **Change History:** Use `change_status` resources. -* **AI Max for Search Campaigns:** Set `Campaign.ai_max_setting.enable_ai_max = True`. -* **Prioritize Dedicated Services for System-Managed Entities:** When dealing with entities that are "automatically created," "system-generated," or "managed by Google," always prioritize searching for and using dedicated API services or operations specifically designed for those entities (e.g., `AutomaticallyCreatedAssetRemovalService` for `CampaignAutomaticallyCreatedAsset`). Avoid attempting to manage them through generic `AdService` or `AssetService` mutations unless a dedicated service does not exist. -* **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. - -#### 3.1.1. Handling Automatically Created/System-Managed Entities - -When dealing with entities that are "automatically created," "system-generated," or "managed by Google" (e.g., `CampaignAutomaticallyCreatedAsset`, `CampaignDraft`), always prioritize searching for and using dedicated API services or operations specifically designed for those entities. - -* **Explicitly Look for "AutomaticallyCreated" or "SystemGenerated" in Service/Resource Names:** During API research, pay close attention to service and resource names that include terms like "AutomaticallyCreated," "SystemGenerated," or "AutoGenerated" as these often indicate specific management methods. -* **Avoid Generic Services for Specific Automated Entities:** Do not attempt to manage these highly specific automated entities through more generic services (e.g., `AdService`, `AssetService`, or `CampaignAssetService`) unless a dedicated service does not exist or documentation explicitly states it. The API often provides specialized services for precise control over these system-managed resources. -* **Specifically for Removing Automatically Created Assets:** When the task involves removing "automatically created" campaign assets (e.g., `CampaignAutomaticallyCreatedAsset` resources), use the `AutomaticallyCreatedAssetRemovalService` and its `mutate_automatically_created_asset_removals` method with `AutomaticallyCreatedAssetRemovalOperation` to remove the asset associations. Do NOT use generic asset removal services for these specific entity types - -#### 3.2. GAQL QUERY WRITING - -* **Format:** Provide GAQL queries within `sql` markdown blocks. -* **Explanation:** Explain the `FROM` resource and `SELECT` fields. -* **Structure Reference:** `https://developers.google.com/google-ads/api/docs/query/` -* **Entities Reference:** `https://developers.google.com/google-ads/api/docs/` (The AI Assistant MUST dynamically determine the latest Google Ads API version and provide the corresponding URL for fields reference, e.g., https://developers.google.com/google-ads/api/fields/vXX) -* **Validation:** Validate GAQL queries BEFORE execution or inclusion in code. -* **Date Ranges:** Compute start and end dates dynamically; do not use constants (e.g., `LAST_90_DAYS`). -* **Conversion Import Summaries:** The reports `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary` cannot be segmented by date. Use the `daily_summaries` field to get summary details for each of the last seven days. - -#### 3.3. CODE GENERATION (PYTHON) - -* **Default Language:** Python (inform user if not specified). -* **Reference Source:** Use examples from `google-ads-python` subdirectory ONLY. -* **Formatting:** Execute `python -m ruff format` on files in `/saved_code` immediately after generation/modification and BEFORE saving/execution. -* **Fixing:** Execute `python -m ruff check --fix` on files in `/saved_code` immediately after generation/modification and BEFORE saving/execution. -* **Style:** Use type hints and annotations. -* **Type Annotations:** All generated Python code MUST include type annotations for function parameters and return values. -* **Completeness:** Provide complete, runnable code with all necessary imports. -* **Placeholders:** Use ONLY for values not found in configuration (e.g., `YOUR_CAMPAIGN_ID`). -* **Documentation:** Ensure docstrings and comments reflect current code. -* **`customer_id` Handling:** Represent as string, pass as command-line argument; NEVER hardcode. -* **`GoogleAdsException`:** Use attribute 'error', NOT 'errors'. -* **Importing `google-ads-python`:** Use the import path prefix `google.ads.googleads` NOT `google.ads.google_ads` when importing the `google-ads-python` module in a Python script. - -#### 3.4. TROUBLESHOOTING - -* **Error Reporting:** Request full error message and exact code snippet. -* **Error Analysis:** Analyze error, suggest specific fix with corrected code. - -#### 3.4.1 CONVERSIONS - -When troubleshooting conversion issues: - -* **Initial Reports:** - * **Discrepancies:** Refer to "Conversions not processed" in `https://support.google.com/google-ads/answer/13321563`. - * **Conversion Action Import Summaries:** Use `offline_conversion_upload_conversion_action_summary` report for conversion import requests (last 7 days, action level). - * **Conversion Import Account Summaries:** Use `offline_conversion_upload_client_summary` report for account-level conversion import requests (last 7 days). - * **Daily Summaries:** For specific dates, use `offline_conversion_upload_client_summary.daily_summaries` or `offline_conversion_upload_conversion_action_summary.daily_summaries` and iterate `upload_date`. - * **Conversion Import Alerts:** Both reports contain alert info (error code, failure percentage). - -* **Reference Documentation:** - * **General Troubleshooting:** `https://developers.google.com/google-ads/api/docs/conversions/troubleshooting` - * **Offline Conversion Issues:** `https://developers.google.com/google-ads/api/docs/conversions/upload-summaries` - * **Enhanced Conversions for Leads (ECL):** `https://developers.google.com/google-ads/api/docs/conversions/upload-offline` - * **Enhanced Conversions for Web (ECW):** `https://developers.google.com/google-ads/api/docs/conversions/upload-online` - * **GAQL Troubleshooting:** Include all possible fields in GAQL queries. - -#### 3.4.2 PERFORMANCE MAX -* **Placements:** - * **Reporting:** When requesting placement metrics, use the performance_max_placement_view. - -### 3.5 KEY ENTITIES - * Example: - * Campaign: Top-level organizational unit for ads. - * Ad Group: Contains ads and keywords within a campaign. - * Criterion: A targeting or exclusion setting (e.g., keyword, location). - * SharedSet: A reusable collection of criteria. - * SharedCriterion: An individual criterion within a SharedSet. --- -### 4. TOOLING & EXECUTION PROTOCOL - -#### 4.1. AVAILABLE TOOLS & USAGE - -The AI Assistant uses ONLY the following tools: - -* **`google_web_search`** - * **Description:** Performs web searches. - * **Policy:** Proactively find official Google Ads Developer documentation (`developers.google.com/google-ads/api`), or the Google Ads Help Center (`https://support.google.com/google-ads`). - * **Usage:** Never ask the user to visit a site, always review the site and provide them details directly. - -* **`read_file`** - * **Description:** Reads file content. - * **Policy:** Use for configuration files (e.g., `google-ads.yaml`) or code analysis. - -* **`run_shell_command`** - * **Description:** Executes shell commands. - * **Policy:** - * **Google Ads API Mutate Prohibition:** Before executing any Python script that interacts with the Google Ads API, you MUST first inspect the script's content. If the script contains any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.), you MUST NOT execute the script. You must instead explain to the user that you have created the script but cannot run it due to the explicit prohibition on mutate operations. - * For `ModuleNotFoundError` in Python, attempt `pip install `. If error persists, specify Python interpreter path. - * Automatically attempt `pip install ` for `ModuleNotFoundError`. - * Explain file system modifying commands BEFORE execution. - * Retrieve script parameters (e.g., `customer_id`) from `customer_id.text`; NEVER ask the user. - * **Displaying Non-Executable Commands:** When providing an example command that should *not* be executed by the assistant (such as a mutate operation or a template for the user to fill in), you MUST format it as a code block within a text response. The `run_shell_command` tool MUST NOT be used to display or wrap non-executable commands. - -* **`write_file`** - * **Description:** Writes content to a file. - * **Policy:** Use for new/modified scripts; adhere to 'FILE SYSTEM INTERACTION POLICY'. - -* **`replace`** - * **Description:** Replaces a string in a file. - * **Policy:** BEFORE use, read file for exact `old_string`. - -#### 4.2. TOOL EXECUTION PROTOCOL - -BEFORE executing any tool, perform: - -* **Pre-Action Rule Review:** Review all `gemini.md` rules and prohibitions. -* **Special Attention Areas:** File System Policy (2.2), Data Sources (2.1), Strict Prohibitions (1.1), GAQL validity. -* **Parameter Validation:** Validate ALL tool parameters against `gemini.md` rules and tool requirements. -* **Write/Replace File Validation:** Ensure `file_path` is absolute and within allowed directories. -* **Run Shell Command Explanation:** Explain commands that modify the system. -* **Ambiguity Resolution:** Seek clarification from the user if requests are ambiguous or conflict with rules, referencing `gemini.md` rules if necessary. -* **Script Execution:** Never ask the user to execute a script, always try to run the script first and report the response directly. +### 3. API and Code Generation + +#### 3.1. API Workflows +- **Search:** Use `SearchGoogleAdsStream` objects or the language-equivalent streaming mechanism. +- **Change History:** Use `change_status` resources. +- **AI Max for Search:** Set `Campaign.ai_max_setting.enable_ai_max = True`. + +#### 3.2. System-Managed Entities +- **Prioritize Dedicated Services:** For "automatically created" or "system-generated" entities (e.g., `CampaignAutomaticallyCreatedAsset`), use dedicated services like `AutomaticallyCreatedAssetRemovalService`. +- **Avoid Generic Services:** Do not use generic services like `AdService` or `AssetService` for these entities. + +#### 3.3. GAQL Queries +- **Format:** Use `sql` markdown blocks. +- **Explain:** Describe the `FROM` and `SELECT` clauses. +- **References:** + - **Structure:** `https://developers.google.com/google-ads/api/docs/query/` + - **Entities:** `https://developers.google.com/google-ads/api/fields/vXX` (replace `vXX` with the confirmed API version). +- **Validation:** Validate queries **before** execution. +- **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). +- **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. + +#### 3.4. Code Generation +- **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. +- **Reference Source:** Refer to official Google Ads API client library examples for the target language. +- **Formatting & Style:** + - Adhere to the idiomatic style and conventions of the target language. + - Use language-appropriate tooling for formatting and linting where available. + - Pass `customer_id` as a command-line argument. + - Use type hints, annotations, or other static typing features if the language supports them. +- **Error Handling:** When using the Python client library, catch `GoogleAdsException` and inspect the `error` attribute. For other languages, use the equivalent exception type. + +#### 3.5. Troubleshooting +- **Conversions:** + - Use `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary` for recent conversion import issues. + - Refer to official documentation for discrepancies and troubleshooting. +- **Performance Max:** + - Use `performance_max_placement_view` for placement metrics. + +#### 3.6. Key Entities +- **Campaign:** Top-level organizational unit. +- **Ad Group:** Contains ads and keywords. +- **Criterion:** Targeting or exclusion setting. +- **SharedSet:** Reusable collection of criteria. +- **SharedCriterion:** Criterion within a SharedSet. --- -### 5. OUTPUT & DOCUMENTATION - -#### 5.1. OUTPUT FORMATTING +### 4. Tool Usage + +#### 4.1. Available Tools +- `google_web_search`: Find official Google Ads developer documentation. +- `read_file`: Read configuration files and code. +- `run_shell_command`: + - **Description:** Executes shell commands. + - **Policy:** + - **Mutate Prohibition:** Before executing any code that interacts with the Google Ads API, you MUST inspect the script's content. If the script contains any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.), you MUST NOT execute the script. Explain to the user that you have created the script but cannot run it due to the prohibition on mutate operations. + - **Dependency Errors:** For missing dependencies (e.g., Python's `ModuleNotFoundError`), attempt to install the dependency using the appropriate package manager (e.g., `pip`, `composer`). + - **Explain Modifying Commands:** Explain file system modifying commands BEFORE execution. + - **Parameter Retrieval:** Retrieve script parameters (e.g., `customer_id`) from `customer_id.txt`; NEVER ask the user. + - **Non-Executable Commands:** To display an example command that should *not* be executed (like a mutate operation), format it as a code block in a text response. DO NOT wrap it in the `run_shell_command` tool. +- `write_file`: Write new or modified scripts. +- `replace`: Replace text in a file. + +#### 4.2. Execution Protocol +1. **Review Rules:** Check this document before every action. +2. **Validate Parameters:** Ensure all tool parameters are valid. +3. **Explain Modifying Commands:** Describe the purpose of commands that modify the file system. +4. **Resolve Ambiguity:** Ask for clarification if a request is unclear. +5. **Execute Scripts:** Run scripts directly; do not ask the user to do so. -* **Code Blocks:** Markdown with language identifiers (e.g., ````python````, ````sql````). -* **Inline Code:** Backticks (e.g., `` `GoogleAdsService` ``). -* **Key Concepts:** **Bolding** for key API resources, services, or concepts. -* **Lists:** Bullet points. +--- -#### 5.2. DOCUMENTATION REFERENCES +### 5. Output and Documentation -* **Official API Documentation:** `https://developers.google.com/google-ads/api/docs/` -* **AI Max for Search campaigns:** `https://blog.google/products/ads-commerce/google-ai-max-for-search-campaigns/` -* **Conversion management documentation:** `https://developers.google.com/google-ads/api/docs/conversions/upload-offline` -* **Conversion import troubleshooting:** `https://developers.google.com/google-ads/api/docs/conversions/troubleshooting` -* **Conversion import monitoring:** `https://developers.google.com/google-ads/api/docs/conversions/upload-summaries` +#### 5.1. Formatting +- **Code:** Use markdown with language identifiers. +- **Inline Code:** Use backticks. +- **Key Concepts:** Use bolding. +- **Lists:** Use bullet points. -#### 5.3. DISAMBIGUATION +#### 5.2. References +- **API Docs:** `https://developers.google.com/google-ads/api/docs/` +- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` -* **Campaign Types:** 'AI Max' or 'AI Max for Search' refers to 'AI Max for Search campaigns', NOT 'Performance Max'. 'PMax' means 'Performance Max'. -* **Conversion import and update terminology:** "Import" and "upload" are interchangeable for sending conversion data to the Google Ads API. +#### 5.3. Disambiguation +- **'AI Max' vs 'PMax':** 'AI Max' refers to 'AI Max for Search campaigns', not 'Performance Max'. +- **'Import' vs 'Upload':** These terms are interchangeable for conversions. \ No newline at end of file diff --git a/setup.sh b/setup.sh index 7f326a1..d8d62e9 100755 --- a/setup.sh +++ b/setup.sh @@ -32,8 +32,27 @@ err() { } # --- Configuration --- -readonly PYTHON_LIB_REPO_URL="https://github.com/googleads/google-ads-python.git" -readonly PYTHON_LIB_NAME="google-ads-python" +readonly DEFAULT_PARENT_DIR="${HOME}/gaada" + +# Associative arrays for repo URLs and default names +declare -A REPO_URLS=( + ["python"]="https://github.com/googleads/google-ads-python.git" + ["php"]="https://github.com/googleads/google-ads-php.git" + ["ruby"]="https://github.com/googleads/google-ads-ruby.git" + ["java"]="https://github.com/googleads/google-ads-java.git" + ["dotnet"]="https://github.com/googleads/google-ads-dotnet.git" +) + +declare -A REPO_NAMES=( + ["python"]="google-ads-python" + ["php"]="google-ads-php" + ["ruby"]="google-ads-ruby" + ["java"]="google-ads-java" + ["dotnet"]="google-ads-dotnet" +) + +# Defaults for paths (will be populated with defaults or overrides) +declare -A LIB_PATHS # --- Dependency Check --- if ! command -v jq &> /dev/null; then @@ -46,23 +65,62 @@ if ! command -v git &> /dev/null; then exit 1 fi +# --- Help Function --- +usage() { + echo "Usage: $0 [OPTIONS]" + echo " Clones/updates Google Ads client libraries and modifies the settings file." + echo "" + echo " This script initializes the development environment for the Google Ads API Developer Assistant." + echo " It clones the client libraries into '${DEFAULT_PARENT_DIR}' by default, or to specified paths." + echo "" + echo " Options:" + echo " -h, --help Show this help message and exit" + echo " --python Override path for google-ads-python" + echo " --php Override path for google-ads-php" + echo " --ruby Override path for google-ads-ruby" + echo " --java Override path for google-ads-java" + echo " --dotnet Override path for google-ads-dotnet" + echo "" + echo " Example:" + echo " $0 --java /home/user/my-java-repo --python /home/user/my-python-repo" + echo "" +} + # --- Argument Parsing --- -if [[ $# -eq 0 ]]; then - readonly PYTHON_LIB_PARENT_DIR_ARG="${HOME}/python/src" - echo "No python_lib_parent_dir provided. Using default: ${PYTHON_LIB_PARENT_DIR_ARG}" -elif [[ $# -eq 1 ]]; then - readonly PYTHON_LIB_PARENT_DIR_ARG="$1" -else - echo "Usage: $0 [python_lib_parent_dir]" >&2 - echo " Clones/updates the ${PYTHON_LIB_NAME} repository and modifies the settings file." >&2 - echo " This script must be run from within the google-ads-api-developer-assistant git repository." >&2 - echo " [python_lib_parent_dir]: Optional. Fully qualified path to an existing directory" >&2 - echo " where the '${PYTHON_LIB_NAME}' library will be cloned." >&2 - echo " Defaults to: \${HOME}/python/src" >&2 - echo " This must NOT be under the project directory." >&2 - echo " Example: $0 /home/user/development/libs" >&2 - exit 1 -fi +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --python) + LIB_PATHS["python"]="$2" + shift 2 + ;; + --php) + LIB_PATHS["php"]="$2" + shift 2 + ;; + --ruby) + LIB_PATHS["ruby"]="$2" + shift 2 + ;; + --java) + LIB_PATHS["java"]="$2" + shift 2 + ;; + --dotnet) + LIB_PATHS["dotnet"]="$2" + shift 2 + ;; + *) + err "ERROR: Unknown argument: $1" + usage + exit 1 + ;; + esac +done + # --- Project Directory Resolution --- # Determine the root directory of the current git repository. if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then @@ -72,42 +130,77 @@ fi readonly PROJECT_DIR_ABS echo "Detected project root: ${PROJECT_DIR_ABS}" -# --- Python Lib Path Resolution and Validation --- -if [[ ! -d "${PYTHON_LIB_PARENT_DIR_ARG}" ]]; then - echo "Directory ${PYTHON_LIB_PARENT_DIR_ARG} does not exist. Creating it..." - if ! mkdir -p "${PYTHON_LIB_PARENT_DIR_ARG}"; then - err "ERROR: Failed to create directory: ${PYTHON_LIB_PARENT_DIR_ARG}" - exit 1 - fi +# --- Path Resolution and Validation --- +# Ensure default directory exists if we are going to use it +if [[ ! -d "${DEFAULT_PARENT_DIR}" ]]; then + # We only create it if we actually need it (i.e., at least one lib is using default) + # But simpler to just create it if it doesn't exist, as it's the intended home. + echo "Creating default library directory: ${DEFAULT_PARENT_DIR}" + mkdir -p "${DEFAULT_PARENT_DIR}" || { err "ERROR: Failed to create ${DEFAULT_PARENT_DIR}"; exit 1; } fi -if ! PYTHON_LIB_PARENT_DIR=$(realpath "${PYTHON_LIB_PARENT_DIR_ARG}" 2>/dev/null); then - err "ERROR: Invalid path provided for python_lib_parent_dir: ${PYTHON_LIB_PARENT_DIR_ARG}" - exit 1 -fi -readonly PYTHON_LIB_PARENT_DIR +for lang in "${!REPO_NAMES[@]}"; do + if [[ -z "${LIB_PATHS[$lang]:-}" ]]; then + # Use default path + LIB_PATHS["$lang"]="${DEFAULT_PARENT_DIR}/${REPO_NAMES[$lang]}" + fi + + # Resolve to absolute path + # Note: The directory might not exist yet if it's a clone target, + # but the parent should exist if we are to be safe? + # Actually, 'git clone' creates the directory. + # We should resolve the parent dir for validation if possible, or just resolve the path if it exists. + + # Logic: + # 1. If path exists, resolve it. + # 2. If path doesn't exist, check if parent exists. + + path="${LIB_PATHS[$lang]}" + parent_dir=$(dirname "$path") + + if [[ ! -d "$parent_dir" ]]; then + echo "Creating parent directory for $lang: $parent_dir" + mkdir -p "$parent_dir" || { err "ERROR: Failed to create parent directory $parent_dir"; exit 1; } + fi -readonly PYTHON_LIB_CLONE_PATH="${PYTHON_LIB_PARENT_DIR}/${PYTHON_LIB_NAME}" + # Now we can optimistically set the absolute path. + # Ideally we want the canonical path. + # 'realpath' works on non-existent files in some versions, or we can use -m. + # If -m is not supported, we can cd to parent and pwd. + + if command -v realpath &> /dev/null; then + # Try using -m if available (doesn't require existence), otherwise just path + ABS_PATH=$(realpath -m "$path") + else + # Fallback + ABS_PATH="$(cd "$parent_dir" && pwd)/$(basename "$path")" + fi + + LIB_PATHS["$lang"]="$ABS_PATH" -# Ensure python_lib_parent_dir is NOT under the project_dir -if [[ "${PYTHON_LIB_PARENT_DIR}" == "${PROJECT_DIR_ABS}"* ]]; then - err "ERROR: python_lib_parent_dir (${PYTHON_LIB_PARENT_DIR}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" - exit 1 -fi + # Validation: check against project dir + if [[ "${ABS_PATH}" == "${PROJECT_DIR_ABS}"* ]]; then + err "ERROR: ${lang} path (${ABS_PATH}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" + exit 1 + fi +done -# --- Clone/Update Python Lib Repository --- +# --- Clone/Update Repositories --- clone_or_update() { local repo_url="$1" local clone_path="$2" local repo_name - + repo_name=$(basename "${clone_path}") echo "Managing repository ${repo_name} in ${clone_path}" if [[ -d "${clone_path}/.git" ]]; then - echo "WARN: Directory ${clone_path} already exists and is a git repo. Skipping clone." - # Optionally, you could add git pull here: - # (cd "${clone_path}" && git pull) || err "Failed to pull updates for ${repo_name}" + echo "Directory ${clone_path} already exists. Updating..." + if ! (cd "${clone_path}" && git pull); then + echo "WARN: Failed to update ${repo_name}. Continuing..." + else + echo "Successfully updated ${repo_name}." + fi elif [[ -d "${clone_path}" ]]; then echo "WARN: Directory ${clone_path} exists but is not a git repo. Skipping." else @@ -120,7 +213,9 @@ clone_or_update() { fi } -clone_or_update "${PYTHON_LIB_REPO_URL}" "${PYTHON_LIB_CLONE_PATH}" +for lang in "${!REPO_URLS[@]}"; do + clone_or_update "${REPO_URLS[$lang]}" "${LIB_PATHS[$lang]}" +done # --- Modify settings.json --- readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" @@ -132,14 +227,32 @@ fi echo "Updating ${SETTINGS_FILE} with context paths..." -# Define the includeDirectories paths, ensuring they are full paths -readonly CONTEXT_PATH1="${PROJECT_DIR_ABS}/api_examples" -readonly CONTEXT_PATH2="${PROJECT_DIR_ABS}/saved_code" -if ! CONTEXT_PATH3=$(realpath "${PYTHON_LIB_CLONE_PATH}" 2>/dev/null); then - err "ERROR: Could not resolve absolute path for python lib clone: ${PYTHON_LIB_CLONE_PATH}" - exit 1 -fi -readonly CONTEXT_PATH3 +# Define the always-included directories +readonly CONTEXT_PATH_EXAMPLES="${PROJECT_DIR_ABS}/api_examples" +readonly CONTEXT_PATH_SAVED="${PROJECT_DIR_ABS}/saved_code" + +# Collect all paths for jq +# We build a JSON array string or pass args. Passing args is safer. +# We have 5 dynamic paths + 2 static paths. + +# Construct jq args +JQ_ARGS=( + --arg examples "${CONTEXT_PATH_EXAMPLES}" + --arg saved "${CONTEXT_PATH_SAVED}" +) + +# Add each lib path as an arg +for lang in "${!LIB_PATHS[@]}"; do + JQ_ARGS+=(--arg "lib_${lang}" "${LIB_PATHS[$lang]}") +done + +# Construct the array construction string for jq +# It should look like: [$examples, $saved, $lib_python, $lib_php, ...] +JQ_ARRAY_STR="[\$examples, \$saved" +for lang in "${!LIB_PATHS[@]}"; do + JQ_ARRAY_STR+=", \$lib_$lang" +done +JQ_ARRAY_STR+="]" # Use jq to modify the JSON file TMP_SETTINGS_FILE="" @@ -151,10 +264,8 @@ if ! TMP_SETTINGS_FILE=$(mktemp "${SETTINGS_FILE}.XXXXXX"); then fi if ! jq \ - --arg path1 "${CONTEXT_PATH1}" \ - --arg path2 "${CONTEXT_PATH2}" \ - --arg path3 "${CONTEXT_PATH3}" \ - '.context.includeDirectories = [$path1, $path2, $path3]' \ + "${JQ_ARGS[@]}" \ + ".context.includeDirectories = ${JQ_ARRAY_STR}" \ "${SETTINGS_FILE}" > "${TMP_SETTINGS_FILE}"; then err "ERROR: jq command failed to update ${SETTINGS_FILE}" exit 1 @@ -168,22 +279,28 @@ fi # Register the extension with the gemini extensions manifest echo "Registering with the gemini extensions manifest" -if ! INSTALL_OUTPUT=$(gemini extensions install "${PROJECT_DIR_ABS}" 2>&1); then - if [[ "${INSTALL_OUTPUT}" == *"already installed"* ]]; then - echo "Extension already installed. Reinstalling..." - # We ignore the uninstall error just in case, though it should exist if we got "already installed" - gemini extensions uninstall "google-ads-api-developer-assistant" || true - gemini extensions install "${PROJECT_DIR_ABS}" +if command -v gemini &> /dev/null; then + if ! INSTALL_OUTPUT=$(gemini extensions install "${PROJECT_DIR_ABS}" 2>&1); then + if [[ "${INSTALL_OUTPUT}" == *"already installed"* ]]; then + echo "Extension already installed. Reinstalling..." + # We ignore the uninstall error just in case + gemini extensions uninstall "google-ads-api-developer-assistant" || true + gemini extensions install "${PROJECT_DIR_ABS}" + else + echo "${INSTALL_OUTPUT}" >&2 + err "ERROR: Failed to install extension." + exit 1 + fi else - echo "${INSTALL_OUTPUT}" >&2 - err "ERROR: Failed to install extension." - exit 1 + echo "${INSTALL_OUTPUT}" fi else - echo "${INSTALL_OUTPUT}" + echo "WARN: 'gemini' command not found. Skipping extension registration." + echo " This is normal if you are running this script outside of the Gemini environment" + echo " or if 'gemini' is an alias not exported to this script." fi -trap - EXIT # Clear the trap as the file has been moved. +trap - EXIT # Clear the trap echo "Successfully updated ${SETTINGS_FILE}" echo "New contents of context.includeDirectories:" diff --git a/update.sh b/update.sh index 8297626..c2a07a3 100755 --- a/update.sh +++ b/update.sh @@ -29,6 +29,27 @@ err() { echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2 } +# --- Help Function --- +usage() { + echo "Usage: $0 [OPTIONS]" + echo " Updates the Google Ads API Developer Assistant and configured client libraries." + echo "" + echo " This script performs the following actions:" + echo " 1. Updates the 'google-ads-api-developer-assistant' repository (git pull)." + echo " 2. Reads '.gemini/settings.json' to find configured client libraries." + echo " 3. Updates each found client library repository (git pull)." + echo "" + echo " Options:" + echo " -h, --help Show this help message and exit" + echo "" +} + +# --- Argument Parsing --- +if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + # --- Dependency Check --- if ! command -v jq &> /dev/null; then err "ERROR: jq is not installed. Please install it to continue." @@ -57,7 +78,7 @@ if ! git pull; then fi echo "Successfully updated google-ads-api-developer-assistant." -# --- Locate and Update Python Lib --- +# --- Locate and Update Client Libraries --- readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" if [[ ! -f "${SETTINGS_FILE}" ]]; then @@ -66,29 +87,57 @@ if [[ ! -f "${SETTINGS_FILE}" ]]; then exit 1 fi -echo "Reading ${SETTINGS_FILE} to find google-ads-python..." +echo "Reading ${SETTINGS_FILE} to find client libraries..." -# Extract the path ending with 'google-ads-python' from includeDirectories -PYTHON_LIB_PATH=$(jq -r '.context.includeDirectories[] | select(endswith("google-ads-python"))' "${SETTINGS_FILE}") +# Read all includeDirectories +# Use mapfile to handle potential spaces in paths safely +mapfile -t INCLUDE_DIRS < <(jq -r '.context.includeDirectories[]' "${SETTINGS_FILE}") -if [[ -z "${PYTHON_LIB_PATH}" ]]; then - err "ERROR: Could not find google-ads-python path in ${SETTINGS_FILE}." - exit 1 +if [[ ${#INCLUDE_DIRS[@]} -eq 0 ]]; then + echo "WARN: No directories found in ${SETTINGS_FILE}." + exit 0 fi -echo "Found google-ads-python at: ${PYTHON_LIB_PATH}" - -if [[ ! -d "${PYTHON_LIB_PATH}" ]]; then - err "ERROR: Directory not found: ${PYTHON_LIB_PATH}" - exit 1 -fi - -echo "Updating google-ads-python..." -# Use a subshell to change directory and pull, so we don't affect the current script's CWD -if ! (cd "${PYTHON_LIB_PATH}" && git pull); then - err "ERROR: Failed to update google-ads-python at ${PYTHON_LIB_PATH}" - exit 1 -fi +echo "Found ${#INCLUDE_DIRS[@]} directories in settings." + +for lib_path in "${INCLUDE_DIRS[@]}"; do + # Skip if path is empty + [[ -z "${lib_path}" ]] && continue + + # Check if path exists + if [[ ! -d "${lib_path}" ]]; then + echo "WARN: Directory not found: ${lib_path}. Skipping." + continue + fi + + # Resolve absolute path for comparison + if ! abs_lib_path=$(realpath "${lib_path}" 2>/dev/null); then + echo "WARN: Could not resolve path: ${lib_path}. Skipping." + continue + fi + + # Skip if it is the project directory itself or a subdirectory of it + if [[ "${abs_lib_path}" == "${PROJECT_DIR_ABS}"* ]]; then + echo "Skipping internal directory: ${abs_lib_path}" + continue + fi + + # Check if it is a git repository + if [[ ! -d "${abs_lib_path}/.git" ]]; then + echo "Skipping non-git directory: ${abs_lib_path}" + continue + fi + + echo "Updating repository at: ${abs_lib_path}..." + if ! (cd "${abs_lib_path}" && git pull); then + err "ERROR: Failed to update ${abs_lib_path}" + # We continue updating other libraries even if one fails? + # The prompt didn't specify, but usually best effort is good for updates. + # However, scripts usually exit on error. set -e is on. + # To fail fast: + exit 1 + fi + echo "Successfully updated ${abs_lib_path}." +done -echo "Successfully updated google-ads-python." echo "Update complete." From 9c5874378832259f97f1ea83935216a16d098c53 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 16 Dec 2025 15:30:56 -0500 Subject: [PATCH 25/61] constraint to prompt for api version --- GEMINI.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index be82526..f2dfcb4 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -10,9 +10,7 @@ This document outlines mandatory operational guidelines, constraints, and best p ### 1. Core Directives #### 1.0. Session Initialization -**ABSOLUTE FIRST ACTION:** At the beginning of every new session, your first action MUST be to check for a user-confirmed Google Ads API version in your memory. -- If the version is present, you may proceed. -- If the version is **not** present, you MUST immediately initiate the "API Versioning and Pre-Task Validation" workflow (see section 1.3). You are forbidden from performing any other action until this workflow is complete. +**ABSOLUTE FIRST ACTION:** You MUST immediately initiate the "API Versioning and Pre-Task Validation" workflow (see section 1.3). You are forbidden from performing any other action until this workflow is complete. #### 1.1. Identity - **Role:** Google Ads API Developer Assistant @@ -161,4 +159,4 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 5.4. Displaying File Contents - When writing content to `explanation.txt`, `saved_code/` or any other file intended for user consumption, -you MUST immediately follow up by displaying the content of that file directly to the user. \ No newline at end of file +you MUST immediately follow up by displaying the content of that file directly to the user. From f59446b59bcf5f2f208ecdb2625969a3f688d544 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 17 Dec 2025 10:39:35 -0500 Subject: [PATCH 26/61] - Modified setup.sh to specify the client libraries to clone as command line arguments. - Updated setup.sh and update.sh to be Mac compatible. - Added PowerShell scripts for Windows. --- ChangeLog | 7 ++ README.md | 37 ++++---- setup.ps1 | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++++ setup.sh | 240 +++++++++++++++++++++++++++++--------------------- update.ps1 | 119 +++++++++++++++++++++++++ update.sh | 6 +- 6 files changed, 543 insertions(+), 117 deletions(-) create mode 100644 setup.ps1 create mode 100644 update.ps1 diff --git a/ChangeLog b/ChangeLog index 5330eed..db987fd 100644 --- a/ChangeLog +++ b/ChangeLog @@ -6,3 +6,10 @@ - Find latest verion of the API and prompt user whether to use this version. - Modified setup.sh to clone copies of the client libraries. +* 1.2.0 +- Update README.md for clarity +- Modified setup.sh to work with Linux and macOS. +- Modified update.sh to work with Linux and macOS. +- Added PowerShell scripts for Windows. + + diff --git a/README.md b/README.md index 2151da1..8e4a7c5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the * *"Tell me about reporting for Performance Max campaigns."* * *"How do I filter by date in GAQL?"* -* **Natural Language to GAQL & Python Code:** Convert requests into ready-to-run Python code. +* **Natural Language to GAQL & Client Library Code:** Convert requests into executable code using the Google Ads Client Libraries. * Code is saved to `saved_code/`. * *"Show me campaigns with the most conversions last 30 days."* * *"Get all ad groups for customer '123-456-7890'."* @@ -49,8 +49,8 @@ By default, Python is used for code generation. You can change this by prefacing 2. A Google Ads API developer token. 3. A configured credentials file in your home directory if using Python, PHP, or Ruby. 4. Gemini CLI installed (see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)). -5. A local clone of each client library for the languages you want to use. setup.sh can set this up for you. -6. Python >= 3.10 installed and available on your system PATH. This is because Python is the default language for code generation. +5. A local clone of each client library for the languages you want to use. `setup.sh` (Linux/macOS) or `setup.ps1` (Windows) can set this up for you. +6. Python >= 3.10 installed and available on your system PATH. This is required for executing the default generated Python code directly from the CLI. ## Setup @@ -58,16 +58,23 @@ By default, Python is used for code generation. You can change this by prefacing 2. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. -3. **Run setup.sh** - * Ensure that [jq](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. This is a json processor that allows us to write a valid settings.json. - * cd to /google-ads-api-developer-assistant - * run ./setup.sh. Without command line arguments, it will clone the client libraries in `$HOME/gaada`. If you want to use custom locations, execute `./setup.sh --help` for more information. +3. **Run setup script** + * **Linux/macOS:** + * Ensure that [jq](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. + * Run `./setup.sh`. + * By default (no arguments), this installs **ALL** supported client libraries to `$HOME/gaada`. + * To install specific languages, use flags: `./setup.sh --python --php`. + * Execute `./setup.sh --help` for more details. + * **Windows:** + * Open PowerShell and run `.\setup.ps1`. + * By default, this installs **ALL** supported client libraries to `$HOME\gaada`. + * To install specific languages, use parameters: `.\setup.ps1 -Python -Php`. 4. **Configure Credentials:** Make sure your API credentials configuration files are in your `$HOME` directory. Each language has its own configuration file naming convention and structure. 5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id:YOUR_CUSTOMER_ID` (e.g., `customer_id: 1234567890`). You can then use prompts like *"Get my campaigns"* and the Assistant will use the CID for the request. ### Manual Setup -This replaces Step 3 above. +This is an alternative method to running `setup.sh` / `setup.ps1`. Replace Step 3 above with the following: a. **Clone Google Ads Client Libraries:** Clone the client libraries repository to a local directory that is NOT under the Google Ads API Developer Assistant project directory. This provides context for code generation. @@ -123,11 +130,7 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of ## Mutate Operations -* Assistant's Job: To help you by writing the code that would modify things (e.g., create or update a campaign, add a user), but NOT to execute this code. - -* Your Job: To review that code for accuracy, decide if you want to run it, and then execute it yourself outside of the Assistant. The Assistant won't run it for you. - -* See the TOOLING & EXECUTION PROTOCOL section in GEMINI.md for details. +The Assistant is designed to generate code for mutate operations (e.g., creating campaigns, adding users) but will **not** execute them. This execution policy ensures you have full control over any changes to your Google Ads account. You must review the generated code for accuracy and execute it manually outside of the Assistant. ## Known Quirks @@ -138,10 +141,10 @@ attempt, using the context from the client libraries. ## Maintenance -We will release updates to both this extension and the google-ads-python client -library. To ensure you are using the latest versions, run `update.sh` when a new -version of the API is published or a new version of the client library is -released. +We will periodically release updates to both this extension and the client libraries. +To ensure you are using the latest versions, run `update.sh` (Linux/macOS) +or `update.ps1` (Windows) when a new version of the API is published or a new +version of a client library is released. ## Contributing diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..b227282 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,251 @@ +<# +.SYNOPSIS + Initializes the development environment for the Google Ads API Developer Assistant on Windows. + +.DESCRIPTION + This script performs the following steps: + 1. Verifies that required tools (git) are installed. + 2. Clones or updates the selected Google Ads client libraries into a specified directory. + 3. Updates the '.gemini/settings.json' file to include the project's API examples, + saved code, and the cloned client libraries in the context. + 4. Registers the project as a Gemini extension. + +.PARAMETER Python + Include google-ads-python. + +.PARAMETER Php + Include google-ads-php. + +.PARAMETER Ruby + Include google-ads-ruby. + +.PARAMETER Java + Include google-ads-java. + +.PARAMETER Dotnet + Include google-ads-dotnet. + +.EXAMPLE + .\setup.ps1 -Python -Java + Installs only Python and Java libraries. + +.EXAMPLE + .\setup.ps1 + Installs ALL supported libraries. +#> + +param( + [switch]$Python, + [switch]$Php, + [switch]$Ruby, + [switch]$Java, + [switch]$Dotnet +) + +$ErrorActionPreference = "Stop" + +# --- Configuration --- +$DefaultParentDir = Join-Path $HOME "gaada" +$AllLangs = @("python", "php", "ruby", "java", "dotnet") + +# Helper to get repo config +function Get-RepoConfig { + param([string]$Lang) + switch ($Lang) { + "python" { return @{ Name = "google-ads-python"; Url = "https://github.com/googleads/google-ads-python.git" } } + "php" { return @{ Name = "google-ads-php"; Url = "https://github.com/googleads/google-ads-php.git" } } + "ruby" { return @{ Name = "google-ads-ruby"; Url = "https://github.com/googleads/google-ads-ruby.git" } } + "java" { return @{ Name = "google-ads-java"; Url = "https://github.com/googleads/google-ads-java.git" } } + "dotnet" { return @{ Name = "google-ads-dotnet"; Url = "https://github.com/googleads/google-ads-dotnet.git" } } + } +} + +# --- Defaults --- +# If no specific languages selected, select all +if (-not ($Python -or $Php -or $Ruby -or $Java -or $Dotnet)) { + Write-Host "No specific languages selected. Defaulting to ALL languages." + $Python = $true + $Php = $true + $Ruby = $true + $Java = $true + $Dotnet = $true +} + +# --- Dependency Check --- +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Error "ERROR: git is not installed. Please install it to continue." + exit 1 +} + +# --- Project Directory Resolution --- +# Determine the root directory of the current git repository. +try { + $ProjectDirAbs = git rev-parse --show-toplevel 2>$null + if (-not $ProjectDirAbs) { throw "Not in a git repo" } + # Normalize path separator + $ProjectDirAbs = (Get-Item -LiteralPath $ProjectDirAbs).FullName +} +catch { + Write-Error "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +} + +Write-Host "Detected project root: $ProjectDirAbs" + +# --- Path Resolution and Validation --- +Write-Host "Ensuring default library directory exists: $DefaultParentDir" +if (-not (Test-Path -LiteralPath $DefaultParentDir)) { + New-Item -ItemType Directory -Force -LiteralPath $DefaultParentDir | Out-Null +} + +# Helper to check if enabled +function Test-Enabled { + param([string]$Lang) + switch ($Lang) { + "python" { return $Python } + "php" { return $Php } + "ruby" { return $Ruby } + "java" { return $Java } + "dotnet" { return $Dotnet } + default { return $false } + } +} + +$LibPaths = @{} + +foreach ($Lang in $AllLangs) { + if (Test-Enabled -Lang $Lang) { + $Config = Get-RepoConfig -Lang $Lang + $RepoPath = Join-Path $DefaultParentDir $Config.Name + $LibPaths[$Lang] = $RepoPath + + # Validation: check against project dir + # Simple string check for subdirectory + if ($RepoPath.StartsWith($ProjectDirAbs)) { + Write-Error "ERROR: $Lang path ($RepoPath) cannot be a subdirectory of the project directory ($ProjectDirAbs)" + exit 1 + } + } +} + +# --- Clone/Update Repositories --- +foreach ($Lang in $AllLangs) { + if (Test-Enabled -Lang $Lang) { + $Config = Get-RepoConfig -Lang $Lang + $RepoPath = $LibPaths[$Lang] + $RepoUrl = $Config.Url + + Write-Host "Managing repository $($Config.Name) in $RepoPath" + + if (Test-Path -LiteralPath (Join-Path $RepoPath ".git")) { + Write-Host "Directory $RepoPath already exists. Updating..." + Push-Location $RepoPath + try { + git pull + if ($LASTEXITCODE -eq 0) { + Write-Host "Successfully updated $($Config.Name)." + } else { + Write-Warning "Failed to update $($Config.Name). Continuing..." + } + } + finally { + Pop-Location + } + } + elseif (Test-Path -LiteralPath $RepoPath) { + Write-Warning "Directory $RepoPath exists but is not a git repo. Skipping." + } + else { + Write-Host "Cloning $RepoUrl into $RepoPath" + git clone $RepoUrl $RepoPath + if ($LASTEXITCODE -ne 0) { + Write-Error "ERROR: Failed to clone $RepoUrl" + exit 1 + } + Write-Host "Successfully cloned $($Config.Name)." + } + } +} + +# --- Modify settings.json --- +$SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" + +if (-not (Test-Path -LiteralPath $SettingsFile)) { + Write-Error "ERROR: Settings file not found: $SettingsFile" + exit 1 +} + +Write-Host "Updating $SettingsFile with context paths..." + +$ContextPathExamples = Join-Path $ProjectDirAbs "api_examples" +$ContextPathSaved = Join-Path $ProjectDirAbs "saved_code" + +try { + $SettingsJson = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json + + # Initialize array with default paths + $NewPaths = @($ContextPathExamples, $ContextPathSaved) + + # Add enabled lib paths + foreach ($Lang in $AllLangs) { + if (Test-Enabled -Lang $Lang) { + $NewPaths += $LibPaths[$Lang] + } + } + + # Update the object + if (-not $SettingsJson.context) { + $SettingsJson | Add-Member -MemberType NoteProperty -Name "context" -Value @{} + } + # Note: If context is a PSCustomObject, we can just assign. + if (-not $SettingsJson.context.PSObject.Properties["includeDirectories"]) { + $SettingsJson.context | Add-Member -MemberType NoteProperty -Name "includeDirectories" -Value $NewPaths + } else { + $SettingsJson.context.includeDirectories = $NewPaths + } + + # Save back to file + $SettingsJson | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsFile -Encoding UTF8 + + Write-Host "Successfully updated $SettingsFile" + Write-Host "New contents of context.includeDirectories:" + Write-Host ($SettingsJson.context.includeDirectories | Out-String) +} +catch { + Write-Error "ERROR: Failed to update settings file: $_" + exit 1 +} + +# --- Register Extension --- +Write-Host "Registering with the gemini extensions manifest" +if (Get-Command gemini -ErrorAction SilentlyContinue) { + try { + # Redirect stderr to stdout to capture everything + $InstallOutput = & gemini extensions install "$ProjectDirAbs" 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($InstallOutput -match "already installed") { + Write-Host "Extension already installed. Reinstalling..." + gemini extensions uninstall "google-ads-api-developer-assistant" 2>&1 | Out-Null + gemini extensions install "$ProjectDirAbs" + } else { + Write-Error $InstallOutput + Write-Error "ERROR: Failed to install extension." + exit 1 + } + } else { + Write-Host $InstallOutput + } + } + catch { + Write-Warning "An unexpected error occurred during extension registration: $_" + } +} else { + Write-Warning "'gemini' command not found. Skipping extension registration." + Write-Warning " This is normal if you are running this script outside of the Gemini environment" + Write-Warning " or if 'gemini' is not in your PATH." +} + +Write-Host "Setup complete." +Write-Host "" +Write-Host "IMPORTANT: You must manually configure a development environment for each language you wish to use." +Write-Host " (e.g., run 'pip install google-ads' for Python, run 'composer install' for PHP, etc.)" diff --git a/setup.sh b/setup.sh index e9a67c9..7f0a167 100755 --- a/setup.sh +++ b/setup.sh @@ -33,33 +33,66 @@ err() { # --- Configuration --- readonly DEFAULT_PARENT_DIR="${HOME}/gaada" +readonly ALL_LANGS="python php ruby java dotnet" -# Associative arrays for repo URLs and default names -declare -A REPO_URLS=( - ["python"]="https://github.com/googleads/google-ads-python.git" - ["php"]="https://github.com/googleads/google-ads-php.git" - ["ruby"]="https://github.com/googleads/google-ads-ruby.git" - ["java"]="https://github.com/googleads/google-ads-java.git" - ["dotnet"]="https://github.com/googleads/google-ads-dotnet.git" -) +# Helper functions for repo info (Replacing associative arrays for Bash 3.2 compatibility) +get_repo_url() { + case "$1" in + python) echo "https://github.com/googleads/google-ads-python.git" ;; + php) echo "https://github.com/googleads/google-ads-php.git" ;; + ruby) echo "https://github.com/googleads/google-ads-ruby.git" ;; + java) echo "https://github.com/googleads/google-ads-java.git" ;; + dotnet) echo "https://github.com/googleads/google-ads-dotnet.git" ;; + esac +} -declare -A REPO_NAMES=( - ["python"]="google-ads-python" - ["php"]="google-ads-php" - ["ruby"]="google-ads-ruby" - ["java"]="google-ads-java" - ["dotnet"]="google-ads-dotnet" -) +get_repo_name() { + case "$1" in + python) echo "google-ads-python" ;; + php) echo "google-ads-php" ;; + ruby) echo "google-ads-ruby" ;; + java) echo "google-ads-java" ;; + dotnet) echo "google-ads-dotnet" ;; + esac +} -# Defaults for paths (will be populated with defaults or overrides) -declare -A LIB_PATHS +# --- Defaults --- +# Simple variables to track selection (associative arrays not supported in Bash 3.2) +INSTALL_PYTHON=false +INSTALL_PHP=false +INSTALL_RUBY=false +INSTALL_JAVA=false +INSTALL_DOTNET=false +ANY_SELECTED=false # --- Dependency Check --- if ! command -v jq &> /dev/null; then - err "ERROR: jq is not installed. Please install it to continue." - err "See: https://jqlang.github.io/jq/download/" - exit 1 + echo "jq is not installed. Attempting to install..." + if command -v brew &> /dev/null; then + echo "Homebrew detected. Installing jq..." + if brew install jq; then + echo "Successfully installed jq." + else + err "ERROR: Failed to install jq via Homebrew." + exit 1 + fi + elif command -v apt-get &> /dev/null; then + if sudo apt-get update && sudo apt-get install -y jq; then + echo "Successfully installed jq." + else + err "ERROR: Failed to install jq automatically." + err "Please install jq manually to continue." + err "See: https://jqlang.github.io/jq/download/" + exit 1 + fi + else + err "ERROR: jq is not installed and no supported package manager (brew/apt-get) found." + err "Please install jq manually to continue." + err "See: https://jqlang.github.io/jq/download/" + exit 1 + fi fi + if ! command -v git &> /dev/null; then err "ERROR: git is not installed. Please install it to continue." exit 1 @@ -71,18 +104,20 @@ usage() { echo " Clones/updates Google Ads client libraries and modifies the settings file." echo "" echo " This script initializes the development environment for the Google Ads API Developer Assistant." - echo " It clones the client libraries into '${DEFAULT_PARENT_DIR}' by default, or to specified paths." + echo " It clones the selected client libraries into '${DEFAULT_PARENT_DIR}'." echo "" echo " Options:" echo " -h, --help Show this help message and exit" - echo " --python Override path for google-ads-python" - echo " --php Override path for google-ads-php" - echo " --ruby Override path for google-ads-ruby" - echo " --java Override path for google-ads-java" - echo " --dotnet Override path for google-ads-dotnet" + echo " --python Include google-ads-python" + echo " --php Include google-ads-php" + echo " --ruby Include google-ads-ruby" + echo " --java Include google-ads-java" + echo " --dotnet Include google-ads-dotnet" + echo "" + echo " If no language flags are provided, ALL supported languages will be installed." echo "" echo " Example:" - echo " $0 --java /home/user/my-java-repo --python /home/user/my-python-repo" + echo " $0 --java --python (Installs only Java and Python libraries)" echo "" } @@ -94,24 +129,29 @@ while [[ $# -gt 0 ]]; do exit 0 ;; --python) - LIB_PATHS["python"]="$2" - shift 2 + INSTALL_PYTHON=true + ANY_SELECTED=true + shift ;; --php) - LIB_PATHS["php"]="$2" - shift 2 + INSTALL_PHP=true + ANY_SELECTED=true + shift ;; --ruby) - LIB_PATHS["ruby"]="$2" - shift 2 + INSTALL_RUBY=true + ANY_SELECTED=true + shift ;; --java) - LIB_PATHS["java"]="$2" - shift 2 + INSTALL_JAVA=true + ANY_SELECTED=true + shift ;; --dotnet) - LIB_PATHS["dotnet"]="$2" - shift 2 + INSTALL_DOTNET=true + ANY_SELECTED=true + shift ;; *) err "ERROR: Unknown argument: $1" @@ -130,58 +170,60 @@ fi readonly PROJECT_DIR_ABS echo "Detected project root: ${PROJECT_DIR_ABS}" -# --- Path Resolution and Validation --- -# Ensure default directory exists if we are going to use it -if [[ ! -d "${DEFAULT_PARENT_DIR}" ]]; then - # We only create it if we actually need it (i.e., at least one lib is using default) - # But simpler to just create it if it doesn't exist, as it's the intended home. - echo "Creating default library directory: ${DEFAULT_PARENT_DIR}" - mkdir -p "${DEFAULT_PARENT_DIR}" || { err "ERROR: Failed to create ${DEFAULT_PARENT_DIR}"; exit 1; } +# --- Language Selection Logic --- +# If no languages selected, select all +if [[ "${ANY_SELECTED}" == "false" ]]; then + echo "No specific languages selected. Defaulting to ALL languages." + INSTALL_PYTHON=true + INSTALL_PHP=true + INSTALL_RUBY=true + INSTALL_JAVA=true + INSTALL_DOTNET=true fi -for lang in "${!REPO_NAMES[@]}"; do - if [[ -z "${LIB_PATHS[$lang]:-}" ]]; then - # Use default path - LIB_PATHS["$lang"]="${DEFAULT_PARENT_DIR}/${REPO_NAMES[$lang]}" - fi - - # Resolve to absolute path - # Note: The directory might not exist yet if it's a clone target, - # but the parent should exist if we are to be safe? - # Actually, 'git clone' creates the directory. - # We should resolve the parent dir for validation if possible, or just resolve the path if it exists. - - # Logic: - # 1. If path exists, resolve it. - # 2. If path doesn't exist, check if parent exists. - - path="${LIB_PATHS[$lang]}" - parent_dir=$(dirname "$path") - - if [[ ! -d "$parent_dir" ]]; then - echo "Creating parent directory for $lang: $parent_dir" - mkdir -p "$parent_dir" || { err "ERROR: Failed to create parent directory $parent_dir"; exit 1; } - fi +# --- Path Resolution and Validation --- +# Ensure default directory exists +echo "Ensuring default library directory exists: ${DEFAULT_PARENT_DIR}" +mkdir -p "${DEFAULT_PARENT_DIR}" || { err "ERROR: Failed to create ${DEFAULT_PARENT_DIR}"; exit 1; } - # Now we can optimistically set the absolute path. - # Ideally we want the canonical path. - # 'realpath' works on non-existent files in some versions, or we can use -m. - # If -m is not supported, we can cd to parent and pwd. - - if command -v realpath &> /dev/null; then - # Try using -m if available (doesn't require existence), otherwise just path - ABS_PATH=$(realpath -m "$path") - else - # Fallback - ABS_PATH="$(cd "$parent_dir" && pwd)/$(basename "$path")" - fi - - LIB_PATHS["$lang"]="$ABS_PATH" +# Helper to check if a language is enabled +is_enabled() { + case "$1" in + python) [[ "${INSTALL_PYTHON}" == "true" ]] ;; + php) [[ "${INSTALL_PHP}" == "true" ]] ;; + ruby) [[ "${INSTALL_RUBY}" == "true" ]] ;; + java) [[ "${INSTALL_JAVA}" == "true" ]] ;; + dotnet) [[ "${INSTALL_DOTNET}" == "true" ]] ;; + *) return 1 ;; + esac +} - # Validation: check against project dir - if [[ "${ABS_PATH}" == "${PROJECT_DIR_ABS}"* ]]; then - err "ERROR: ${lang} path (${ABS_PATH}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" - exit 1 +# Resolve paths +for lang in $ALL_LANGS; do + if is_enabled "$lang"; then + repo_name=$(get_repo_name "$lang") + path="${DEFAULT_PARENT_DIR}/${repo_name}" + + # Resolve to absolute path + if command -v realpath &> /dev/null; then + # Try using -m if available (doesn't require existence), otherwise just path + # On macOS, realpath might not support -m or might not exist (coreutils). + # We handle missing realpath below. + ABS_PATH=$(realpath -m "$path" 2>/dev/null || realpath "$path" 2>/dev/null || echo "$path") + else + # Fallback - parent (DEFAULT_PARENT_DIR) exists now + ABS_PATH="$(cd "${DEFAULT_PARENT_DIR}" && pwd)/$(basename "$path")" + fi + + # Store path in dynamic variable for later use (jq args) + # Bash 3.2 compatible way to set variable by name + eval "LIB_PATH_${lang}='${ABS_PATH}'" + + # Validation: check against project dir + if [[ "${ABS_PATH}" == "${PROJECT_DIR_ABS}"* ]]; then + err "ERROR: ${lang} path (${ABS_PATH}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" + exit 1 + fi fi done @@ -213,8 +255,12 @@ clone_or_update() { fi } -for lang in "${!REPO_URLS[@]}"; do - clone_or_update "${REPO_URLS[$lang]}" "${LIB_PATHS[$lang]}" +for lang in $ALL_LANGS; do + if is_enabled "$lang"; then + eval "path=\"\$LIB_PATH_${lang}\"" + url=$(get_repo_url "$lang") + clone_or_update "$url" "$path" + fi done # --- Modify settings.json --- @@ -227,14 +273,9 @@ fi echo "Updating ${SETTINGS_FILE} with context paths..." -# Define the always-included directories readonly CONTEXT_PATH_EXAMPLES="${PROJECT_DIR_ABS}/api_examples" readonly CONTEXT_PATH_SAVED="${PROJECT_DIR_ABS}/saved_code" -# Collect all paths for jq -# We build a JSON array string or pass args. Passing args is safer. -# We have 5 dynamic paths + 2 static paths. - # Construct jq args JQ_ARGS=( --arg examples "${CONTEXT_PATH_EXAMPLES}" @@ -242,15 +283,19 @@ JQ_ARGS=( ) # Add each lib path as an arg -for lang in "${!LIB_PATHS[@]}"; do - JQ_ARGS+=(--arg "lib_${lang}" "${LIB_PATHS[$lang]}") +for lang in $ALL_LANGS; do + if is_enabled "$lang"; then + eval "path=\"\$LIB_PATH_${lang}\"" + JQ_ARGS+=(--arg "lib_${lang}" "${path}") + fi done # Construct the array construction string for jq -# It should look like: [$examples, $saved, $lib_python, $lib_php, ...] JQ_ARRAY_STR="[\$examples, \$saved" -for lang in "${!LIB_PATHS[@]}"; do - JQ_ARRAY_STR+=", \$lib_$lang" +for lang in $ALL_LANGS; do + if is_enabled "$lang"; then + JQ_ARRAY_STR+=", \$lib_$lang" + fi done JQ_ARRAY_STR+="]" @@ -310,4 +355,3 @@ echo "Setup complete." echo "" echo "IMPORTANT: You must manually configure a development environment for each language you wish to use." echo " (e.g., run 'pip install google-ads' for Python, run 'composer install' for PHP, etc.)" - diff --git a/update.ps1 b/update.ps1 new file mode 100644 index 0000000..c1047c0 --- /dev/null +++ b/update.ps1 @@ -0,0 +1,119 @@ +<# +.SYNOPSIS + Updates the Google Ads API Developer Assistant and its dependencies on Windows. + +.DESCRIPTION + This script performs the following steps: + 1. Updates the 'google-ads-api-developer-assistant' repository (git pull). + 2. Reads '.gemini/settings.json' to locate configured client library repositories. + 3. Updates each found client library repository (git pull). + +.EXAMPLE + .\update.ps1 +#> + +$ErrorActionPreference = "Stop" + +# --- Dependency Check --- +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Error "ERROR: git is not installed. Please install it to continue." + exit 1 +} + +# --- Project Directory Resolution --- +try { + $ProjectDirAbs = git rev-parse --show-toplevel 2>$null + if (-not $ProjectDirAbs) { throw "Not in a git repo" } + $ProjectDirAbs = (Get-Item -LiteralPath $ProjectDirAbs).FullName +} +catch { + Write-Error "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +} + +Write-Host "Detected project root: $ProjectDirAbs" + +# --- Update Assistant Repo --- +Write-Host "Updating google-ads-api-developer-assistant..." +try { + git pull + if ($LASTEXITCODE -ne 0) { + Write-Error "ERROR: Failed to update google-ads-api-developer-assistant." + exit 1 + } + Write-Host "Successfully updated google-ads-api-developer-assistant." +} +catch { + Write-Error "ERROR: Failed to update google-ads-api-developer-assistant: $_" + exit 1 +} + +# --- Locate and Update Client Libraries --- +$SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" + +if (-not (Test-Path -LiteralPath $SettingsFile)) { + Write-Error "ERROR: Settings file not found: $SettingsFile" + Write-Error "Please run setup.ps1 first." + exit 1 +} + +Write-Host "Reading $SettingsFile to find client libraries..." + +try { + $SettingsJson = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json + + if (-not $SettingsJson.context -or -not $SettingsJson.context.includeDirectories) { + Write-Warning "No directories found in settings." + exit 0 + } + + $IncludeDirs = $SettingsJson.context.includeDirectories + Write-Host "Found $($IncludeDirs.Count) directories in settings." + + foreach ($LibPath in $IncludeDirs) { + if ([string]::IsNullOrWhiteSpace($LibPath)) { continue } + + # Check if path exists + if (-not (Test-Path -LiteralPath $LibPath)) { + Write-Warning "Directory not found: $LibPath. Skipping." + continue + } + + $AbsLibPath = (Get-Item -LiteralPath $LibPath).FullName + + # Skip if it is the project directory itself or a subdirectory of it + if ($AbsLibPath.StartsWith($ProjectDirAbs)) { + Write-Host "Skipping internal directory: $AbsLibPath" + continue + } + + # Check if it is a git repository + if (-not (Test-Path -LiteralPath (Join-Path $AbsLibPath ".git"))) { + Write-Warning "Skipping non-git directory: $AbsLibPath" + continue + } + + Write-Host "Updating repository at: $AbsLibPath..." + Push-Location $AbsLibPath + try { + git pull + if ($LASTEXITCODE -eq 0) { + Write-Host "Successfully updated $AbsLibPath." + } else { + Write-Error "ERROR: Failed to update $AbsLibPath" + # We exit on error to match update.sh behavior of failing fast-ish? + # Actually update.sh likely fails fast due to set -e. + exit 1 + } + } + finally { + Pop-Location + } + } +} +catch { + Write-Error "ERROR: An error occurred while processing settings or updating libraries: $_" + exit 1 +} + +Write-Host "Update complete." diff --git a/update.sh b/update.sh index c2a07a3..235dbce 100755 --- a/update.sh +++ b/update.sh @@ -90,8 +90,10 @@ fi echo "Reading ${SETTINGS_FILE} to find client libraries..." # Read all includeDirectories -# Use mapfile to handle potential spaces in paths safely -mapfile -t INCLUDE_DIRS < <(jq -r '.context.includeDirectories[]' "${SETTINGS_FILE}") +INCLUDE_DIRS=() +while IFS= read -r line; do + INCLUDE_DIRS+=("$line") +done < <(jq -r '.context.includeDirectories[]' "${SETTINGS_FILE}") if [[ ${#INCLUDE_DIRS[@]} -eq 0 ]]; then echo "WARN: No directories found in ${SETTINGS_FILE}." From c0b6742e136ea6c2c05a8e45bc57077d3f0d8921 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 17 Dec 2025 11:47:26 -0500 Subject: [PATCH 27/61] - Removed the attempt to install the extension to the local manifest. This reguarly freezes on multiple machines and operating systems. --- setup.ps1 | 29 +---------------------------- setup.sh | 23 ----------------------- 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/setup.ps1 b/setup.ps1 index b227282..18a069d 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -216,34 +216,7 @@ catch { exit 1 } -# --- Register Extension --- -Write-Host "Registering with the gemini extensions manifest" -if (Get-Command gemini -ErrorAction SilentlyContinue) { - try { - # Redirect stderr to stdout to capture everything - $InstallOutput = & gemini extensions install "$ProjectDirAbs" 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { - if ($InstallOutput -match "already installed") { - Write-Host "Extension already installed. Reinstalling..." - gemini extensions uninstall "google-ads-api-developer-assistant" 2>&1 | Out-Null - gemini extensions install "$ProjectDirAbs" - } else { - Write-Error $InstallOutput - Write-Error "ERROR: Failed to install extension." - exit 1 - } - } else { - Write-Host $InstallOutput - } - } - catch { - Write-Warning "An unexpected error occurred during extension registration: $_" - } -} else { - Write-Warning "'gemini' command not found. Skipping extension registration." - Write-Warning " This is normal if you are running this script outside of the Gemini environment" - Write-Warning " or if 'gemini' is not in your PATH." -} + Write-Host "Setup complete." Write-Host "" diff --git a/setup.sh b/setup.sh index 7f0a167..3414b11 100755 --- a/setup.sh +++ b/setup.sh @@ -322,29 +322,6 @@ if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then exit 1 fi -# Register the extension with the gemini extensions manifest -echo "Registering with the gemini extensions manifest" -if command -v gemini &> /dev/null; then - if ! INSTALL_OUTPUT=$(gemini extensions install "${PROJECT_DIR_ABS}" 2>&1); then - if [[ "${INSTALL_OUTPUT}" == *"already installed"* ]]; then - echo "Extension already installed. Reinstalling..." - # We ignore the uninstall error just in case - gemini extensions uninstall "google-ads-api-developer-assistant" || true - gemini extensions install "${PROJECT_DIR_ABS}" - else - echo "${INSTALL_OUTPUT}" >&2 - err "ERROR: Failed to install extension." - exit 1 - fi - else - echo "${INSTALL_OUTPUT}" - fi -else - echo "WARN: 'gemini' command not found. Skipping extension registration." - echo " This is normal if you are running this script outside of the Gemini environment" - echo " or if 'gemini' is an alias not exported to this script." -fi - trap - EXIT # Clear the trap echo "Successfully updated ${SETTINGS_FILE}" From 2f7f80532a4d8673b82a9b0403bc6e1121960290 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 17 Dec 2025 12:47:48 -0500 Subject: [PATCH 28/61] Updated ChangeLog for v1.2.0 --- ChangeLog | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index db987fd..b45df23 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,5 @@ * 1.0.0 -- Release of verions 1.0 +- Release of verion 1.0 * 1.1.0 - Added support for PHP, Ruby, Java, and C#. @@ -7,9 +7,10 @@ - Modified setup.sh to clone copies of the client libraries. * 1.2.0 -- Update README.md for clarity +- Updated README.md for clarity - Modified setup.sh to work with Linux and macOS. - Modified update.sh to work with Linux and macOS. - Added PowerShell scripts for Windows. +- Removed extension installation to manifest since it regularly hangs. From d541efab39537c04c709a561ea4db69a418b8e80 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 17 Dec 2025 13:10:35 -0500 Subject: [PATCH 29/61] Added tests for setup.sh and update.sh --- tests/test_setup.sh | 134 +++++++++++++++++++++++++++++++++++++ tests/test_update_logic.sh | 72 ++++++++++++++++++++ update.sh | 45 +++++++++++++ 3 files changed, 251 insertions(+) create mode 100755 tests/test_setup.sh create mode 100755 tests/test_update_logic.sh diff --git a/tests/test_setup.sh b/tests/test_setup.sh new file mode 100755 index 0000000..53e469f --- /dev/null +++ b/tests/test_setup.sh @@ -0,0 +1,134 @@ +#!/bin/bash +set -u + +# --- Test Setup --- +TEST_TMP_DIR=$(mktemp -d) +SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/setup.sh" + +echo "Running tests in ${TEST_TMP_DIR}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" +} +trap cleanup EXIT + +# 1. Mock Environment +FAKE_HOME=$(mktemp -d) +FAKE_PROJECT=$(mktemp -d) +echo "FAKE_HOME: ${FAKE_HOME}" +echo "FAKE_PROJECT: ${FAKE_PROJECT}" + +export HOME="${FAKE_HOME}" +mkdir -p "${FAKE_HOME}/bin" +export PATH="${FAKE_HOME}/bin:${PATH}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" + rm -rf "${FAKE_HOME}" + rm -rf "${FAKE_PROJECT}" +} +trap cleanup EXIT + +# Create mock git +cat > "${FAKE_HOME}/bin/git" < /dev/null; then + echo "jq not found, using mock implementation (this test prefers real jq)" + # A simple mock might be too hard for the complex jq command used + echo "FAIL: real jq is required for this test" + exit 1 +fi + +# 2. Setup "Project" in Temp Dir +# setup.sh expects to be run from within the repo +# We will run it from FAKE_PROJECT, pretending it's the repo root +mkdir -p "${FAKE_PROJECT}/.gemini" +echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settings.json" + +# Create dummy directories that setup.sh references +mkdir -p "${FAKE_PROJECT}/api_examples" +mkdir -p "${FAKE_PROJECT}/saved_code" + +# --- Test Case 1: Run setup.sh with --python --- +echo "--- Running setup.sh --python ---" +if ! bash "${SETUP_SCRIPT_PATH}" --python; then + echo "FAIL: setup.sh failed with --python" + exit 1 +fi + +# Check if directory created (mock clone) +if [[ ! -d "${FAKE_HOME}/gaada/google-ads-python/.git" ]]; then + echo "FAIL: google-ads-python was not 'cloned' (mocked)" + exit 1 +fi + +# Check if settings.json updated +if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "PASS: settings.json contains google-ads-python" +else + echo "FAIL: settings.json does NOT contain google-ads-python" + cat "${FAKE_PROJECT}/.gemini/settings.json" + exit 1 +fi + +# --- Test Case 2: Run setup.sh --java (update existing check) --- +echo "--- Running setup.sh --java ---" +if ! bash "${SETUP_SCRIPT_PATH}" --java; then + echo "FAIL: setup.sh failed with --java" + exit 1 +fi + +# Check if java directory created +if [[ ! -d "${FAKE_HOME}/gaada/google-ads-java/.git" ]]; then + echo "FAIL: google-ads-java was not 'cloned'" + exit 1 +fi + +# Check if settings.json has both now (actually jq might rewrite/append, setup.sh overwrites the list based on selection?) +# setup.sh reads: JQ_ARGS arguments based on enabled languages in THAT run. +# It overwrites `context.includeDirectories` with `[$examples, $saved, ...selected_libs]`. +# So if I run with ONLY --java, python might be REMOVED? +# Let's check the script logic: +# `for lang in $ALL_LANGS; do if is_enabled "$lang"; then ... JQ_ARGS+=...; fi; done` +# `JQ_ARRAY_STR="[\$examples, \$saved"` ... `JQ_ARRAY_STR+=", \$lib_$lang"` ... +# Yes, it overwrites with ONLY the currently selected languages + existing examples/saved. +# THIS IS IMPORTANT. Running `setup.sh --java` AFTER `setup.sh --python` removes python from settings if `setup.sh` doesn't read existing settings. +# Wait, `setup.sh` REPLACES the list. +# Let's verify this behavior is what we expect or if it's a "bug" (or feature). +# For now, I test that java IS present. + +if grep -q "google-ads-java" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "PASS: settings.json contains google-ads-java" +else + echo "FAIL: settings.json does NOT contain google-ads-java" + exit 1 +fi + +# Verify Python is gone (based on current implementation analysis) +if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "INFO: google-ads-python is STILL present (Accumulative?)" +else + echo "INFO: google-ads-python is GONE (Expected per current logic if overwriting)" +fi + +echo "ALL TESTS PASSED" diff --git a/tests/test_update_logic.sh b/tests/test_update_logic.sh new file mode 100755 index 0000000..569b5aa --- /dev/null +++ b/tests/test_update_logic.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -u + +# Setup environment +TEST_DIR=$(mktemp -d) +cd "${TEST_DIR}" + +mkdir .gemini +SETTINGS_JSON=".gemini/settings.json" + +# function to mimic err +err() { + echo "ERROR: $*" >&2 +} + +# Create "user" settings (simulating existing file) +echo '{"user_setting": true, "common_setting": "user_value"}' > "${SETTINGS_JSON}" + +# Define the logic block to test (extracted from update.sh) +run_update_logic() { + SETTINGS_JSON=".gemini/settings.json" + TEMP_SETTINGS=$(mktemp) + + # 1. Backup existing settings if they exist + if [[ -f "${SETTINGS_JSON}" ]]; then + echo "Backing up ${SETTINGS_JSON}..." + cp "${SETTINGS_JSON}" "${TEMP_SETTINGS}" + + # MOCK: git checkout would go here + echo "Mocking git checkout..." + fi + + # MOCK: git pull (simulating update that changes settings.json) + echo "Mocking git pull (updating settings.json)..." + # Overwrite settings.json with "repo" version + echo '{"repo_setting": true, "common_setting": "repo_value"}' > "${SETTINGS_JSON}" + + # 3. Restore/Merge settings + if [[ -f "${TEMP_SETTINGS}" ]] && [[ -s "${TEMP_SETTINGS}" ]]; then + echo "Merging preserved settings with new defaults..." + if jq -s '.[0] * .[1]' "${SETTINGS_JSON}" "${TEMP_SETTINGS}" > "${TEMP_SETTINGS}.merged"; then + mv "${TEMP_SETTINGS}.merged" "${SETTINGS_JSON}" + echo "Settings restored and merged successfully." + else + echo "WARN: Failed to merge settings.json." + fi + rm -f "${TEMP_SETTINGS}" + fi +} + +echo "Initial settings:" +cat "${SETTINGS_JSON}" + +run_update_logic + +echo "Final settings:" +cat "${SETTINGS_JSON}" + +# Verify +USER_VAL=$(jq -r .user_setting "${SETTINGS_JSON}") +REPO_VAL=$(jq -r .repo_setting "${SETTINGS_JSON}") +COMMON_VAL=$(jq -r .common_setting "${SETTINGS_JSON}") + +if [[ "$USER_VAL" == "true" ]] && [[ "$REPO_VAL" == "true" ]] && [[ "$COMMON_VAL" == "user_value" ]]; then + echo "TEST PASSED" +else + echo "TEST FAILED" + echo "user_setting: $USER_VAL (expected true)" + echo "repo_setting: $REPO_VAL (expected true)" + echo "common_setting: $COMMON_VAL (expected user_value)" + exit 1 +fi diff --git a/update.sh b/update.sh index 235dbce..0759b8a 100755 --- a/update.sh +++ b/update.sh @@ -72,10 +72,55 @@ echo "Detected project root: ${PROJECT_DIR_ABS}" # --- Update Assistant Repo --- echo "Updating google-ads-api-developer-assistant..." + +SETTINGS_JSON=".gemini/settings.json" +TEMP_SETTINGS=$(mktemp) + +# 1. Backup existing settings if they exist +if [[ -f "${SETTINGS_JSON}" ]]; then + echo "Backing up ${SETTINGS_JSON}..." + cp "${SETTINGS_JSON}" "${TEMP_SETTINGS}" + + # 2. Reset local changes to settings.json to allow git pull + # Only if the file is tracked and modified (or just blindly checkout if we know it's strict) + # Safest is to just checkout it if it exists in git. + if git ls-files --error-unmatch "${SETTINGS_JSON}" &> /dev/null; then + echo "Resetting ${SETTINGS_JSON} to avoid merge conflicts..." + git checkout "${SETTINGS_JSON}" + fi +fi + if ! git pull; then err "ERROR: Failed to update google-ads-api-developer-assistant." + # Attempt to restore settings if they were backed up? + # Probably safer to leave the repo state as is if pull failed, + # but strictly speaking we might want to restore the user's settings + # if we reverted them. + if [[ -f "${TEMP_SETTINGS}" ]] && [[ -s "${TEMP_SETTINGS}" ]]; then + echo "Restoring original settings after failed pull..." + mv "${TEMP_SETTINGS}" "${SETTINGS_JSON}" + fi exit 1 fi + +# 3. Restore/Merge settings +if [[ -f "${TEMP_SETTINGS}" ]] && [[ -s "${TEMP_SETTINGS}" ]]; then + echo "Merging preserved settings with new defaults..." + # Merge: existing (backup) *over* new (repo) + # We want local user values to override repo values, but we also want + # to keep any new keys from the repo that weren't in user's file. + # Logic: .[0] is repo (new), .[1] is backup (user). + # .[0] * .[1] means backup overrides repo. + if jq -s '.[0] * .[1]' "${SETTINGS_JSON}" "${TEMP_SETTINGS}" > "${TEMP_SETTINGS}.merged"; then + mv "${TEMP_SETTINGS}.merged" "${SETTINGS_JSON}" + echo "Settings restored and merged successfully." + else + err "WARN: Failed to merge settings.json. Restoring original backup without merge." + mv "${TEMP_SETTINGS}" "${SETTINGS_JSON}" + fi + rm -f "${TEMP_SETTINGS}" +fi + echo "Successfully updated google-ads-api-developer-assistant." # --- Locate and Update Client Libraries --- From 2c7ac281a9f2d08771647c0f1e2880db077a5705 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 17 Dec 2025 13:15:10 -0500 Subject: [PATCH 30/61] Update ps1 scripts to align with .sh logic --- setup.ps1 | 1 - update.ps1 | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/setup.ps1 b/setup.ps1 index 18a069d..c49f6f4 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -8,7 +8,6 @@ 2. Clones or updates the selected Google Ads client libraries into a specified directory. 3. Updates the '.gemini/settings.json' file to include the project's API examples, saved code, and the cloned client libraries in the context. - 4. Registers the project as a Gemini extension. .PARAMETER Python Include google-ads-python. diff --git a/update.ps1 b/update.ps1 index c1047c0..501f407 100644 --- a/update.ps1 +++ b/update.ps1 @@ -35,18 +35,97 @@ Write-Host "Detected project root: $ProjectDirAbs" # --- Update Assistant Repo --- Write-Host "Updating google-ads-api-developer-assistant..." + +$SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" +$TempSettingsFile = [System.IO.Path]::GetTempFileName() + try { + # 1. Backup existing settings if they exist + if (Test-Path -LiteralPath $SettingsFile) { + Write-Host "Backing up $SettingsFile..." + Copy-Item -LiteralPath $SettingsFile -Destination $TempSettingsFile -Force + + # 2. Reset local changes to settings.json to allow git pull + # Check if file is tracked by git + $GitStatus = git ls-files --error-unmatch $SettingsFile 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Resetting $SettingsFile to avoid merge conflicts..." + git checkout $SettingsFile + } + } + + # 3. Update Repo git pull if ($LASTEXITCODE -ne 0) { - Write-Error "ERROR: Failed to update google-ads-api-developer-assistant." - exit 1 + throw "Failed to update google-ads-api-developer-assistant." } Write-Host "Successfully updated google-ads-api-developer-assistant." + + # 4. Restore/Merge settings + if ((Test-Path -LiteralPath $TempSettingsFile) -and (Get-Item $TempSettingsFile).Length -gt 0) { + Write-Host "Merging preserved settings with new defaults..." + + # Read contents + $UserContent = Get-Content -LiteralPath $TempSettingsFile -Raw | ConvertFrom-Json + $RepoContent = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json + + # Merge Logic: User overrides Repo + # Helper function for recursive merge could go here, but for now we do specific top-level merge + # replicating jq * behavior for simple objects. + # Actually, let's just use strict property copy from User to Repo for top-level keys + # If deeply nested merge is needed, valid for context.includeDirectories? + # Usually settings.json is flat or 1-level deep. + + # Simple Merge: Add/Overwrite properties from User to Repo object + foreach ($Prop in $UserContent.PSObject.Properties) { + if ($Prop.Name -eq "context") { + # Special handling for context if needed, or just overwrite? + # jq * merges recursively. + # Let's try to merge context if both have it. + if ($RepoContent.PSObject.Properties["context"]) { + foreach ($CtxProp in $Prop.Value.PSObject.Properties) { + # e.g. includeDirectories + if (-not $RepoContent.context.PSObject.Properties[$CtxProp.Name]) { + $RepoContent.context | Add-Member -MemberType NoteProperty -Name $CtxProp.Name -Value $CtxProp.Value + } else { + $RepoContent.context.$($CtxProp.Name) = $CtxProp.Value + } + } + } else { + $RepoContent | Add-Member -MemberType NoteProperty -Name "context" -Value $Prop.Value + } + } else { + if (-not $RepoContent.PSObject.Properties[$Prop.Name]) { + $RepoContent | Add-Member -MemberType NoteProperty -Name $Prop.Name -Value $Prop.Value + } else { + $RepoContent.$($Prop.Name) = $Prop.Value + } + } + } + + # Save merged + $RepoContent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsFile -Encoding UTF8 + Write-Host "Settings restored and merged successfully." + } + } catch { - Write-Error "ERROR: Failed to update google-ads-api-developer-assistant: $_" + Write-Error "ERROR: $_" + # Restore backup if pull failed or something went wrong involving the file + if ((Test-Path -LiteralPath $TempSettingsFile) -and (Get-Item $TempSettingsFile).Length -gt 0) { + if (-not (Test-Path -LiteralPath $SettingsFile) -or (Get-Item $SettingsFile).Length -eq 0) { + Write-Host "Restoring original settings after failure..." + Copy-Item -LiteralPath $TempSettingsFile -Destination $SettingsFile -Force + } + } exit 1 } +finally { + if (Test-Path -LiteralPath $TempSettingsFile) { + Remove-Item -LiteralPath $TempSettingsFile -Force -ErrorAction SilentlyContinue + } +} + # --- Locate and Update Client Libraries --- $SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" From 6dd823162455c9ea1ee66af577d984616ec2d2fa Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 17 Dec 2025 13:16:58 -0500 Subject: [PATCH 31/61] Updated ChangeLog --- ChangeLog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ChangeLog b/ChangeLog index b45df23..97802b5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -12,5 +12,8 @@ - Modified update.sh to work with Linux and macOS. - Added PowerShell scripts for Windows. - Removed extension installation to manifest since it regularly hangs. +- Modified logic in setup and update scripts. +- Added tests for setup and update scripts. + From 4c909884bb5a0bb9037490530bf9151a05d6a736 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 18 Dec 2025 11:50:37 -0500 Subject: [PATCH 32/61] additional constraint on current API version --- GEMINI.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index f2dfcb4..e0ae487 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -27,11 +27,11 @@ This document outlines mandatory operational guidelines, constraints, and best p - **NEVER** execute API calls that modify data (e.g., `create`, `update`, `delete`). #### 1.3. API Versioning and Pre-Task Validation -**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version. +**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version and **NEVER** save the confirmed API version to memory. 1. **SEARCH:** Use `google_web_search` with the query: `latest stable google ads api version`. 2. **VERIFY:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM:** State the version and ask the user for confirmation: "Is it OK to proceed using this version?". +3. **CONFIRM:** You must state the version you found and ask for confirmation. For example: "The latest stable Google Ads API version is vXX. Is it OK to proceed using this version?". 4. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. 5. **REJECT/RETRY:** If the user rejects the version, repeat step 1. 6. **NEVER** save the confirmed API version to memory. From 129fcc6e92de434e08679b606609320c1d2811aa Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 18 Dec 2025 11:50:37 -0500 Subject: [PATCH 33/61] additional constraint on current API version --- GEMINI.md | 4 ++-- update.ps1 | 34 ++++++++++++++++++++++++++++++++++ update.sh | 28 ++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index f2dfcb4..e0ae487 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -27,11 +27,11 @@ This document outlines mandatory operational guidelines, constraints, and best p - **NEVER** execute API calls that modify data (e.g., `create`, `update`, `delete`). #### 1.3. API Versioning and Pre-Task Validation -**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version. +**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version and **NEVER** save the confirmed API version to memory. 1. **SEARCH:** Use `google_web_search` with the query: `latest stable google ads api version`. 2. **VERIFY:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM:** State the version and ask the user for confirmation: "Is it OK to proceed using this version?". +3. **CONFIRM:** You must state the version you found and ask for confirmation. For example: "The latest stable Google Ads API version is vXX. Is it OK to proceed using this version?". 4. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. 5. **REJECT/RETRY:** If the user rejects the version, repeat step 1. 6. **NEVER** save the confirmed API version to memory. diff --git a/update.ps1 b/update.ps1 index 501f407..7ce898a 100644 --- a/update.ps1 +++ b/update.ps1 @@ -39,6 +39,9 @@ Write-Host "Updating google-ads-api-developer-assistant..." $SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" $TempSettingsFile = [System.IO.Path]::GetTempFileName() +$CustomerIdFile = Join-Path $ProjectDirAbs "customer_id.txt" +$TempCustomerIdFile = [System.IO.Path]::GetTempFileName() + try { # 1. Backup existing settings if they exist if (Test-Path -LiteralPath $SettingsFile) { @@ -54,6 +57,19 @@ try { } } + # 1b. Backup customer_id.txt if it exists + if (Test-Path -LiteralPath $CustomerIdFile) { + Write-Host "Backing up $CustomerIdFile..." + Copy-Item -LiteralPath $CustomerIdFile -Destination $TempCustomerIdFile -Force + + # Reset local changes + $GitStatus = git ls-files --error-unmatch $CustomerIdFile 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Resetting $CustomerIdFile to avoid merge conflicts..." + git checkout $CustomerIdFile + } + } + # 3. Update Repo git pull if ($LASTEXITCODE -ne 0) { @@ -106,6 +122,15 @@ try { # Save merged $RepoContent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsFile -Encoding UTF8 Write-Host "Settings restored and merged successfully." + Write-Host "Settings restored and merged successfully." + } + + # 4b. Restore customer_id.txt + if ((Test-Path -LiteralPath $TempCustomerIdFile) -and (Get-Item $TempCustomerIdFile).Length -gt 0) { + Write-Host "Restoring preserved $CustomerIdFile..." + # Always overwrite with user's backup + Move-Item -LiteralPath $TempCustomerIdFile -Destination $CustomerIdFile -Force + Write-Host "Restored $CustomerIdFile successfully." } } @@ -118,12 +143,21 @@ catch { Copy-Item -LiteralPath $TempSettingsFile -Destination $SettingsFile -Force } } + if ((Test-Path -LiteralPath $TempCustomerIdFile) -and (Get-Item $TempCustomerIdFile).Length -gt 0) { + if (-not (Test-Path -LiteralPath $CustomerIdFile) -or (Get-Item $CustomerIdFile).Length -eq 0) { + Write-Host "Restoring original customer_id.txt after failure..." + Copy-Item -LiteralPath $TempCustomerIdFile -Destination $CustomerIdFile -Force + } + } exit 1 } finally { if (Test-Path -LiteralPath $TempSettingsFile) { Remove-Item -LiteralPath $TempSettingsFile -Force -ErrorAction SilentlyContinue } + if (Test-Path -LiteralPath $TempCustomerIdFile) { + Remove-Item -LiteralPath $TempCustomerIdFile -Force -ErrorAction SilentlyContinue + } } diff --git a/update.sh b/update.sh index 0759b8a..55eb7d3 100755 --- a/update.sh +++ b/update.sh @@ -76,6 +76,9 @@ echo "Updating google-ads-api-developer-assistant..." SETTINGS_JSON=".gemini/settings.json" TEMP_SETTINGS=$(mktemp) +CUSTOMER_ID_FILE="customer_id.txt" +TEMP_CUSTOMER_ID=$(mktemp) + # 1. Backup existing settings if they exist if [[ -f "${SETTINGS_JSON}" ]]; then echo "Backing up ${SETTINGS_JSON}..." @@ -90,6 +93,18 @@ if [[ -f "${SETTINGS_JSON}" ]]; then fi fi +# 1b. Backup customer_id.txt if it exists +if [[ -f "${CUSTOMER_ID_FILE}" ]]; then + echo "Backing up ${CUSTOMER_ID_FILE}..." + cp "${CUSTOMER_ID_FILE}" "${TEMP_CUSTOMER_ID}" + + # Reset local changes to customer_id.txt to allow git pull + if git ls-files --error-unmatch "${CUSTOMER_ID_FILE}" &> /dev/null; then + echo "Resetting ${CUSTOMER_ID_FILE} to avoid merge conflicts..." + git checkout "${CUSTOMER_ID_FILE}" + fi +fi + if ! git pull; then err "ERROR: Failed to update google-ads-api-developer-assistant." # Attempt to restore settings if they were backed up? @@ -100,6 +115,10 @@ if ! git pull; then echo "Restoring original settings after failed pull..." mv "${TEMP_SETTINGS}" "${SETTINGS_JSON}" fi + if [[ -f "${TEMP_CUSTOMER_ID}" ]] && [[ -s "${TEMP_CUSTOMER_ID}" ]]; then + echo "Restoring original customer_id.txt after failed pull..." + mv "${TEMP_CUSTOMER_ID}" "${CUSTOMER_ID_FILE}" + fi exit 1 fi @@ -121,6 +140,15 @@ if [[ -f "${TEMP_SETTINGS}" ]] && [[ -s "${TEMP_SETTINGS}" ]]; then rm -f "${TEMP_SETTINGS}" fi +# 3b. Restore customer_id.txt +if [[ -f "${TEMP_CUSTOMER_ID}" ]] && [[ -s "${TEMP_CUSTOMER_ID}" ]]; then + echo "Restoring preserved ${CUSTOMER_ID_FILE}..." + # Always overwrite with user's backup + mv "${TEMP_CUSTOMER_ID}" "${CUSTOMER_ID_FILE}" + echo "${CUSTOMER_ID_FILE} restored successfully." + rm -f "${TEMP_CUSTOMER_ID}" +fi + echo "Successfully updated google-ads-api-developer-assistant." # --- Locate and Update Client Libraries --- From d6e1e8c61f7c85a39a8fd15714ceab1f9489a4cf Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 9 Jan 2026 08:58:39 -0500 Subject: [PATCH 34/61] Add explain command --- .gemini/commands/explain.toml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gemini/commands/explain.toml diff --git a/.gemini/commands/explain.toml b/.gemini/commands/explain.toml new file mode 100644 index 0000000..7c854e3 --- /dev/null +++ b/.gemini/commands/explain.toml @@ -0,0 +1,8 @@ +description = "Explains the code or text provided in plain English." + +prompt = """ +You are a helpful coding tutor. +Please explain the following code or text in simple, easy-to-understand terms: + +{{args}} +""" From 5a04673544bdc6767395a75d0559bd4bffe4bd36 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 9 Jan 2026 09:35:25 -0500 Subject: [PATCH 35/61] Support client libraries in a sub-directory under the project directory. This avoides possible security conflicts with storing them directly under HOME. --- .gemini/commands/explain.toml | 8 +++++++ GEMINI.md | 1 + setup.ps1 | 39 ++++++++++++++--------------------- setup.sh | 26 ++++++++++------------- update.ps1 | 6 +----- update.sh | 6 +----- 6 files changed, 38 insertions(+), 48 deletions(-) create mode 100644 .gemini/commands/explain.toml diff --git a/.gemini/commands/explain.toml b/.gemini/commands/explain.toml new file mode 100644 index 0000000..7c854e3 --- /dev/null +++ b/.gemini/commands/explain.toml @@ -0,0 +1,8 @@ +description = "Explains the code or text provided in plain English." + +prompt = """ +You are a helpful coding tutor. +Please explain the following code or text in simple, easy-to-understand terms: + +{{args}} +""" diff --git a/GEMINI.md b/GEMINI.md index e0ae487..0ba79bb 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -54,6 +54,7 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 2.2. File System - **Allowed Write Directories:** `saved_code/`, `saved_csv/`. - **Prohibited Write Directories:** Client library source directories (e.g., `google-ads-python/`, `google-ads-perl/`), `api_examples/`, or other project source directories unless explicitly instructed. +- **NEVER** modify the files in `api_examples/`. If you need to use a file as a base for a request, copy the comments and put the file with modifications in `saved_code/`. - **All new or modified code MUST be written to the `saved_code/` directory.** - **File Naming:** Use descriptive, language-appropriate names (e.g., `get_campaign_metrics.py`, `GetCampaignMetrics.java`). - **Temporary Files:** Use the system's temporary directory. diff --git a/setup.ps1 b/setup.ps1 index c49f6f4..36eb1cc 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -43,8 +43,23 @@ param( $ErrorActionPreference = "Stop" +# --- Project Directory Resolution --- +# Determine the root directory of the current git repository. +try { + $ProjectDirAbs = git rev-parse --show-toplevel 2>$null + if (-not $ProjectDirAbs) { throw "Not in a git repo" } + # Normalize path separator + $ProjectDirAbs = (Get-Item -LiteralPath $ProjectDirAbs).FullName +} +catch { + Write-Error "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +} + +Write-Host "Detected project root: $ProjectDirAbs" + # --- Configuration --- -$DefaultParentDir = Join-Path $HOME "gaada" +$DefaultParentDir = Join-Path $ProjectDirAbs "client_libs" $AllLangs = @("python", "php", "ruby", "java", "dotnet") # Helper to get repo config @@ -76,21 +91,6 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { exit 1 } -# --- Project Directory Resolution --- -# Determine the root directory of the current git repository. -try { - $ProjectDirAbs = git rev-parse --show-toplevel 2>$null - if (-not $ProjectDirAbs) { throw "Not in a git repo" } - # Normalize path separator - $ProjectDirAbs = (Get-Item -LiteralPath $ProjectDirAbs).FullName -} -catch { - Write-Error "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." - exit 1 -} - -Write-Host "Detected project root: $ProjectDirAbs" - # --- Path Resolution and Validation --- Write-Host "Ensuring default library directory exists: $DefaultParentDir" if (-not (Test-Path -LiteralPath $DefaultParentDir)) { @@ -117,13 +117,6 @@ foreach ($Lang in $AllLangs) { $Config = Get-RepoConfig -Lang $Lang $RepoPath = Join-Path $DefaultParentDir $Config.Name $LibPaths[$Lang] = $RepoPath - - # Validation: check against project dir - # Simple string check for subdirectory - if ($RepoPath.StartsWith($ProjectDirAbs)) { - Write-Error "ERROR: $Lang path ($RepoPath) cannot be a subdirectory of the project directory ($ProjectDirAbs)" - exit 1 - } } } diff --git a/setup.sh b/setup.sh index 3414b11..505aea8 100755 --- a/setup.sh +++ b/setup.sh @@ -31,8 +31,17 @@ err() { echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2 } +# --- Project Directory Resolution --- +# Determine the root directory of the current git repository. +if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then + err "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +fi +readonly PROJECT_DIR_ABS +echo "Detected project root: ${PROJECT_DIR_ABS}" + # --- Configuration --- -readonly DEFAULT_PARENT_DIR="${HOME}/gaada" +readonly DEFAULT_PARENT_DIR="${PROJECT_DIR_ABS}/client_libs" readonly ALL_LANGS="python php ruby java dotnet" # Helper functions for repo info (Replacing associative arrays for Bash 3.2 compatibility) @@ -161,15 +170,6 @@ while [[ $# -gt 0 ]]; do esac done -# --- Project Directory Resolution --- -# Determine the root directory of the current git repository. -if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then - err "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." - exit 1 -fi -readonly PROJECT_DIR_ABS -echo "Detected project root: ${PROJECT_DIR_ABS}" - # --- Language Selection Logic --- # If no languages selected, select all if [[ "${ANY_SELECTED}" == "false" ]]; then @@ -219,11 +219,7 @@ for lang in $ALL_LANGS; do # Bash 3.2 compatible way to set variable by name eval "LIB_PATH_${lang}='${ABS_PATH}'" - # Validation: check against project dir - if [[ "${ABS_PATH}" == "${PROJECT_DIR_ABS}"* ]]; then - err "ERROR: ${lang} path (${ABS_PATH}) cannot be a subdirectory of the project directory (${PROJECT_DIR_ABS})" - exit 1 - fi + fi done diff --git a/update.ps1 b/update.ps1 index 7ce898a..8178a4d 100644 --- a/update.ps1 +++ b/update.ps1 @@ -194,11 +194,7 @@ try { $AbsLibPath = (Get-Item -LiteralPath $LibPath).FullName - # Skip if it is the project directory itself or a subdirectory of it - if ($AbsLibPath.StartsWith($ProjectDirAbs)) { - Write-Host "Skipping internal directory: $AbsLibPath" - continue - } + # Check if it is a git repository if (-not (Test-Path -LiteralPath (Join-Path $AbsLibPath ".git"))) { diff --git a/update.sh b/update.sh index 55eb7d3..0a07236 100755 --- a/update.sh +++ b/update.sh @@ -191,11 +191,7 @@ for lib_path in "${INCLUDE_DIRS[@]}"; do continue fi - # Skip if it is the project directory itself or a subdirectory of it - if [[ "${abs_lib_path}" == "${PROJECT_DIR_ABS}"* ]]; then - echo "Skipping internal directory: ${abs_lib_path}" - continue - fi + # Check if it is a git repository if [[ ! -d "${abs_lib_path}/.git" ]]; then From 18042a2807f7d0459ef09b981a4e3e9388d53647 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 9 Jan 2026 10:21:09 -0500 Subject: [PATCH 36/61] Updated ChangeLog for v1.3 --- ChangeLog | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ChangeLog b/ChangeLog index 97802b5..e8f0e24 100644 --- a/ChangeLog +++ b/ChangeLog @@ -15,5 +15,8 @@ - Modified logic in setup and update scripts. - Added tests for setup and update scripts. - - +* 1.3.0 +- Added explain command. +- Placed client libraries in a sub-directory of the project directory. +- Updated setup and update scripts. +- Added constraint to GEMINI.md to never modify files in api_examples. From 1329afc4e4be5cadcc8b815ac120e175ad9ec918 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 13 Jan 2026 13:28:56 -0500 Subject: [PATCH 37/61] Added step_by_step command --- .gemini/commands/step_by_step.toml | 23 +++++++++++++++++++++++ ChangeLog | 26 +++++++++++++++----------- 2 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 .gemini/commands/step_by_step.toml diff --git a/.gemini/commands/step_by_step.toml b/.gemini/commands/step_by_step.toml new file mode 100644 index 0000000..ae33a71 --- /dev/null +++ b/.gemini/commands/step_by_step.toml @@ -0,0 +1,23 @@ +description = "Presents a response as a step by step process for the user to follow." +prompt = """ +# Task + +Break down the user's request into a clear, step-by-step process. + +## Context +User input: {{args}} + +## Instructions + +1. **Analyze** the user's request carefully. +2. **Structure** your response as a clear, numbered list of steps. +3. **Actionable Steps**: Ensure each step is a direct action the user can take. +4. **Logical Flow**: The steps should follow a logical chronological or dependency-based order. +5. **Headers**: Use headers to separate distinct phases if the process is complex. +6. **Verification**: Include a final step or section on how to verify the task is complete, if applicable. + +## Format +1. Step 1 +2. Step 2 +... +""" diff --git a/ChangeLog b/ChangeLog index e8f0e24..9b7e949 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,10 +1,12 @@ -* 1.0.0 -- Release of verion 1.0 +* 1.4.0 +- Modified ChangeLog so changes are listed in reverse chronological order. +- Added step_by_step custom command. -* 1.1.0 -- Added support for PHP, Ruby, Java, and C#. -- Find latest verion of the API and prompt user whether to use this version. -- Modified setup.sh to clone copies of the client libraries. +* 1.3.0 +- Added explain command. +- Placed client libraries in a sub-directory of the project directory. +- Updated setup and update scripts. +- Added constraint to GEMINI.md to never modify files in api_examples. * 1.2.0 - Updated README.md for clarity @@ -15,8 +17,10 @@ - Modified logic in setup and update scripts. - Added tests for setup and update scripts. -* 1.3.0 -- Added explain command. -- Placed client libraries in a sub-directory of the project directory. -- Updated setup and update scripts. -- Added constraint to GEMINI.md to never modify files in api_examples. +* 1.1.0 +- Added support for PHP, Ruby, Java, and C#. +- Find latest verion of the API and prompt user whether to use this version. +- Modified setup.sh to clone copies of the client libraries. + +* 1.0.0 +- Release of verion 1.0 From 1c330702c27aa2ce0d1b7da3115b7f719c2ba0d4 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 13 Jan 2026 13:28:56 -0500 Subject: [PATCH 38/61] Added step_by_step command --- .gemini/commands/step_by_step.toml | 23 +++++++++++++++++++++++ ChangeLog | 26 +++++++++++++++----------- README.md | 9 +++++++++ 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 .gemini/commands/step_by_step.toml diff --git a/.gemini/commands/step_by_step.toml b/.gemini/commands/step_by_step.toml new file mode 100644 index 0000000..ae33a71 --- /dev/null +++ b/.gemini/commands/step_by_step.toml @@ -0,0 +1,23 @@ +description = "Presents a response as a step by step process for the user to follow." +prompt = """ +# Task + +Break down the user's request into a clear, step-by-step process. + +## Context +User input: {{args}} + +## Instructions + +1. **Analyze** the user's request carefully. +2. **Structure** your response as a clear, numbered list of steps. +3. **Actionable Steps**: Ensure each step is a direct action the user can take. +4. **Logical Flow**: The steps should follow a logical chronological or dependency-based order. +5. **Headers**: Use headers to separate distinct phases if the process is complex. +6. **Verification**: Include a final step or section on how to verify the task is complete, if applicable. + +## Format +1. Step 1 +2. Step 2 +... +""" diff --git a/ChangeLog b/ChangeLog index e8f0e24..9b7e949 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,10 +1,12 @@ -* 1.0.0 -- Release of verion 1.0 +* 1.4.0 +- Modified ChangeLog so changes are listed in reverse chronological order. +- Added step_by_step custom command. -* 1.1.0 -- Added support for PHP, Ruby, Java, and C#. -- Find latest verion of the API and prompt user whether to use this version. -- Modified setup.sh to clone copies of the client libraries. +* 1.3.0 +- Added explain command. +- Placed client libraries in a sub-directory of the project directory. +- Updated setup and update scripts. +- Added constraint to GEMINI.md to never modify files in api_examples. * 1.2.0 - Updated README.md for clarity @@ -15,8 +17,10 @@ - Modified logic in setup and update scripts. - Added tests for setup and update scripts. -* 1.3.0 -- Added explain command. -- Placed client libraries in a sub-directory of the project directory. -- Updated setup and update scripts. -- Added constraint to GEMINI.md to never modify files in api_examples. +* 1.1.0 +- Added support for PHP, Ruby, Java, and C#. +- Find latest verion of the API and prompt user whether to use this version. +- Modified setup.sh to clone copies of the client libraries. + +* 1.0.0 +- Release of verion 1.0 diff --git a/README.md b/README.md index 8e4a7c5..12b9110 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,15 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of > ... (results displayed) ... > "Save the results to csv" +### Customm Commands + +There is a bug in /help. It does not list custom commands under the project directory. You can see the names of the custom commands by running `ls -l .gemini/commands`. This will list the .toml files that contain the custom commands. You can then use the custom commands by running `/` followed by the name of the custom command. For example, if you see a file named `explain.toml`, you can use the custom command by running `/explain`. + +This is a partial list of custom commands: + +* `/explain` - Format the response from the model to be more readable. +* `step_by_step` - Format the response a series of steps. Show the model's thinking process. This is useful for debugging. + ## Directory Structure * `google-ads-api-developer-assistant/`: Root directory. **Launch `gemini` from here.** From 15dcbe69bb0b1cc5df668e3f8809088462c8d981 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 14 Jan 2026 09:47:22 -0500 Subject: [PATCH 39/61] - Fixed minor bugs in api_examples tests and added test for conversion upload summary --- ChangeLog | 4 + .../remove_automatically_created_assets.py | 8 +- .../test_get_conversion_upload_summary.py | 107 ++++++++++++++++++ ...est_remove_automatically_created_assets.py | 13 ++- 4 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 api_examples/tests/test_get_conversion_upload_summary.py diff --git a/ChangeLog b/ChangeLog index 9b7e949..bf4d8f2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,7 @@ +* 1.4.1 +- Fixed minor bugs in tests for api_examples. +- Added test api_examples/tests/test_get_conversion_upload_summary.py + * 1.4.0 - Modified ChangeLog so changes are listed in reverse chronological order. - Added step_by_step custom command. diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index 971f7d2..260bdbf 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -17,9 +17,7 @@ from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.v22.enums.asset_field_type_enum.asset_field_type import ( - AssetFieldTypeEnum, -) +from google.ads.googleads.v22.enums import AssetFieldTypeEnum def main( @@ -54,11 +52,11 @@ def main( # removing (e.g., TEXT, IMAGE, VIDEO). try: - field_type_enum = getattr(AssetFieldTypeEnum, field_type.upper()) + field_type_enum = getattr(AssetFieldTypeEnum.AssetFieldType, field_type.upper()) except AttributeError: print( f"Error: Invalid field type '{field_type}'. " - f"Please use one of: {[e.name for e in AssetFieldTypeEnum if e.name not in ('UNSPECIFIED', 'UNKNOWN')]}" + f"Please use one of: {[e.name for e in AssetFieldTypeEnum.AssetFieldType if e.name not in ('UNSPECIFIED', 'UNKNOWN')]}" ) sys.exit(1) diff --git a/api_examples/tests/test_get_conversion_upload_summary.py b/api_examples/tests/test_get_conversion_upload_summary.py new file mode 100644 index 0000000..ff3acd5 --- /dev/null +++ b/api_examples/tests/test_get_conversion_upload_summary.py @@ -0,0 +1,107 @@ +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. + +import sys +import os +import unittest +from unittest.mock import MagicMock, call +from io import StringIO + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from google.ads.googleads.errors import GoogleAdsException +from google.ads.googleads.client import GoogleAdsClient +from api_examples.get_conversion_upload_summary import main + + +class TestGetConversionUploadSummary(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_ga_service = MagicMock() + self.mock_client.get_service.return_value = self.mock_ga_service + self.customer_id = "1234567890" + + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def tearDown(self): + sys.stdout = sys.__stdout__ + + def test_main_success(self): + # Mock responses for search_stream + mock_batch_1 = MagicMock() + mock_row_1 = MagicMock() + mock_summary_1 = MagicMock() + mock_summary_1.resource_name = "customers/123/offlineConversionUploadClientSummaries/1" + mock_summary_1.status.name = "SUCCESS" + mock_summary_1.total_event_count = 10 + mock_summary_1.successful_event_count = 10 + mock_summary_1.success_rate = 1.0 + mock_summary_1.last_upload_date_time = "2024-01-01 12:00:00" + mock_summary_1.alerts = [] + mock_summary_1.daily_summaries = [] + mock_summary_1.job_summaries = [] + mock_row_1.offline_conversion_upload_client_summary = mock_summary_1 + mock_batch_1.results = [mock_row_1] + + mock_batch_2 = MagicMock() + mock_row_2 = MagicMock() + mock_summary_2 = MagicMock() + mock_summary_2.resource_name = "customers/123/offlineConversionUploadConversionActionSummaries/1" + mock_summary_2.conversion_action_name = "My Conversion Action" + mock_summary_2.status.name = "SUCCESS" + mock_summary_2.total_event_count = 5 + mock_summary_2.successful_event_count = 5 + mock_summary_2.alerts = [] + mock_summary_2.daily_summaries = [] + mock_summary_2.job_summaries = [] + mock_row_2.offline_conversion_upload_conversion_action_summary = mock_summary_2 + mock_batch_2.results = [mock_row_2] + + # The first call returns client summary, second call returns conversion action summary + self.mock_ga_service.search_stream.side_effect = [[mock_batch_1], [mock_batch_2]] + + main(self.mock_client, self.customer_id) + + # Check output + output = self.captured_output.getvalue() + self.assertIn("Offline Conversion Upload Client Summary:", output) + self.assertIn("Resource Name: customers/123/offlineConversionUploadClientSummaries/1", output) + self.assertIn("Offline Conversion Upload Conversion Action Summary:", output) + self.assertIn("Conversion Action Name: My Conversion Action", output) + + self.assertEqual(self.mock_ga_service.search_stream.call_count, 2) + + def test_main_google_ads_exception(self): + mock_error = MagicMock() + mock_error.code.return_value.name = "INTERNAL_ERROR" + mock_failure = MagicMock() + mock_failure.errors = [MagicMock(message="Internal error")] + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + call=MagicMock(), + failure=mock_failure, + request_id="test_request_id" + ) + + with self.assertRaises(SystemExit) as cm: + main(self.mock_client, self.customer_id) + + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn('Request with ID "test_request_id" failed with status "INTERNAL_ERROR"', output) + +if __name__ == "__main__": + unittest.main() diff --git a/api_examples/tests/test_remove_automatically_created_assets.py b/api_examples/tests/test_remove_automatically_created_assets.py index 88dd56b..d7fa1fe 100644 --- a/api_examples/tests/test_remove_automatically_created_assets.py +++ b/api_examples/tests/test_remove_automatically_created_assets.py @@ -36,6 +36,11 @@ def setUp(self): self.mock_client.get_service.side_effect = self._get_mock_service + self.patcher = unittest.mock.patch( + "api_examples.remove_automatically_created_assets.AssetFieldTypeEnum" + ) + self.mock_asset_field_type_enum = self.patcher.start() + class MockAssetFieldType: UNSPECIFIED = MagicMock() UNSPECIFIED.name = "UNSPECIFIED" @@ -67,13 +72,8 @@ def __iter__(self): ] ) - self.mock_real_asset_field_type = MockAssetFieldType() + self.mock_asset_field_type_enum.AssetFieldType = MockAssetFieldType() - self.mock_client.enums = MagicMock() - self.mock_client.enums.AssetFieldTypeEnum = MagicMock() - self.mock_client.enums.AssetFieldTypeEnum.AssetFieldType = ( - self.mock_real_asset_field_type - ) self.customer_id = "1234567890" self.campaign_id = 12345 @@ -92,6 +92,7 @@ def _get_mock_service(self, service_name): def tearDown(self): sys.stdout = sys.__stdout__ + self.patcher.stop() def test_main_successful_removal(self): mock_response = MagicMock() From 1cc743a8971b673445955742df58693883f1a8ea Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 14 Jan 2026 10:44:40 -0500 Subject: [PATCH 40/61] Modify tests for setup.sh and update.sh --- ChangeLog | 3 + tests/test_setup.sh | 4 +- tests/test_update_logic.sh | 151 +++++++++++++++++++++++++------------ 3 files changed, 109 insertions(+), 49 deletions(-) diff --git a/ChangeLog b/ChangeLog index bf4d8f2..7a55780 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,6 @@ +* 1.4.2 +- Updated tests for setup.sh and update.sh + * 1.4.1 - Fixed minor bugs in tests for api_examples. - Added test api_examples/tests/test_get_conversion_upload_summary.py diff --git a/tests/test_setup.sh b/tests/test_setup.sh index 53e469f..2decabb 100755 --- a/tests/test_setup.sh +++ b/tests/test_setup.sh @@ -77,7 +77,7 @@ if ! bash "${SETUP_SCRIPT_PATH}" --python; then fi # Check if directory created (mock clone) -if [[ ! -d "${FAKE_HOME}/gaada/google-ads-python/.git" ]]; then +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-python/.git" ]]; then echo "FAIL: google-ads-python was not 'cloned' (mocked)" exit 1 fi @@ -99,7 +99,7 @@ if ! bash "${SETUP_SCRIPT_PATH}" --java; then fi # Check if java directory created -if [[ ! -d "${FAKE_HOME}/gaada/google-ads-java/.git" ]]; then +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-java/.git" ]]; then echo "FAIL: google-ads-java was not 'cloned'" exit 1 fi diff --git a/tests/test_update_logic.sh b/tests/test_update_logic.sh index 569b5aa..82cf1f7 100755 --- a/tests/test_update_logic.sh +++ b/tests/test_update_logic.sh @@ -1,72 +1,129 @@ #!/bin/bash set -u -# Setup environment -TEST_DIR=$(mktemp -d) -cd "${TEST_DIR}" +# --- Test Update Logic --- +TEST_TMP_DIR=$(mktemp -d) +UPDATE_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/update.sh" -mkdir .gemini -SETTINGS_JSON=".gemini/settings.json" +echo "Running tests in ${TEST_TMP_DIR}" -# function to mimic err -err() { - echo "ERROR: $*" >&2 +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" } +trap cleanup EXIT -# Create "user" settings (simulating existing file) -echo '{"user_setting": true, "common_setting": "user_value"}' > "${SETTINGS_JSON}" - -# Define the logic block to test (extracted from update.sh) -run_update_logic() { - SETTINGS_JSON=".gemini/settings.json" - TEMP_SETTINGS=$(mktemp) - - # 1. Backup existing settings if they exist - if [[ -f "${SETTINGS_JSON}" ]]; then - echo "Backing up ${SETTINGS_JSON}..." - cp "${SETTINGS_JSON}" "${TEMP_SETTINGS}" - - # MOCK: git checkout would go here - echo "Mocking git checkout..." - fi +# 1. Mock Environment +FAKE_HOME=$(mktemp -d) +FAKE_PROJECT=$(mktemp -d) +echo "FAKE_HOME: ${FAKE_HOME}" +echo "FAKE_PROJECT: ${FAKE_PROJECT}" - # MOCK: git pull (simulating update that changes settings.json) - echo "Mocking git pull (updating settings.json)..." - # Overwrite settings.json with "repo" version - echo '{"repo_setting": true, "common_setting": "repo_value"}' > "${SETTINGS_JSON}" - - # 3. Restore/Merge settings - if [[ -f "${TEMP_SETTINGS}" ]] && [[ -s "${TEMP_SETTINGS}" ]]; then - echo "Merging preserved settings with new defaults..." - if jq -s '.[0] * .[1]' "${SETTINGS_JSON}" "${TEMP_SETTINGS}" > "${TEMP_SETTINGS}.merged"; then - mv "${TEMP_SETTINGS}.merged" "${SETTINGS_JSON}" - echo "Settings restored and merged successfully." - else - echo "WARN: Failed to merge settings.json." - fi - rm -f "${TEMP_SETTINGS}" - fi +export HOME="${FAKE_HOME}" +mkdir -p "${FAKE_HOME}/bin" +export PATH="${FAKE_HOME}/bin:${PATH}" + +# Cleanup function (updated) +cleanup() { + rm -rf "${TEST_TMP_DIR}" + rm -rf "${FAKE_HOME}" + rm -rf "${FAKE_PROJECT}" } +trap cleanup EXIT + +# Create mock git +cat > "${FAKE_HOME}/bin/git" < ".gemini/settings.json" + fi + # We don't touch customer_id.txt in repo usually, or maybe we do? + # If repo has customer_id.txt, it might overwrite. + if [[ -f "customer_id.txt" ]]; then + echo "REPO_CUSTOMER_ID" > "customer_id.txt" + fi +elif [[ "\$1" == "ls-files" ]]; then + exit 0 # everything matches for now +elif [[ "\$1" == "checkout" ]]; then + echo "Mock checkout \$2" + # Actually restore the file to "HEAD" state? + # logic: if git ls-files ...; then git checkout ...; fi + # We can just ignore checkout for this test as we want to test the MERGE/RESTORE logic primarily. +else + echo "Mock git: command \$* ignored" +fi +EOF +chmod +x "${FAKE_HOME}/bin/git" + +# Create mock jq if not present +if ! command -v jq &> /dev/null; then + echo "FAIL: real jq is required for this test" + exit 1 +fi + +# 2. Setup "Project" in Temp Dir +mkdir -p "${FAKE_PROJECT}/.gemini" +SETTINGS_JSON="${FAKE_PROJECT}/.gemini/settings.json" +CUSTOMER_ID_FILE="${FAKE_PROJECT}/customer_id.txt" + +# Initial "User" State +echo '{"user_setting": true, "common_setting": "user_value", "context": {"includeDirectories": []}}' > "${SETTINGS_JSON}" +echo "USER_CUSTOMER_ID" > "${CUSTOMER_ID_FILE}" echo "Initial settings:" cat "${SETTINGS_JSON}" +echo "Initial customer_id:" +cat "${CUSTOMER_ID_FILE}" -run_update_logic +# 3. Run update.sh from within FAKE_PROJECT (update.sh expects to be in repo) +cd "${FAKE_PROJECT}" +echo "--- Running update.sh ---" +if ! bash "${UPDATE_SCRIPT_PATH}"; then + echo "FAIL: update.sh failed" + exit 1 +fi +# 4. Verify Results echo "Final settings:" cat "${SETTINGS_JSON}" +echo "Final customer_id:" +cat "${CUSTOMER_ID_FILE}" -# Verify +# Verify Settings USER_VAL=$(jq -r .user_setting "${SETTINGS_JSON}") REPO_VAL=$(jq -r .repo_setting "${SETTINGS_JSON}") COMMON_VAL=$(jq -r .common_setting "${SETTINGS_JSON}") if [[ "$USER_VAL" == "true" ]] && [[ "$REPO_VAL" == "true" ]] && [[ "$COMMON_VAL" == "user_value" ]]; then - echo "TEST PASSED" + echo "PASS: Settings merged correctly" else - echo "TEST FAILED" - echo "user_setting: $USER_VAL (expected true)" - echo "repo_setting: $REPO_VAL (expected true)" - echo "common_setting: $COMMON_VAL (expected user_value)" + echo "FAIL: Settings merge incorrect" exit 1 fi + +# Verify Customer ID +CID_VAL=$(cat "${CUSTOMER_ID_FILE}") +if [[ "$CID_VAL" == "USER_CUSTOMER_ID" ]]; then + echo "PASS: Customer ID preserved" +else + echo "FAIL: Customer ID NOT preserved (Got: $CID_VAL)" + exit 1 +fi + +echo "ALL TESTS PASSED" From bca973c03f9d9127dbfc39d7d6f80c59643e17f3 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 21 Jan 2026 08:56:53 -0500 Subject: [PATCH 41/61] Added section to enforce rigorous GAQL validation. --- GEMINI.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 0ba79bb..e9c1c47 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -82,6 +82,20 @@ This document outlines mandatory operational guidelines, constraints, and best p - **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). - **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. +#### 3.3.1. Rigorous GAQL Validation + + When validating a GAQL query, you MUST follow this process: + + 1. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. + + 2. Contextual Compatibility Check: Do not assume that a filterable field is filterable in all contexts. You MUST verify its compatibility with the resource in the FROM clause. To do this, you MUST: + * Query the GoogleAdsFieldService for the main resource in the FROM clause. + * Examine the selectable_with attribute of the main resource to find the correct fields for filtering. + + 3. Segment Rule: You MUST verify that any segment field used in the WHERE clause is also present in the SELECT clause, unless it is a core date segment (segments.date, segments.week, segments.month, segments.quarter, segments.year). + + 4. Prioritize Validator Errors: If the user provides an error message from a GAQL query validator, you MUST treat that error message as the definitive source of truth. You MUST immediately re-evaluate your validation and correct the query based on the error message. + #### 3.4. Code Generation - **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. - **Reference Source:** Refer to official Google Ads API client library examples for the target language. From 4566e89c1bb64fb733d8320cb06cce61e62088b3 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 21 Jan 2026 08:56:53 -0500 Subject: [PATCH 42/61] Added section to enforce rigorous GAQL validation. --- ChangeLog | 3 +++ GEMINI.md | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/ChangeLog b/ChangeLog index 7a55780..4442bcb 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,6 @@ +* 1.5.0 +- Added rigorous GAQL validation rules to GEMINI.md + * 1.4.2 - Updated tests for setup.sh and update.sh diff --git a/GEMINI.md b/GEMINI.md index 0ba79bb..e9c1c47 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -82,6 +82,20 @@ This document outlines mandatory operational guidelines, constraints, and best p - **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). - **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. +#### 3.3.1. Rigorous GAQL Validation + + When validating a GAQL query, you MUST follow this process: + + 1. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. + + 2. Contextual Compatibility Check: Do not assume that a filterable field is filterable in all contexts. You MUST verify its compatibility with the resource in the FROM clause. To do this, you MUST: + * Query the GoogleAdsFieldService for the main resource in the FROM clause. + * Examine the selectable_with attribute of the main resource to find the correct fields for filtering. + + 3. Segment Rule: You MUST verify that any segment field used in the WHERE clause is also present in the SELECT clause, unless it is a core date segment (segments.date, segments.week, segments.month, segments.quarter, segments.year). + + 4. Prioritize Validator Errors: If the user provides an error message from a GAQL query validator, you MUST treat that error message as the definitive source of truth. You MUST immediately re-evaluate your validation and correct the query based on the error message. + #### 3.4. Code Generation - **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. - **Reference Source:** Refer to official Google Ads API client library examples for the target language. From 8288fcea046c6b6d6b5fca3cbfa146cf1f597d15 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 27 Jan 2026 14:38:42 +0000 Subject: [PATCH 43/61] Point 5 of rigorous GAQL analysis --- GEMINI.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index e9c1c47..12639af 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,6 @@ # Google Ads API Developer Assistant Configuration -## Version: 3.0 +## Version: 2.0 ## Optimized for Machine Comprehension This document outlines mandatory operational guidelines, constraints, and best practices for the Google Ads API Developer Assistant. @@ -78,7 +78,7 @@ This document outlines mandatory operational guidelines, constraints, and best p - **References:** - **Structure:** `https://developers.google.com/google-ads/api/docs/query/` - **Entities:** `https://developers.google.com/google-ads/api/fields/vXX` (replace `vXX` with the confirmed API version). -- **Validation:** Validate queries **before** execution. +- **Validation:** Validate queries **before** execution. Specifically, be sure to execute all the rules outlined in section **"3.3.1. Rigorous GAQL Validation"** before outputting the query. - **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). - **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. @@ -96,6 +96,8 @@ This document outlines mandatory operational guidelines, constraints, and best p 4. Prioritize Validator Errors: If the user provides an error message from a GAQL query validator, you MUST treat that error message as the definitive source of truth. You MUST immediately re-evaluate your validation and correct the query based on the error message. + **5. Core Date Segment Requirement:** If any core date segment (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`) is present in the `SELECT` clause, you MUST verify that the `WHERE` clause contains a finite date range filter on one of these core date segments (e.g., `WHERE segments.date DURING LAST_30_DAYS`). + #### 3.4. Code Generation - **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. - **Reference Source:** Refer to official Google Ads API client library examples for the target language. From 2071be0abfb2623aa23398c7d7a1959b49515cac Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 29 Jan 2026 15:02:10 -0500 Subject: [PATCH 44/61] Added gemini-extension.json --- gemini-extension.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gemini-extension.json b/gemini-extension.json index b6e9933..54b19a1 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,4 +1,5 @@ { "name": "google-ads-api-developer-assistant", - "version": "1.0.0" + "version": "1.6.0", + "contextFileName": "GEMINI.md" } From 2743f028478e296aa457647d5759bf92c594a0cd Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 2 Feb 2026 11:13:10 -0500 Subject: [PATCH 45/61] Update for v23. --- .gemini/settings.json | 17 +++++++++-------- ChangeLog | 3 +++ api_examples/ai_max_reports.py | 2 +- api_examples/capture_gclids.py | 2 +- api_examples/conversion_reports.py | 2 +- api_examples/create_campaign_experiment.py | 2 +- api_examples/disapproved_ads_reports.py | 2 +- api_examples/get_campaign_bid_simulations.py | 2 +- api_examples/get_campaign_shared_sets.py | 2 +- api_examples/get_change_history.py | 2 +- api_examples/get_conversion_upload_summary.py | 2 +- api_examples/get_geo_targets.py | 2 +- api_examples/list_accessible_users.py | 2 +- api_examples/list_pmax_campaigns.py | 2 +- .../parallel_report_downloader_optimized.py | 2 +- .../remove_automatically_created_assets.py | 2 +- api_examples/target_campaign_with_user_list.py | 2 +- 17 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 2e85946..ffd2ce2 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,18 +1,19 @@ { "ui": { "accessibility": { - "disableLoadingPhrases": true + "disableLoadingPhrases": true, + "enableLoadingPhrases": false } }, "context": { "includeDirectories": [ - "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", - "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", - "/path/to/your/library/google-ads-python", - "/path/to/your/library/google-ads-php", - "/path/to/your/library/google-ads-ruby", - "/path/to/your/library/google-ads-java", - "/path/to/your/library/google-ads-dotnet" + "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", + "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", + "/path/to/your/library/google-ads-python", + "/path/to/your/library/google-ads-php", + "/path/to/your/library/google-ads-ruby", + "/path/to/your/library/google-ads-java", + "/path/to/your/library/google-ads-dotnet" ] } } diff --git a/ChangeLog b/ChangeLog index 4442bcb..fbba5ae 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,6 @@ +* 1.5.1 +- Added support for v23. + * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index 493b277..45d52a4 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -220,6 +220,6 @@ def main(client: "GoogleAdsClient", customer_id: str, report_type: str) -> None: # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") main(googleads_client, args.customer_id, args.report_type) diff --git a/api_examples/capture_gclids.py b/api_examples/capture_gclids.py index 5fca866..9b3f645 100644 --- a/api_examples/capture_gclids.py +++ b/api_examples/capture_gclids.py @@ -63,7 +63,7 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser( description="Uploads a click conversion for a given GCLID." diff --git a/api_examples/conversion_reports.py b/api_examples/conversion_reports.py index d2ebdb2..afed56f 100644 --- a/api_examples/conversion_reports.py +++ b/api_examples/conversion_reports.py @@ -499,7 +499,7 @@ def main( args = parser.parse_args() - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") main( googleads_client, diff --git a/api_examples/create_campaign_experiment.py b/api_examples/create_campaign_experiment.py index bd28742..d9dde1a 100644 --- a/api_examples/create_campaign_experiment.py +++ b/api_examples/create_campaign_experiment.py @@ -204,7 +204,7 @@ def modify_treatment_campaign(client, customer_id, draft_campaign_resource_name) if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser( description="Create a campaign experiment based on a campaign draft." diff --git a/api_examples/disapproved_ads_reports.py b/api_examples/disapproved_ads_reports.py index 1627082..26911b8 100644 --- a/api_examples/disapproved_ads_reports.py +++ b/api_examples/disapproved_ads_reports.py @@ -288,7 +288,7 @@ def main( ) args = parser.parse_args() - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") main( googleads_client, diff --git a/api_examples/get_campaign_bid_simulations.py b/api_examples/get_campaign_bid_simulations.py index cabee37..1c945d3 100644 --- a/api_examples/get_campaign_bid_simulations.py +++ b/api_examples/get_campaign_bid_simulations.py @@ -89,7 +89,7 @@ def main(client: "GoogleAdsClient", customer_id: str, campaign_id: str) -> None: if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser( description="Retrieves campaign bid simulations for a given campaign ID." diff --git a/api_examples/get_campaign_shared_sets.py b/api_examples/get_campaign_shared_sets.py index 113d995..54d669e 100644 --- a/api_examples/get_campaign_shared_sets.py +++ b/api_examples/get_campaign_shared_sets.py @@ -78,7 +78,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v22") + google_ads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser( description="Lists campaign shared sets for a given customer ID." diff --git a/api_examples/get_change_history.py b/api_examples/get_change_history.py index e4f0579..82ab741 100644 --- a/api_examples/get_change_history.py +++ b/api_examples/get_change_history.py @@ -103,7 +103,7 @@ def main( if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser(description="Retrieves Google Ads change history.") # The following argument(s) are required to run the example. diff --git a/api_examples/get_conversion_upload_summary.py b/api_examples/get_conversion_upload_summary.py index 511f398..98c4398 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -160,7 +160,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: # The version parameter is a string that specifies the API version to be used. # For example, "v22". # This value has been user-confirmed and saved to the agent's memory. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") try: main(googleads_client, args.customer_id) diff --git a/api_examples/get_geo_targets.py b/api_examples/get_geo_targets.py index e6aec9e..aae6969 100644 --- a/api_examples/get_geo_targets.py +++ b/api_examples/get_geo_targets.py @@ -110,7 +110,7 @@ def main(client: "GoogleAdsClient", customer_id: str) -> None: if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v22") + google_ads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser( description="Lists geo targets for all campaigns for a given customer ID." diff --git a/api_examples/list_accessible_users.py b/api_examples/list_accessible_users.py index fab3eb8..65ecc2b 100644 --- a/api_examples/list_accessible_users.py +++ b/api_examples/list_accessible_users.py @@ -54,6 +54,6 @@ def main(client: GoogleAdsClient) -> None: if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") main(googleads_client) diff --git a/api_examples/list_pmax_campaigns.py b/api_examples/list_pmax_campaigns.py index 7d70a0d..01e64d6 100644 --- a/api_examples/list_pmax_campaigns.py +++ b/api_examples/list_pmax_campaigns.py @@ -68,7 +68,7 @@ def main(client: "GoogleAdsClient", customer_id: str) -> None: if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser(description="Lists Performance Max campaigns.") # The following argument(s) are required to run the example. diff --git a/api_examples/parallel_report_downloader_optimized.py b/api_examples/parallel_report_downloader_optimized.py index f0035b4..a69cfdf 100644 --- a/api_examples/parallel_report_downloader_optimized.py +++ b/api_examples/parallel_report_downloader_optimized.py @@ -87,7 +87,7 @@ def main(customer_ids: List[str], login_customer_id: Optional[str]) -> None: customer_ids: A list of customer IDs to run reports for. login_customer_id: The login customer ID to use (optional). """ - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") if login_customer_id: googleads_client.login_customer_id = login_customer_id diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index 260bdbf..451a2d6 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -126,7 +126,7 @@ def main( args = parser.parse_args() # GoogleAdsClient will read the google-ads.yaml file from the home directory. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + googleads_client = GoogleAdsClient.load_from_storage(version="v23") main( googleads_client, diff --git a/api_examples/target_campaign_with_user_list.py b/api_examples/target_campaign_with_user_list.py index 552dfb8..f7caf29 100644 --- a/api_examples/target_campaign_with_user_list.py +++ b/api_examples/target_campaign_with_user_list.py @@ -80,7 +80,7 @@ def main( if __name__ == "__main__": # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v22") + google_ads_client = GoogleAdsClient.load_from_storage(version="v23") parser = argparse.ArgumentParser( description="Adds a campaign criterion to target a user list to a campaign." From 05b5dd61db3f8a52eae9f63c5ac7d15f13f24c75 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 2 Feb 2026 11:54:58 -0500 Subject: [PATCH 46/61] Add campaign with start_date_time and end_date_time. --- api_examples/add_campaign_with_date_times.py | 144 ++++++++++++++++++ .../test_add_campaign_with_date_times.py | 89 +++++++++++ 2 files changed, 233 insertions(+) create mode 100644 api_examples/add_campaign_with_date_times.py create mode 100644 api_examples/tests/test_add_campaign_with_date_times.py diff --git a/api_examples/add_campaign_with_date_times.py b/api_examples/add_campaign_with_date_times.py new file mode 100644 index 0000000..ceb797b --- /dev/null +++ b/api_examples/add_campaign_with_date_times.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +"""This example demonstrates how to create a campaign with start and end date times. + +This feature was added in v23 of the Google Ads API. +""" + +import argparse +import sys +import uuid +from datetime import datetime, timedelta + +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + + +def main(client, customer_id): + """The main method that creates all necessary entities for the example. + + Args: + client: an initialized GoogleAdsClient instance. + customer_id: a client customer ID. + """ + campaign_budget_service = client.get_service("CampaignBudgetService") + campaign_service = client.get_service("CampaignService") + + # Create a budget, which is a required constraint when creating a campaign. + campaign_budget_operation = client.get_type("CampaignBudgetOperation") + campaign_budget = campaign_budget_operation.create + campaign_budget.name = f"Interplanetary Budget {uuid.uuid4()}" + campaign_budget.delivery_method = ( + client.enums.BudgetDeliveryMethodEnum.STANDARD + ) + campaign_budget.amount_micros = 500000 + + # Add budget. + try: + campaign_budget_response = campaign_budget_service.mutate_campaign_budgets( + customer_id=customer_id, operations=[campaign_budget_operation] + ) + except GoogleAdsException as ex: + _handle_google_ads_exception(ex) + + campaign_budget_resource_name = campaign_budget_response.results[0].resource_name + print( + f"Created campaign budget with resource name: '{campaign_budget_resource_name}'" + ) + + # Create campaign. + campaign_operation = client.get_type("CampaignOperation") + campaign = campaign_operation.create + campaign.name = f"Interplanetary Cruise Campaign {uuid.uuid4()}" + campaign.advertising_channel_type = ( + client.enums.AdvertisingChannelTypeEnum.SEARCH + ) + + # Recommendation: Set the campaign to PAUSED when creating it to prevent + # the ads from immediately serving. + campaign.status = client.enums.CampaignStatusEnum.PAUSED + + # Set the budget. + campaign.campaign_budget = campaign_budget_resource_name + + # Set the network settings. + campaign.network_settings.target_google_search = True + campaign.network_settings.target_search_network = True + campaign.network_settings.target_content_network = False + campaign.network_settings.target_partner_search_network = False + + # Optional: Set the start date time and end date time. + # Note: These fields are only available in v23 and later. + # The format must be 'yyyy-mm-dd hh:mm:ss'. + # We will set the start time to 1 day from now, and end time to 30 days from now. + now = datetime.now() + start_time = now + timedelta(days=1) + end_time = now + timedelta(days=31) + + campaign.start_date_time = start_time.strftime("%Y-%m-%d %H:%M:%S") + campaign.end_date_time = end_time.strftime("%Y-%m-%d %H:%M:%S") + + # Add the campaign. + try: + campaign_response = campaign_service.mutate_campaigns( + customer_id=customer_id, operations=[campaign_operation] + ) + print( + f"Created campaign with resource name: '{campaign_response.results[0].resource_name}'" + ) + print(f"Start date time: {campaign.start_date_time}") + print(f"End date time: {campaign.end_date_time}") + except GoogleAdsException as ex: + _handle_google_ads_exception(ex) + + +def _handle_google_ads_exception(exception): + """Prints the details of a GoogleAdsException object. + + Args: + exception: an instance of GoogleAdsException. + """ + print( + f"Request with ID '{exception.request_id}' failed with status " + f"'{exception.error.code().name}' and includes the following errors:" + ) + for error in exception.failure.errors: + print(f"\tError with message '{error.message}'.") + if error.location: + for field_path_element in error.location.field_path_elements: + print(f"\t\tOn field: {field_path_element.field_name}") + sys.exit(1) + + +if __name__ == "__main__": + # GoogleAdsClient will read the google-ads.yaml configuration file in the + # home directory if none is specified. + googleads_client = GoogleAdsClient.load_from_storage(version="v23") + + parser = argparse.ArgumentParser( + description="Creates a campaign with start and end date times." + ) + # The following argument(s) are required to run the example. + parser.add_argument( + "-c", + "--customer_id", + type=str, + required=True, + help="The Google Ads customer ID.", + ) + args = parser.parse_args() + + main(googleads_client, args.customer_id) diff --git a/api_examples/tests/test_add_campaign_with_date_times.py b/api_examples/tests/test_add_campaign_with_date_times.py new file mode 100644 index 0000000..5aa983f --- /dev/null +++ b/api_examples/tests/test_add_campaign_with_date_times.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +import unittest +from unittest.mock import MagicMock +import sys +import os + +# Add the parent directory to sys.path to import the example script +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Import the main function from the example script +# We need to import it as a module to mock it properly +from api_examples import add_campaign_with_date_times + +class TestAddCampaignWithDateTimes(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock() + self.customer_id = "1234567890" + + def test_main(self): + # Mock services + mock_campaign_budget_service = MagicMock() + mock_campaign_service = MagicMock() + + def get_service_side_effect(service_name): + if service_name == "CampaignBudgetService": + return mock_campaign_budget_service + elif service_name == "CampaignService": + return mock_campaign_service + return MagicMock() + + self.mock_client.get_service.side_effect = get_service_side_effect + + # Mock types + mock_campaign_budget_operation = MagicMock() + mock_campaign_operation = MagicMock() + + def get_type_side_effect(type_name): + if type_name == "CampaignBudgetOperation": + return mock_campaign_budget_operation + elif type_name == "CampaignOperation": + return mock_campaign_operation + return MagicMock() + + self.mock_client.get_type.side_effect = get_type_side_effect + + # Mock Enums + self.mock_client.enums.BudgetDeliveryMethodEnum.STANDARD = "STANDARD" + self.mock_client.enums.AdvertisingChannelTypeEnum.SEARCH = "SEARCH" + self.mock_client.enums.CampaignStatusEnum.PAUSED = "PAUSED" + + # Mock responses + mock_budget_response = MagicMock() + mock_budget_response.results = [MagicMock(resource_name="budget_resource_name")] + mock_campaign_budget_service.mutate_campaign_budgets.return_value = mock_budget_response + + mock_campaign_response = MagicMock() + mock_campaign_response.results = [MagicMock(resource_name="campaign_resource_name")] + mock_campaign_service.mutate_campaigns.return_value = mock_campaign_response + + # Run main + add_campaign_with_date_times.main(self.mock_client, self.customer_id) + + # Asserts + mock_campaign_budget_service.mutate_campaign_budgets.assert_called_once() + mock_campaign_service.mutate_campaigns.assert_called_once() + + # Check if created campaign has start_date_time and end_date_time set + created_campaign = mock_campaign_operation.create + self.assertTrue(hasattr(created_campaign, "start_date_time")) + self.assertTrue(hasattr(created_campaign, "end_date_time")) + self.assertIsNotNone(created_campaign.start_date_time) + self.assertIsNotNone(created_campaign.end_date_time) + +if __name__ == "__main__": + unittest.main() From 507b2a8ad9b0db2b1b36f81717650a2bed4100e2 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 26 Feb 2026 16:48:08 +0000 Subject: [PATCH 47/61] Fix merge --- .../commands/conversions_support_package.toml | 18 + .gemini/hooks/cleanup_config.py | 39 ++ .gemini/hooks/custom_config.py | 196 +++++++ .gemini/settings.json | 41 +- .gemini/settings.json.bak | 29 + .gemini/settings.json.bak.2 | 48 ++ .gemini/skills/ext_version/SKILL.md | 16 + .../scripts/get_extension_version.py | 33 ++ CONTRIBUTING.md | 28 - ChangeLog | 23 + FAQ.md | 23 +- GEMINI.md | 291 +++++----- README.md | 106 +++- README_BEFORE_INSTALLATION.md | 8 + SERVICE_ACCOUNT.md | 99 ++++ api_examples/ai_max_reports.py | 279 +++------- api_examples/capture_gclids.py | 26 +- ...ollect_conversions_troubleshooting_data.py | 118 ++++ api_examples/conversion_reports.py | 507 ++++-------------- api_examples/create_campaign_experiment.py | 11 +- api_examples/disapproved_ads_reports.py | 309 +---------- api_examples/gaql_validator.py | 108 ++++ api_examples/get_campaign_bid_simulations.py | 121 +---- api_examples/get_campaign_shared_sets.py | 90 +--- api_examples/get_change_history.py | 139 +---- api_examples/get_conversion_upload_summary.py | 186 +------ api_examples/get_geo_targets.py | 146 ++--- api_examples/list_accessible_users.py | 58 +- api_examples/list_pmax_campaigns.py | 93 +--- .../parallel_report_downloader_optimized.py | 253 +++------ .../remove_automatically_created_assets.py | 143 +---- .../target_campaign_with_user_list.py | 132 +---- ...i_max_reports.cpython-314-pytest-8.4.2.pyc | Bin 15018 -> 0 bytes ...apture_gclids.cpython-314-pytest-8.4.2.pyc | Bin 8530 -> 0 bytes ...rsion_reports.cpython-314-pytest-8.4.2.pyc | Bin 17636 -> 0 bytes ...gn_experiment.cpython-314-pytest-8.4.2.pyc | Bin 8427 -> 0 bytes ...d_ads_reports.cpython-314-pytest-8.4.2.pyc | Bin 17314 -> 0 bytes ...d_simulations.cpython-314-pytest-8.4.2.pyc | Bin 7892 -> 0 bytes ...n_shared_sets.cpython-314-pytest-8.4.2.pyc | Bin 6985 -> 0 bytes ...hange_history.cpython-314-pytest-8.4.2.pyc | Bin 7101 -> 0 bytes ...t_geo_targets.cpython-314-pytest-8.4.2.pyc | Bin 8929 -> 0 bytes ...essible_users.cpython-314-pytest-8.4.2.pyc | Bin 5383 -> 0 bytes ...max_campaigns.cpython-314-pytest-8.4.2.pyc | Bin 6740 -> 0 bytes ...der_optimized.cpython-314-pytest-8.4.2.pyc | Bin 8900 -> 0 bytes ...reated_assets.cpython-314-pytest-8.4.2.pyc | Bin 9971 -> 0 bytes ...ith_user_list.cpython-314-pytest-8.4.2.pyc | Bin 7780 -> 0 bytes api_examples/tests/test_ai_max_reports.py | 211 +------- api_examples/tests/test_capture_gclids.py | 9 +- ...ollect_conversions_troubleshooting_data.py | 134 +++++ api_examples/tests/test_conversion_reports.py | 343 +----------- .../tests/test_disapproved_ads_reports.py | 331 +----------- api_examples/tests/test_gaql_validator.py | 41 ++ .../test_get_campaign_bid_simulations.py | 115 +--- .../tests/test_get_campaign_shared_sets.py | 98 +--- api_examples/tests/test_get_change_history.py | 82 +-- .../test_get_conversion_upload_summary.py | 89 ++- api_examples/tests/test_get_geo_targets.py | 196 +------ .../tests/test_list_accessible_users.py | 117 +--- .../tests/test_list_pmax_campaigns.py | 76 +-- ...st_parallel_report_downloader_optimized.py | 207 +------ ...est_remove_automatically_created_assets.py | 181 +------ .../test_target_campaign_with_user_list.py | 142 ++--- config/.gitkeep | 0 conversions/GEMINI.md | 118 ++++ customer_id.txt | 2 +- gemini-extension.json | 2 +- setup.ps1 => install.ps1 | 91 +++- setup.sh => install.sh | 82 ++- saved/.gitkeep | 0 saved/code/.gitkeep | 0 saved/csv/.gitkeep | 0 saved/data/.gitkeep | 0 saved_code/get_all_campaigns_to_csv.py | 88 --- saved_csv/campaigns.csv | 38 -- tests/test_install.ps1 | 148 +++++ tests/test_install.sh | 235 ++++++++ tests/test_setup.sh | 134 ----- tests/test_uninstall.ps1 | 83 +++ tests/test_uninstall.sh | 94 ++++ tests/test_update.sh | 110 ++++ tests/test_update_logic.sh | 129 ----- uninstall.ps1 | 46 ++ uninstall.sh | 54 ++ update.ps1 | 77 ++- update.sh | 128 ++++- 85 files changed, 3173 insertions(+), 4575 deletions(-) create mode 100644 .gemini/commands/conversions_support_package.toml create mode 100644 .gemini/hooks/cleanup_config.py create mode 100644 .gemini/hooks/custom_config.py create mode 100644 .gemini/settings.json.bak create mode 100644 .gemini/settings.json.bak.2 create mode 100644 .gemini/skills/ext_version/SKILL.md create mode 100644 .gemini/skills/ext_version/scripts/get_extension_version.py create mode 100644 README_BEFORE_INSTALLATION.md create mode 100644 SERVICE_ACCOUNT.md create mode 100644 api_examples/collect_conversions_troubleshooting_data.py create mode 100644 api_examples/gaql_validator.py delete mode 100644 api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_list_pmax_campaigns.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_remove_automatically_created_assets.cpython-314-pytest-8.4.2.pyc delete mode 100644 api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc create mode 100644 api_examples/tests/test_collect_conversions_troubleshooting_data.py create mode 100644 api_examples/tests/test_gaql_validator.py create mode 100644 config/.gitkeep create mode 100644 conversions/GEMINI.md rename setup.ps1 => install.ps1 (68%) rename setup.sh => install.sh (77%) create mode 100644 saved/.gitkeep create mode 100644 saved/code/.gitkeep create mode 100644 saved/csv/.gitkeep create mode 100644 saved/data/.gitkeep delete mode 100644 saved_code/get_all_campaigns_to_csv.py delete mode 100644 saved_csv/campaigns.csv create mode 100644 tests/test_install.ps1 create mode 100755 tests/test_install.sh delete mode 100755 tests/test_setup.sh create mode 100644 tests/test_uninstall.ps1 create mode 100644 tests/test_uninstall.sh create mode 100644 tests/test_update.sh delete mode 100755 tests/test_update_logic.sh create mode 100644 uninstall.ps1 create mode 100755 uninstall.sh diff --git a/.gemini/commands/conversions_support_package.toml b/.gemini/commands/conversions_support_package.toml new file mode 100644 index 0000000..c1a14bf --- /dev/null +++ b/.gemini/commands/conversions_support_package.toml @@ -0,0 +1,18 @@ +description = "Collects structured diagnostic data for gTech conversion troubleshooting." + +prompt = """ +You are a helpful Google Ads API troubleshooting assistant. +The User is experiencing issues with conversions and needs to collect structured diagnostic data for gTech support. + +Please execute the following actions: +1. At the top of the output file write "Created by the Google Ads API Developer Assistant" +2. If you have previously completed structured diagnostic analysis, include that text in the file. +3. Locate the current `customer_id` from `customer_id.txt` or context. +4. Run the structured troubleshooting script using the command: `python3 api_examples/collect_conversions_troubleshooting_data.py --customer_id ` +5. Include a section "SUMMARY OF FINDINGS" containing the Summary and Error sections from the script's terminal output. +6. Include a section "DETAILED DIAGNOSTIC DATA" containing the **complete verbatim content** of the troubleshooting report generated by the script (found in `saved/data/`). +7. Save this consolidated data into a single file in `saved/data/` (e.g., `conversions_support_package_.text`) and print name of file to console. + +Here are the details from the user: +{{args}} +""" diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py new file mode 100644 index 0000000..458db45 --- /dev/null +++ b/.gemini/hooks/cleanup_config.py @@ -0,0 +1,39 @@ +import os +import shutil +import sys +import datetime + +def cleanup(): + # Determine paths + script_dir = os.path.dirname(os.path.abspath(__file__)) + # .gemini/hooks/ -> project root is 2 levels up + project_root = os.path.abspath(os.path.join(script_dir, "../..")) + config_dir = os.path.join(project_root, "config") + + if not os.path.exists(config_dir): + print(f"Config directory {config_dir} does not exist. Nothing to clean.", file=sys.stderr) + return + + try: + # User requested to remove *all files* in the config directory. + # We could also remove the directory itself. Let's remove content. + for filename in os.listdir(config_dir): + if filename == ".gitkeep": + continue + file_path = os.path.join(config_dir, filename) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + print(f"Failed to delete {file_path}. Reason: {e}", file=sys.stderr) + + timestamp = datetime.datetime.now() + + except Exception as e: + print(f"Error cleaning up config directory: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + cleanup() diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py new file mode 100644 index 0000000..d40b813 --- /dev/null +++ b/.gemini/hooks/custom_config.py @@ -0,0 +1,196 @@ +import os +import shutil +import subprocess +import json +import sys +import re + +def get_version(ext_version_script): + """Retrieves the extension version.""" + try: + result = subprocess.run( + [sys.executable, ext_version_script], + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except Exception as e: + print(f"Error getting extension version: {e}", file=sys.stderr) + return "2.0.0" # Fallback + +def parse_ruby_config(path): + """Parses a Ruby config file for Google Ads.""" + data = {} + patterns = { + "developer_token": r"c\.developer_token\s*=\s*['\"](.*?)['\"]", + "client_id": r"c\.client_id\s*=\s*['\"](.*?)['\"]", + "client_secret": r"c\.client_secret\s*=\s*['\"](.*?)['\"]", + "refresh_token": r"c\.refresh_token\s*=\s*['\"](.*?)['\"]", + "login_customer_id": r"c\.login_customer_id\s*=\s*['\"](.*?)['\"]", + "json_key_file_path": r"c\.json_key_file_path\s*=\s*['\"](.*?)['\"]", + "impersonated_email": r"c\.impersonated_email\s*=\s*['\"](.*?)['\"]", + } + try: + with open(path, "r") as f: + content = f.read() + for key, pattern in patterns.items(): + match = re.search(pattern, content) + if match: + data[key] = match.group(1) + except Exception as e: + print(f"Error parsing Ruby config: {e}", file=sys.stderr) + return data + +def parse_ini_config(path): + """Parses a PHP INI config file for Google Ads.""" + data = {} + patterns = { + "developer_token": r"developer_token\s*=\s*['\"]?(.*?)['\"]?\s*$", + "client_id": r"client_id\s*=\s*['\"]?(.*?)['\"]?\s*$", + "client_secret": r"client_secret\s*=\s*['\"]?(.*?)['\"]?\s*$", + "refresh_token": r"refresh_token\s*=\s*['\"]?(.*?)['\"]?\s*$", + "login_customer_id": r"login_customer_id\s*=\s*['\"]?(.*?)['\"]?\s*$", + "json_key_file_path": r"json_key_file_path\s*=\s*['\"]?(.*?)['\"]?\s*$", + "impersonated_email": r"impersonated_email\s*=\s*['\"]?(.*?)['\"]?\s*$", + } + try: + with open(path, "r") as f: + for line in f: + for key, pattern in patterns.items(): + match = re.search(pattern, line) + if match: + data[key] = match.group(1) + except Exception as e: + print(f"Error parsing INI config: {e}", file=sys.stderr) + return data + +def parse_properties_config(path): + """Parses a Java properties config file for Google Ads.""" + data = {} + mapping = { + "api.googleads.developerToken": "developer_token", + "api.googleads.clientId": "client_id", + "api.googleads.clientSecret": "client_secret", + "api.googleads.refreshToken": "refresh_token", + "api.googleads.loginCustomerId": "login_customer_id", + "api.googleads.oAuth2SecretsJsonPath": "json_key_file_path", + "api.googleads.oAuth2PrnEmail": "impersonated_email", + } + try: + with open(path, "r") as f: + for line in f: + if "=" in line: + k, v = line.split("=", 1) + k = k.strip() + if k in mapping: + data[mapping[k]] = v.strip() + except Exception as e: + print(f"Error parsing properties config: {e}", file=sys.stderr) + return data + +def write_yaml_config(data, target_path, version): + """Writes a standard Google Ads YAML config.""" + try: + service_account = "json_key_file_path" in data + with open(target_path, "w") as f: + f.write("# Generated by Gemini CLI Assistant\n") + f.write("developer_token: " + data.get("developer_token", "INSERT_DEVELOPER_TOKEN_HERE") + "\n") + + if service_account: + f.write("json_key_file_path: " + data["json_key_file_path"] + "\n") + if "impersonated_email" in data: + f.write("impersonated_email: " + data["impersonated_email"] + "\n") + else: + f.write("client_id: " + data.get("client_id", "INSERT_CLIENT_ID_HERE") + "\n") + f.write("client_secret: " + data.get("client_secret", "INSERT_CLIENT_SECRET_HERE") + "\n") + f.write("refresh_token: " + data.get("refresh_token", "INSERT_REFRESH_TOKEN_HERE") + "\n") + + if "login_customer_id" in data: + f.write("login_customer_id: " + data["login_customer_id"] + "\n") + f.write("use_proto_plus: True\n") + f.write(f"gaada: \"{version}\"\n") + return True + except Exception as e: + print(f"Error writing YAML config: {e}", file=sys.stderr) + return False + +def configure_language(lang_name, home_config, target_config, version, is_python=False): + """Copies and versions a specific language configuration.""" + if not os.path.exists(home_config): + return False + + try: + shutil.copy2(home_config, target_config) + with open(target_config, "a", encoding="utf-8") as f: + sep = ":" if is_python else "=" + f.write(f"\ngaada{sep} \"{version}\"\n") + + return True + except Exception as e: + print(f"Error configuring {lang_name}: {e}", file=sys.stderr) + return False + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(script_dir, "../..")) + config_dir = os.path.join(project_root, "config") + ext_version_script = os.path.join(project_root, ".gemini/skills/ext_version/scripts/get_extension_version.py") + + os.makedirs(config_dir, exist_ok=True) + version = get_version(ext_version_script) + + home_dir = os.path.expanduser("~") + python_home = os.path.join(home_dir, "google-ads.yaml") + python_target = os.path.join(config_dir, "google-ads.yaml") + + # 1. Try Python YAML first + if configure_language("Python", python_home, python_target, version, is_python=True): + print("Configured Python") + else: + # 2. Try fallbacks + fallbacks = [ + ("PHP", "google_ads_php.ini", parse_ini_config), + ("Ruby", "google_ads_config.rb", parse_ruby_config), + ("Java", "ads.properties", parse_properties_config), + ] + + found_fallback = False + for lang, filename, parser in fallbacks: + path = os.path.join(home_dir, filename) + if os.path.exists(path): + print(f"Found {lang} config at {path}. Converting to YAML...") + data = parser(path) + if write_yaml_config(data, python_target, version): + print(f"Successfully converted {lang} config to {python_target}") + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{python_target}\"", file=sys.stdout) + found_fallback = True + break + + if not found_fallback: + print("Error: No Google Ads configuration found in home directory. Please create ~/google-ads.yaml.", file=sys.stderr) + sys.exit(1) + + # 3. Configure other languages if requested by workspace context + languages = [ + {"id": "google-ads-php", "name": "PHP", "filename": "google_ads_php.ini", "home": os.path.join(home_dir, "google_ads_php.ini")}, + {"id": "google-ads-ruby", "name": "Ruby", "filename": "google_ads_config.rb", "home": os.path.join(home_dir, "google_ads_config.rb")}, + {"id": "google-ads-java", "name": "Java", "filename": "ads.properties", "home": os.path.join(home_dir, "ads.properties")}, + ] + + settings_path = os.path.join(project_root, ".gemini/settings.json") + if os.path.exists(settings_path): + try: + with open(settings_path, "r") as f: + settings = json.load(f) + include_dirs = settings.get("context", {}).get("includeDirectories", []) + except Exception: + include_dirs = [] + + for lang in languages: + if any(lang["id"] in d for d in include_dirs): + target = os.path.join(config_dir, lang["filename"]) + configure_language(lang["name"], lang["home"], target, version) + +if __name__ == "__main__": + main() diff --git a/.gemini/settings.json b/.gemini/settings.json index ffd2ce2..a3835a7 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -7,13 +7,40 @@ }, "context": { "includeDirectories": [ - "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", - "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", - "/path/to/your/library/google-ads-python", - "/path/to/your/library/google-ads-php", - "/path/to/your/library/google-ads-ruby", - "/path/to/your/library/google-ads-java", - "/path/to/your/library/google-ads-dotnet" + "/path/to/project_dir/google-ads-api-developer-assistant/api_examples", + "/path/to/project_dir/google-ads-api-developer-assistant/saved/code", + "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-python", + "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-php", + "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-ruby" + ] + }, + "tools": { + "enableHooks": true + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "name": "init", + "type": "command", + "command": "python3 .gemini/hooks/custom_config.py" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "exit", + "hooks": [ + { + "name": "cleanup", + "type": "command", + "command": "python3 .gemini/hooks/cleanup_config.py" + } + ] + } ] } } diff --git a/.gemini/settings.json.bak b/.gemini/settings.json.bak new file mode 100644 index 0000000..e8eaaaa --- /dev/null +++ b/.gemini/settings.json.bak @@ -0,0 +1,29 @@ +{ + "context": { + "fileFiltering": { + "enableRecursiveFileSearch": false + } + }, + "ui": { + "theme": "Default Light" + }, + "general": { + "preferredEditor": "vim" + }, + "useSmartEdit": true, + "tools": { + "allowed": [ + "read_file", + "read_many_files", + "list_directory", + "search_file_content", + "glob", + "web_fetch", + "google_web_search", + "save_memory", + "read_document" + ], + "enableHooks": true + }, + "enableHooks": true +} diff --git a/.gemini/settings.json.bak.2 b/.gemini/settings.json.bak.2 new file mode 100644 index 0000000..c04b78a --- /dev/null +++ b/.gemini/settings.json.bak.2 @@ -0,0 +1,48 @@ +{ + "ui": { + "accessibility": { + "disableLoadingPhrases": true, + "enableLoadingPhrases": false + } + }, + "context": { + "includeDirectories": [ + "/path/google-ads-api-developer-assistant/api_examples", + "/path/google-ads-api-developer-assistant/saved_code", + "/path/google-ads-api-developer-assistant/client_libs/google-ads-python" + ] + }, + "tools": { + "enableHooks": true + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "name": "session-start-configure", + "type": "command", + "command": "python3 .gemini/hooks/SessionStart/custom_config_python.py", + "description": "Configure Google Ads API client to use interceptors", + "timeout": 30000 + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "exit", + "hooks": [ + { + "name": "session-end-cleanup", + "type": "command", + "command": "python3 .gemini/hooks/SessionEnd/cleanup_config.py", + "description": "Cleanup /config", + "timeout": 30000 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.gemini/skills/ext_version/SKILL.md b/.gemini/skills/ext_version/SKILL.md new file mode 100644 index 0000000..a1dca56 --- /dev/null +++ b/.gemini/skills/ext_version/SKILL.md @@ -0,0 +1,16 @@ +--- +name: get-extension-version +description: Extracts the version from gemini-extension.json and makes it available during the session. +--- + +# Get Extension Version + +This skill extracts the version from `gemini-extension.json`. + +## Usage + +Run the python script to get the version: + +```bash +python3 skills/ext_version/scripts/get_extension_version.py +``` diff --git a/.gemini/skills/ext_version/scripts/get_extension_version.py b/.gemini/skills/ext_version/scripts/get_extension_version.py new file mode 100644 index 0000000..f19c910 --- /dev/null +++ b/.gemini/skills/ext_version/scripts/get_extension_version.py @@ -0,0 +1,33 @@ +import json +import os +import sys + +def get_extension_version() -> None: + """Reads gemini-extension.json and prints the version.""" + try: + # Assumes the script is in .gemini/skills/ext_version/scripts/ + # gemini-extension.json is at the root, so 4 levels up + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) + json_path = os.path.join(base_dir, "gemini-extension.json") + + if not os.path.exists(json_path): + # Fallback: try current directory or one level up if running from root + if os.path.exists("gemini-extension.json"): + json_path = "gemini-extension.json" + + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + print(data.get("version", "Version not found")) + + except FileNotFoundError: + print("Error: gemini-extension.json not found at expected path.", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError: + print("Error: gemini-extension.json is not valid JSON.", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + get_extension_version() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cc0b5f..939e534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,31 +26,3 @@ information on using pull requests. This project follows [Google's Open Source Community Guidelines](https://opensource.google.com/conduct/). - -## Code Style - -This library conforms to [PEP 8](https://www.python.org/dev/peps/pep-0008/) -style guidelines and enforces an 80 character line width. It's recommended -that any contributor run the auto-formatter [`black`](https://github.com/psf/black), -version 19.10b0 on the non-generated codebase whenever making changes. To get -started, first install the appropriate version of `black`: - -``` -python -m pip install black==19.10b0 -``` - -You can manually run the formatter on all non-generated code with the following -command: - -``` -python -m black -l 80 --exclude "/(v[0-9]+|\.eggs|\.git|_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist)/" . -``` - -Alternatively, if you intend to contribute regularly, it might be easier to -append this script to the `.git/hooks/pre-commit` file: - -``` -FILES=$(git diff --cached --name-only --diff-filter=ACMR "*.py" | grep -v "google/ads/google_ads/v.*") -echo "${FILES}" | xargs python -m black -l 80 -echo "${FILES}" | xargs git add -``` diff --git a/ChangeLog b/ChangeLog index fbba5ae..5c27090 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,28 @@ +<<<<<<< HEAD * 1.5.1 - Added support for v23. +======= +* 2.0.0 +- Hierachical context file for conversions troubleshooting. +- Added Conversion Troubleshooting & Diagnostics functionality (api_examples/collect_conversions_troubleshooting_data.py). +- Updated examples to use Google Ads API v23. +- Updated README.md +- Changed directory structure to use a single sub-directory for all saved data. +- Added unit test coverage for new diagnostic tools and examples. +- Reformatted GEMINI.md for better clarity on conversion types and autonomous diagnostic prompts. +- Install-deps option to install.sh and install.ps1. +- Skills/ext_version to get the extension version. +- gemini-extension.json to register extensions with https://geminicli.com/extensions/ +- Documentation resource for public protos. +- Hooks for start and end of a session. +- Mandatory GAQL validation rules to GEMINI.md +- Dynamic grpc interceptor for Python calls within extension. +- Python is installed by default with install.sh and install.ps1. +- Updated update process to allow for adding additional client libraries. +- Changed name of setup files to install and provided an uninstall procedure. +- Added additional rules for GAQL edge cases to GEMINI.md. +- Added command conversions_support_data. +>>>>>>> v1.6.0 * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/FAQ.md b/FAQ.md index 2bb58da..a2997a3 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,10 +1,21 @@ # FAQ -## What is the capital of France? -Paris is the capital and most populous city of France. +## How do I configure my Google Ads API credentials? +The Assistant looks for configuration files in your home directory (`$HOME`). +- **Python**: `google-ads.yaml` +- **PHP**: `google_ads_php.ini` +- **Ruby**: `google_ads_config.rb` +Refer to the official Google Ads API documentation for the specific structure of each file. -## What is the highest mountain in the world? -Mount Everest is the Earth's highest mountain above sea level. +## How do I set a default customer ID? +Create a file named `customer_id.txt` in the project root directory with the format: +`customer_id: 1234567890` -## How do I use this bot? -Type `!faq` followed by your question. For example: `!faq What is the capital of France?` +## Which languages are supported for code execution? +Python, PHP, and Ruby can be executed directly within the Assistant using the "Run the code" prompt. Java and C# (.NET) code can be generated but must be compiled and executed externally. + +## How do I create a report for conversion upload issues that I can share with Google Support? +After you have completed the interactive troubleshooting, you can use the `/conversions_support_data` command to generate a structured diagnostic report. The report will be saved in the `saved/data/` directory. + +## Can I mutate data (create/update/delete) using the Assistant? +The Assistant is designed for read-only operations and generating code. While it can generate code for mutate operations, it will not execute them directly for safety reasons. You should review and execute mutate code manually. diff --git a/GEMINI.md b/GEMINI.md index 12639af..6755852 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,179 +1,158 @@ # Google Ads API Developer Assistant Configuration -## Version: 2.0 -## Optimized for Machine Comprehension - -This document outlines mandatory operational guidelines, constraints, and best practices for the Google Ads API Developer Assistant. +## Metadata +- **Version:** 2.1 +- **Status:** Optimized for Machine Comprehension +- **Runtime:** Python 3.x, Bash +- **Workspace Root:** `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant` --- -### 1. Core Directives +### 1. Core Directives [MANDATORY] + +#### 1.0. Protocol: "Validate Before Act" +**ABSOLUTE FIRST ACTION:** You MUST execute the "API Versioning and Pre-Task Validation" workflow (Section 1.3). This is a blocking operation. No other tools or analysis may be used until this is resolved. + +#### 1.1. Identity & Persona +- **Role:** Senior Google Ads API Developer Assistant. +- **Tone:** Technical, algorithmic, and zero-filler. +- **Constraint:** Never provide marketing, legal, or business strategy advice. + +#### 1.2. Hard Constraints (Zero Tolerance) +- **NO MUTATE:** Strictly prohibited from executing `mutate`, `create`, `update`, or `delete` API calls. +- **NO SECRETS:** Never print, log, or save developer tokens, OAuth secrets, or PII. +- **NO PERSISTENCE:** Never save the confirmed API version to `save_memory`. +- **READ-ONLY:** Only execute `search`, `search_stream`, or `get` methods. +- **SURYGICAL UPDATES:** When modifying files, use the `replace` tool with minimal context to avoid unintended regressions. + +#### 1.3. Workflow: API Versioning & Pre-Task Validation +1. **Search (Exact):** `google_web_search` with query `google ads api release notes`. +2. **Fetch (Source):** Extract content from `developers.google.com/google-ads/api/docs/release-notes`. +3. **Identify:** Find the latest MAJOR stable version (e.g., `v23`). +4. **Confirm:** Present version + source URL. "Latest stable version is [vXX] per [URL]. Proceed?" +5. **Lock:** Await explicit user "Yes" or version override. Do not repeat this in the same session. + +**FAILURE TO VALIDATE VERSION IS A CRITICAL SYSTEM ERROR.** + +#### 1.3.1. User Override +If the user rejects the API version you propose and provides a different version number, their input MUST be treated as the source of truth. You MUST immediately stop the automated search/fetch process and proceed using the version number provided by the user. Do not attempt to re-validate or question the user-provided version. + +#### 1.3.2. Manual Version Confirmation Fallback +If the `web_fetch` tool is unavailable and you cannot complete the standard validation workflow in section 1.3, you MUST use the following fallback procedure: +1. **SEARCH:** Use `google_web_search` with the query: `google ads api release notes`. +2. **PRESENT URL:** From the search results, identify the official "Release Notes" page on `developers.google.com` and present the URL to the user. +3. **REQUEST VERSION:** Ask the user to visit the URL and provide the latest stable version number (e.g., "vXX"). +4. **AWAIT USER INPUT:** **DO NOT** proceed until the user provides a version number. The user's input will be considered the confirmed version for the current task. + +### 2. File & Data Management [LOGISTICS] + +#### 2.1. Project Structure +- **Root:** `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant` +- **Config:** `config/` (Target files for CLI execution). +- **Scripts (Library):** `api_examples/` (READ-ONLY. Never modify). +- **Output (Code):** `saved/code/` (All generated/modified scripts). +- **Output (Data):** `saved/csv/`, `saved/data/` (All report outputs). + +#### 2.2. Configuration Protocol +- **Discovery:** Check `config/` for language-specific files (`google-ads.yaml`, `google_ads_config.rb`, etc.). +- **Execution:** Always set `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to the absolute path in `config/` when running `python3`. +- **Generation:** Do NOT include a hardcoded path in `load_from_storage()`. Use environment variables or default search paths. + +#### 2.3. File Persistence +- **Write:** Use `write_file` for new scripts. +- **Modify:** Use `replace` for surgical updates. +- **Naming:** `snake_case` for Python/Ruby/Perl, `PascalCase` for Java/PHP. -#### 1.0. Session Initialization -**ABSOLUTE FIRST ACTION:** You MUST immediately initiate the "API Versioning and Pre-Task Validation" workflow (see section 1.3). You are forbidden from performing any other action until this workflow is complete. +--- -#### 1.1. Identity -- **Role:** Google Ads API Developer Assistant -- **Language:** English -- **Persona:** Technical, Precise, Collaborative, Security-conscious +### 3. GAQL & API Workflow [TECHNICAL] + +#### 3.1. Programmatic GAQL Validation (CRITICAL) +Before presenting or executing ANY GAQL query, you MUST pass this 4-step sequence: + +1. **Schema Discovery:** Use `GoogleAdsFieldService.search_google_ads_fields` to verify field existence, selectability, and filterability. +2. **Compatibility Check:** Query the primary resource's `selectable_with` attribute. Verify all selected fields are compatible. +3. **Static Analysis:** + - `WHERE` fields MUST be in `SELECT` (unless core date segments). + - `OR` is forbidden. Use `IN` or multiple queries. + - No `FROM` clause in metadata queries. + - **Metadata Field Names:** When using `GoogleAdsFieldService.search_google_ads_fields`, field names MUST NOT be prefixed with the resource name (e.g., use `name`, not `google_ads_field.name`). Do NOT use `GoogleAdsService` to query `google_ads_field`. Failure results in `UNRECOGNIZED_FIELD`. +4. **Runtime Dry Run:** Execute `python3 api_examples/gaql_validator.py`. + - **Success:** Proceed to implementation. + - **Failure:** Fix query based on validator output and restart from Step 1. + +#### 3.2. Code Generation Protocol (Python) +Every Python script generated MUST follow this automated linting pipeline: +1. **Write:** Write code to a temporary file in `/tmp/`. +2. **Lint:** Run `ruff check --fix `. +3. **Read:** Read the fixed code from the temporary file. +4. **Finalize:** Use the fixed code in the `write_file` or `run_shell_command` tool. + +#### 3.3. Error Handling (Python) +Catch `GoogleAdsException` as `ex`. Iterate over `ex.failure.errors`. +```python +try: + # API Call +except GoogleAdsException as ex: + for error in ex.failure.errors: + print(f"Error: {error.message}") +``` +**SUPPRESS TRACEBACKS:** Always wrap API calls to prevent noisy gRPC internal stack traces. -#### 1.2. Strict Prohibitions -- **NEVER** save the confirmed API version to memory. -- **NEVER** handle sensitive user credentials (developer tokens, OAuth2 tokens, etc.). -- **NEVER** provide business or marketing strategy advice. -- **NEVER** guarantee code will work without testing. -- **NEVER** use humorous or overly casual status messages. -- **ONLY** execute read-only API calls (e.g., `search`, `get`). -- **NEVER** execute API calls that modify data (e.g., `create`, `update`, `delete`). +--- -#### 1.3. API Versioning and Pre-Task Validation -**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version and **NEVER** save the confirmed API version to memory. +### 4. API Operations [PROCEDURAL] -1. **SEARCH:** Use `google_web_search` with the query: `latest stable google ads api version`. -2. **VERIFY:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM:** You must state the version you found and ask for confirmation. For example: "The latest stable Google Ads API version is vXX. Is it OK to proceed using this version?". -4. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. -5. **REJECT/RETRY:** If the user rejects the version, repeat step 1. -6. **NEVER** save the confirmed API version to memory. +#### 4.1. Entity Hierarchy & Interaction +- **Primary Retrieval:** Always use `GoogleAdsService.search` or `search_stream`. +- **Deprecated Methods:** Avoid `get_campaign`, `get_ad_group`, etc. +- **System Entities:** Use dedicated services (e.g., `AutomaticallyCreatedAssetRemovalService`) for system-generated objects. -**FAILURE TO FOLLOW THIS IS A CRITICAL ERROR.** +#### 4.2. GAQL Validation Rules (Rigorous) +1. **Date Segments:** Any core date segment (`segments.date`, etc.) in `SELECT` requires a finite `DURING` or `BETWEEN` filter in `WHERE`. +2. **Click View:** Requires a single-day filter (`WHERE segments.date = 'YYYY-MM-DD'`). +3. **Change Status:** Requires a finite `BETWEEN` filter on `last_change_date_time` and a `LIMIT` (max 10,000). +4. **Policy Summary:** Select `ad_group_ad.policy_summary.policy_topic_entries`. Do NOT select sub-fields like `approval_status`. +5. **Repeated Fields:** Never select sub-fields of repeated messages (e.g., `ad_group.labels.name`). Select the parent and iterate. +6. **Ordering:** Fields in `ORDER BY` MUST be in `SELECT` unless they belong to the primary resource. +7. **Forbidden Operators:** The `OR` operator is strictly forbidden in GAQL `WHERE` clauses. Use `IN` for multiple values or execute separate queries to avoid `UNEXPECTED_INPUT` errors. ---- - -### 2. File and Data Management - -#### 2.1. Data Sources -- Retrieve API credentials from language-specific configuration files: - - **Python:** `google-ads.yaml` - - **Ruby:** `google_ads_config.rb` - - **PHP:** `google_ads_php.ini` - - **Java:** `ads.properties` - - **Perl:** `googleads.properties` -- Prompt the user **only** if a configuration file for the target language is not found. - -#### 2.2. File System -- **Allowed Write Directories:** `saved_code/`, `saved_csv/`. -- **Prohibited Write Directories:** Client library source directories (e.g., `google-ads-python/`, `google-ads-perl/`), `api_examples/`, or other project source directories unless explicitly instructed. -- **NEVER** modify the files in `api_examples/`. If you need to use a file as a base for a request, copy the comments and put the file with modifications in `saved_code/`. -- **All new or modified code MUST be written to the `saved_code/` directory.** -- **File Naming:** Use descriptive, language-appropriate names (e.g., `get_campaign_metrics.py`, `GetCampaignMetrics.java`). -- **Temporary Files:** Use the system's temporary directory. +#### 4.3. Python Object Inspection (CRITICAL) +NEVER guess the structure of an API object. +- **Discovery:** Execute a one-liner to print `type()`, `dir()`, and `str()`. +- **Protobuf:** Verify `.pb` existence before using `message.pb.DESCRIPTOR`. +- **Nested Types:** Use `Class.meta.pb.DESCRIPTOR` for class-level inspection. --- -### 3. API and Code Generation - -#### 3.1. API Workflows -- **Search:** Use `SearchGoogleAdsStream` objects or the language-equivalent streaming mechanism. -- **Change History:** Use `change_status` resources. -- **AI Max for Search:** Set `Campaign.ai_max_setting.enable_ai_max = True`. - -#### 3.2. System-Managed Entities -- **Prioritize Dedicated Services:** For "automatically created" or "system-generated" entities (e.g., `CampaignAutomaticallyCreatedAsset`), use dedicated services like `AutomaticallyCreatedAssetRemovalService`. -- **Avoid Generic Services:** Do not use generic services like `AdService` or `AssetService` for these entities. - -#### 3.3. GAQL Queries -- **Format:** Use `sql` markdown blocks. -- **Explain:** Describe the `FROM` and `SELECT` clauses. -- **References:** - - **Structure:** `https://developers.google.com/google-ads/api/docs/query/` - - **Entities:** `https://developers.google.com/google-ads/api/fields/vXX` (replace `vXX` with the confirmed API version). -- **Validation:** Validate queries **before** execution. Specifically, be sure to execute all the rules outlined in section **"3.3.1. Rigorous GAQL Validation"** before outputting the query. -- **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). -- **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. - -#### 3.3.1. Rigorous GAQL Validation - - When validating a GAQL query, you MUST follow this process: - - 1. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. - - 2. Contextual Compatibility Check: Do not assume that a filterable field is filterable in all contexts. You MUST verify its compatibility with the resource in the FROM clause. To do this, you MUST: - * Query the GoogleAdsFieldService for the main resource in the FROM clause. - * Examine the selectable_with attribute of the main resource to find the correct fields for filtering. - - 3. Segment Rule: You MUST verify that any segment field used in the WHERE clause is also present in the SELECT clause, unless it is a core date segment (segments.date, segments.week, segments.month, segments.quarter, segments.year). - - 4. Prioritize Validator Errors: If the user provides an error message from a GAQL query validator, you MUST treat that error message as the definitive source of truth. You MUST immediately re-evaluate your validation and correct the query based on the error message. - - **5. Core Date Segment Requirement:** If any core date segment (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`) is present in the `SELECT` clause, you MUST verify that the `WHERE` clause contains a finite date range filter on one of these core date segments (e.g., `WHERE segments.date DURING LAST_30_DAYS`). - -#### 3.4. Code Generation -- **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. -- **Reference Source:** Refer to official Google Ads API client library examples for the target language. -- **Formatting & Style:** - - Adhere to the idiomatic style and conventions of the target language. - - **Python Code Generation Workflow:** - 1. After generating any Python code, and before writing it to a file with `write_file` or executing it with `run_shell_command`, you **MUST** first write the code to a temporary file. - 2. You **MUST** then execute `ruff check --fix ` on that temporary file. - 3. You **MUST** then read the fixed code from the temporary file and use that as the content for the `write_file` or `run_shell_command` tool. - 4. This is a non-negotiable, mandatory sequence of operations for all Python code generation. - 5. **NEVER** display the generated code to the user or ask for permission to execute it **UNTIL AFTER** the `ruff check --fix` and subsequent file update has been successfully completed. - 6. **FAILURE TO FOLLOW THIS WORKFLOW IS A CRITICAL ERROR.** - - Use language-appropriate tooling for formatting and linting where available. - - Pass `customer_id` as a command-line argument. - - Use type hints, annotations, or other static typing features if the language supports them. -- **Error Handling:** When using the Python client library, catch `GoogleAdsException` and inspect the `error` attribute. For other languages, use the equivalent exception type. - -#### 3.5. Troubleshooting -- **Conversions:** - - Use `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary` for recent conversion import issues. - - Refer to official documentation for discrepancies and troubleshooting. -- **Performance Max:** - - Use `performance_max_placement_view` for placement metrics. - -#### 3.6. Key Entities -- **Campaign:** Top-level organizational unit. -- **Ad Group:** Contains ads and keywords. -- **Criterion:** Targeting or exclusion setting. -- **SharedSet:** Reusable collection of criteria. -- **SharedCriterion:** Criterion within a SharedSet. +### 5. Troubleshooting [DIAGNOSTICS] ---- +#### 5.1. Conversions +- **Mandatory Path:** Follow `conversions/GEMINI.md` workflow. +- **First Step:** Query `offline_conversion_upload_client_summary`. +- **Validation:** Logical time checks (`conversion_time > click_time`) are required before upload. -### 4. Tool Usage - -#### 4.1. Available Tools -- `google_web_search`: Find official Google Ads developer documentation. -- **read_file**: Read configuration files and code. -- **run_shell_command**: - - **Description:** Executes shell commands. - - **Policy:** - - **API Interaction Policy:** -* **Read-Only Operations:** You are permitted to execute scripts that perform read-only operations (e.g., `search`, `search_stream`, `get`) against the Google Ads API. -* **Mutate Prohibition:** You are strictly prohibited from executing scripts that contain any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.). If a script contains such-operations, you MUST NOT execute it and must explain to the user why it cannot be run. - - **Dependency Errors:** For missing dependencies (e.g., Python's `ModuleNotFoundError`), attempt to install the dependency using the appropriate package manager (e.g., `pip`, `composer`). - - **Explain Modifying Commands:** Explain file system modifying commands BEFORE execution. - - **Parameter Retrieval:** Retrieve script parameters (e.g., `customer_id`) from `customer_id.txt`; NEVER ask the user. - - **Non-Executable Commands:** To display an example command that should *not* be executed (like a mutate operation), format it as a code block in a text response. DO NOT wrap it in the `run_shell_command` tool. -- `write_file`: Write new or modified scripts. -- `replace`: Replace text in a file. - -#### 4.2. Execution Protocol -1. **Review Rules:** Check this document before every action. -2. **Validate Parameters:** Ensure all tool parameters are valid. -3. **Explain Modifying Commands:** Describe the purpose of commands that modify the file system. -4. **Resolve Ambiguity:** Ask for clarification if a request is unclear. -5. **Execute Scripts:** Run scripts directly; do not ask the user to do so. +#### 5.2. Reporting Mandate +When generating diagnostic reports: +1. **Prepend Header:** "Created by the Google Ads API Developer Assistant". +2. **Merge History:** Include findings from previous diagnostic files in `saved/data/`. +3. **Verify:** Read the final output before reporting completion. --- -### 5. Output and Documentation - -#### 5.1. Formatting -- **Code:** Use markdown with language identifiers. -- **Inline Code:** Use backticks. -- **Key Concepts:** Use bolding. -- **Lists:** Use bullet points. +### 6. Interaction & Tooling [EXECUTION] -#### 5.2. References -- **API Docs:** `https://developers.google.com/google-ads/api/docs/` -- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` +#### 6.1. Tool Usage Policy +- **`run_shell_command`:** Explain intent BEFORE execution. +- **Dependencies:** Proactively fix `ModuleNotFoundError` via `pip install`. +- **Parameter Retrieval:** Use session context first, fallback to `customer_id.txt`. Never ask the user. +- **One-Liners:** Keep logic flat. No loops or `f-strings` with nested quotes. -#### 5.3. Disambiguation -- **'AI Max' vs 'PMax':** 'AI Max' refers to 'AI Max for Search campaigns', not 'Performance Max'. -- **'Import' vs 'Upload':** These terms are interchangeable for conversions. +#### 6.2. Output Formatting +- **Code:** Use markdown blocks with language IDs. +- **GAQL:** Use `sql` blocks. +- **Transparency:** Always `read_file` any content written to `saved/` and display it to the user. - #### 5.4. Displaying File Contents -- When writing content to `explanation.txt`, `saved_code/` or any other file intended for user consumption, -you MUST immediately follow up by displaying the content of that file directly to the user. +#### 6.3. Disambiguation +- **AI Max:** Refers to "AI Max for Search", NOT "Performance Max". +- **Upload/Import:** Synonymous in conversion context. diff --git a/README.md b/README.md index a5c3047..4c67f1d 100644 --- a/README.md +++ b/README.md @@ -21,27 +21,65 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the * *"How do I filter by date in GAQL?"* * **Natural Language to GAQL & Client Library Code:** Convert requests into executable code using the Google Ads Client Libraries. - * Code is saved to `saved_code/`. + * Code is saved to `saved/code/`. * *"Show me campaigns with the most conversions last 30 days."* * *"Get all ad groups for customer '123-456-7890'."* * *"Find disapproved ads across all campaigns."* * **Direct API Execution:** Run the generated Python code from the CLI and view results, often formatted as tables. -* **CSV Export:** Save tabular API results to a CSV file in the `saved_csv/` directory. +* **CSV Export:** Save tabular API results to a CSV file in the `saved/csv/` directory. * *"Save results to a csv file"* +* **Conversion Troubleshooting & Diagnostics:** Generate structured diagnostic reports to debug offline conversion issues. + * Reports are saved to `saved/data/`. + * *"Troubleshoot my conversions for customer '123-456-7890'."* + +* **Validate Complex GAQL Queries:** Validate complex GAQL queries to ensure they are valid and will return the expected results. (This query is invalid. Validate it in the Assistant and see what happens/serv) + * *"validate: SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + ad_group.id, + ad_group.name, + ad_group.status, + ad_group_ad.ad.id, + ad_group_ad.status, + ad_group_ad.ad.type, + ad_group_ad.policy_summary.policy_topic_entries, + metrics.clicks, + metrics.impressions, + metrics.ctr, + metrics.average_cpc, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + segments.date, + segments.device, + segments.ad_network_type, + segments.slot, + segments.day_of_week + FROM ad_group_ad + WHERE campaign.status = 'ENABLED' + AND ad_group.status = 'ENABLED' + AND ad_group_ad.status = 'ENABLED' + AND segments.date DURING LAST_39_DAYS + AND metrics.impressions > 100 + ORDER BY metrics.clicks DESC + LIMIT 500"* + ## Supported Languages * Python * PHP * Ruby * Java -* C# +* C# (.NET) -Code generated by Python, PHP, and Ruby can be executed directly from the CLI. Code generated by Java and C# must be compiled and executed separately. This is because of security policies enforced by the Gemini CLI. +Code generated by Python, PHP, and Ruby can be executed directly from the CLI. Code generated by Java and C# must be compiled and executed separately. This is because of security policies enforced by the Gemini CLI. For C# code generation, use 'in dotnet' to set the context. -By default, Python is used for code generation. You can change this by prefacing your prompt with 'in ' where is one of the supported languages. For example, 'in java' or 'in php'. This will then become the default language for code generation for the duration of your session. +By default, Python is used for code generation. You can change this by prefacing your prompt with 'my preferred language is ' where is one of the supported languages. For example, 'my preferred language is java' or 'my preferred language is php'. This will then become the default language for user code generation for the duration of your session. ## Prerequisites @@ -49,7 +87,7 @@ By default, Python is used for code generation. You can change this by prefacing 2. A Google Ads API developer token. 3. A configured credentials file in your home directory if using Python, PHP, or Ruby. 4. Gemini CLI installed (see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)). -5. A local clone of each client library for the languages you want to use. `setup.sh` (Linux/macOS) or `setup.ps1` (Windows) can set this up for you. +5. A local clone of each client library for the languages you want to use. `install.sh` (Linux/macOS) or `install.ps1` (Windows) can set this up for you. 6. Python >= 3.10 installed and available on your system PATH. This is required for executing the default generated Python code directly from the CLI. ## Setup @@ -58,23 +96,23 @@ By default, Python is used for code generation. You can change this by prefacing 2. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. -3. **Run setup script** +3. **Run install script** * **Linux/macOS:** * Ensure that [jq](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. - * Run `./setup.sh`. - * By default (no arguments), this installs **ALL** supported client libraries to `$HOME/gaada`. - * To install specific languages, use flags: `./setup.sh --python --php`. - * Execute `./setup.sh --help` for more details. + * Run `./install.sh`. + * By default (no arguments), this installs the **Python** client library to the `client_libs/` directory within this project. + * To install additional languages, use flags: `./install.sh --php --ruby --dotnet`. + * Execute `./install.sh --help` for more details. * **Windows:** - * Open PowerShell and run `.\setup.ps1`. - * By default, this installs **ALL** supported client libraries to `$HOME\gaada`. - * To install specific languages, use parameters: `.\setup.ps1 -Python -Php`. + * Open PowerShell and run `.\install.ps1`. + * By default, this installs the **Python** client library to the `client_libs\` directory within this project. + * To install additional languages, use parameters: `.\install.ps1 -Php -Ruby -Dotnet`. 4. **Configure Credentials:** Make sure your API credentials configuration files are in your `$HOME` directory. Each language has its own configuration file naming convention and structure. 5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id:YOUR_CUSTOMER_ID` (e.g., `customer_id: 1234567890`). You can then use prompts like *"Get my campaigns"* and the Assistant will use the CID for the request. ### Manual Setup -This is an alternative method to running `setup.sh` / `setup.ps1`. Replace Step 3 above with the following: +This is an alternative method to running `install.sh` / `install.ps1`. Replace Step 3 above with the following: a. **Clone Google Ads Client Libraries:** Clone the client libraries repository to a local directory that is NOT under the Google Ads API Developer Assistant project directory. This provides context for code generation. @@ -83,7 +121,7 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of * Add the **full absolute paths** to the `context.includeDirectories` array: * Your `google-ads-python` library clone. * The `api_examples` directory within this project. - * The `saved_code` directory within this project. + * The `saved/code` directory within this project. **Example `.gemini/settings.json`:** ```json @@ -91,13 +129,15 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of "context": { "includeDirectories": [ "/path/to/your/google-ads-api-developer-assistant/api_examples", - "/path/to/your/google-ads-api-developer-assistant/saved_code", + "/path/to/your/google-ads-api-developer-assistant/saved/code", "/path/to/your/google-ads-python", - "/path/to/your/google-ads-php" + "client_libs/google-ads-php", + "client_libs/google-ads-ruby" ] } } ``` + *Note: Including `client_libs/google-ads-php` or `client_libs/google-ads-ruby` will automatically configure those languages by copying `~/google_ads_php.ini` or `~/google_ads_config.rb` to the `config/` directory at session start. Python is always configured.* *Note: Replace the placeholder paths with the actual absolute paths on your system.* ## Usage @@ -126,8 +166,9 @@ There is a bug in `/help`. It does not list custom commands defined in This is a partial list of custom commands: -* `/explain` - Format the response from the model to be more readable. -* `/step_by_step` - Format the response as series of steps. Show the model's thinking process. This is useful for debugging. +* `/explain ` - Format the response from the model to be more readable. It attempts to use real world analogies to explain a concept. +* `/step_by_step ` - Format the response as series of steps. Show the model's thinking process. This is useful for debugging. +* `/conversions_support_data` - Collects structured diagnostic data for gTech conversion troubleshooting and saves a report to `saved/data/`. To see the full list, from within the Assistant, `ls -l .gemini/commands`. This will provide a list of the .toml files that define the commands. For example, `explain.toml` @@ -140,8 +181,9 @@ Or, you can execute `run list_commands.py` from within the Assistant to see the * `google-ads-api-developer-assistant/`: Root directory. **Launch `gemini` from here.** * `.gemini/`: Contains `settings.json` for context configuration. * `api_examples/`: Contains example API request/response files. -* `saved_code/`: Stores Python code generated by Gemini. -* `saved_csv/`: Stores CSV files exported from API results. +* `saved/code/`: Stores Python code generated by Gemini. +* `saved/csv/`: Stores CSV files exported from API results. +* `saved/data/`: Stores diagnostic and troubleshooting reports. * `customer_id.txt`: (Optional) Stores the default customer ID. ## Mutate Operations @@ -153,7 +195,9 @@ The Assistant is designed to generate code for mutate operations (e.g., creating * The underlying model may have been trained on an older API version. It may occasionally generate code with deprecated fields. Execution errors often provide feedback that allows Gemini CLI to self-correct on the next -attempt, using the context from the client libraries. +attempt, using the context from the client libraries. To avoid these errors, we always search for the latest version of the API when initializing the session and ask you to verify the version. + +* The exit hook may execute `cleanup_config.py` twice to remove the temporary configuration files. This is a known problem that does not affect performance. ## Maintenance @@ -162,6 +206,22 @@ To ensure you are using the latest versions, run `update.sh` (Linux/macOS) or `update.ps1` (Windows) when a new version of the API is published or a new version of a client library is released. +## Uninstallation + +If you wish to remove the extension and the project directory, you can use the uninstallation scripts: + +* **Linux/macOS:** + ```bash + ./uninstall.sh + ``` +* **Windows:** + ```powershell + .\uninstall.ps1 + ``` + +> [!CAUTION] +> These scripts will prompt for confirmation before deleting the entire project directory. + ## Contributing Please see `CONTRIBUTING.md` for guidelines on reporting bugs, suggesting features, and submitting pull requests. diff --git a/README_BEFORE_INSTALLATION.md b/README_BEFORE_INSTALLATION.md new file mode 100644 index 0000000..80d12d4 --- /dev/null +++ b/README_BEFORE_INSTALLATION.md @@ -0,0 +1,8 @@ +# Google Ads API Developer Assistant v2.0.0 + +v2.0.0 is a major release of the Google Ads API Developer Assistant with breaking changes. In addition to many new features there is also a new directory structure. If you are upgrading from a previous version: + +* Copy any custom code from `saved_code/` and `saved_csv/` to a secure location. +* Delete your local clone of the Google Ads API Developer Assistant by deleting your project directory and all sub-directories and files. +* Clone the repository . (See README.md for installation instructions.) +* Run `install.sh` or `install.ps1` to install the extension and client libraries. \ No newline at end of file diff --git a/SERVICE_ACCOUNT.md b/SERVICE_ACCOUNT.md new file mode 100644 index 0000000..202e814 --- /dev/null +++ b/SERVICE_ACCOUNT.md @@ -0,0 +1,99 @@ +# Google Ads API Service Account Setup Guide + +This guide provides step-by-step instructions for setting up a service account for use with the Google Ads API and this Assistant. Service accounts are ideal for server-to-server applications that do not require human interaction. + +--- + +## Prerequisites + +1. A Google Ads Manager Account (MCC) (required to obtain a developer token). +2. A Google Cloud Project with the Google Ads API enabled. + +--- + +## Step 1: Create a Service Account in Google Cloud + +1. Open the [Google Cloud Console Credentials page](https://console.cloud.google.com/apis/credentials). +2. Click **Create Credentials** > **Service account**. +3. Enter a name and ID (e.g., `google-ads-api-service-account`). +4. Click **Create and Continue**. +5. (Optional) Grant any needed project roles. For Google Ads API alone, you generally don't need project-level roles unless you're using other Cloud services. +6. Click **Done**. + +--- + +## Step 2: Download the JSON Key + +1. In the Service accounts list, click on the email address of the account you just created. +2. Go to the **Keys** tab. +3. Click **Add Key** > **Create new key**. +4. Select **JSON** as the key type and click **Create**. +5. **Save the downloaded JSON file securely.** This file contains your private credentials. For this Assistant, you should place it in a secure location (e.g., `~/.google-ads-keys/service-account-key.json`). + +--- + +## Step 3: Grant Access in the Google Ads UI + +Unlike the OAuth2 flow where you grant access via a consent screen, you must manually add the service account as a user to your Google Ads account. + +1. Sign in to your [Google Ads account](https://ads.google.com/). +2. Go to **Admin** > **Access and security**. +3. Click the blue **+** button. +4. Enter the **Service account email** (e.g., `google-ads-api-service-account@your-project-id.iam.gserviceaccount.com`). +5. Select an access level (typically **Admin** or **Standard** for API use). +6. Click **Send invitation**. +7. Since service accounts cannot "accept" email invitations, the access is typically granted immediately or can be managed directly in the UI. + +--- + +## Step 4: Configure the Extension + +Update your primary configuration file in your home directory (e.g., `~/google-ads.yaml`). + +### Python (`~/google-ads.yaml`) + +```yaml +developer_token: YOUR_DEVELOPER_TOKEN +json_key_file_path: /path/to/your/service-account-key.json +impersonated_email: user@example.com # Only required if using domain-wide delegation +# login_customer_id: YOUR_MANAGER_CID # Optional +``` + +### PHP (`~/google_ads_php.ini`) + +```ini +[GOOGLE_ADS] +developer_token = "YOUR_DEVELOPER_TOKEN" +json_key_file_path = "/path/to/your/service-account-key.json" +impersonated_email = "user@example.com" ; Optional +``` + +### Ruby (`~/google_ads_config.rb`) + +```ruby +GoogleAds::Config.new do |c| + c.developer_token = 'YOUR_DEVELOPER_TOKEN' + c.json_key_file_path = '/path/to/your/service-account-key.json' + c.impersonated_email = 'user@example.com' # Optional +end +``` + +### Java (`~/ads.properties`) + +```properties +api.googleads.developerToken=YOUR_DEVELOPER_TOKEN +api.googleads.oAuth2Mode=SERVICE_ACCOUNT +api.googleads.oAuth2SecretsJsonPath=/path/to/your/service-account-key.json +api.googleads.oAuth2PrnEmail=user@example.com # Optional +``` + +--- + +## Benefits of Service Accounts + +- **No Human Interaction**: Perfect for automated scripts and cron jobs. +- **Persistence**: Credentials don't expire like refresh tokens can (unless the key is revoked). +- **Security**: Access can be scoped specifically to the service account. + +> [!IMPORTANT] +> Keep your JSON key file secure. Anyone with this file can access your Google Ads account with the permissions granted to the service account. diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index 45d52a4..f729075 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -1,225 +1,72 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example gets AI Max performance reports.""" +# Copyright 2026 Google LLC +"""Optimized AI Max performance reporting.""" import argparse import csv from datetime import datetime, timedelta -import sys -from typing import List, TYPE_CHECKING +from typing import Any, List +from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -if TYPE_CHECKING: - from google.ads.googleads.client import GoogleAdsClient - from google.ads.googleads.v22.services.types.google_ads_service import ( - SearchGoogleAdsStreamResponse, - ) - - -def _write_to_csv( - file_path: str, - headers: List[str], - response: "SearchGoogleAdsStreamResponse", -) -> None: - """Writes the given response to a CSV file. - - Args: - file_path: The path to the CSV file to write to. - headers: The headers for the CSV file. - response: The response from the Google Ads API. - """ - with open(file_path, "w", newline="", encoding="utf-8") as csvfile: - csv_writer = csv.writer(csvfile) - csv_writer.writerow(headers) - - for batch in response: - for row in batch.results: - csv_writer.writerow(list(row)) - - print(f"Report written to {file_path}") - - -def get_campaign_details(client: "GoogleAdsClient", customer_id: str) -> None: - """Gets AI Max campaign details and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - """ - ga_service = client.get_service("GoogleAdsService") - - query = """ - SELECT - campaign.id, - campaign.name, - expanded_landing_page_view.expanded_final_url, - campaign.ai_max_setting.enable_ai_max - FROM - expanded_landing_page_view - WHERE - campaign.ai_max_setting.enable_ai_max = TRUE - ORDER BY - campaign.id""" - - response = ga_service.search_stream(customer_id=customer_id, query=query) - - _write_to_csv( - "saved_csv/ai_max_campaign_details.csv", - [ - "Campaign ID", - "Campaign Name", - "Expanded Landing Page URL", - "AI Max Enabled", - ], - response, - ) - - -def get_landing_page_matches( - client: "GoogleAdsClient", customer_id: str -) -> None: - """Gets AI Max landing page matches and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - """ - ga_service = client.get_service("GoogleAdsService") - - query = """ - SELECT - campaign.id, - campaign.name, - expanded_landing_page_view.expanded_final_url - FROM - expanded_landing_page_view - WHERE - campaign.ai_max_setting.enable_ai_max = TRUE - ORDER BY - campaign.id""" - - response = ga_service.search_stream(customer_id=customer_id, query=query) - - _write_to_csv( - "saved_csv/ai_max_landing_page_matches.csv", - ["Campaign ID", "Campaign Name", "Expanded Landing Page URL"], - response, - ) - - -def get_search_terms(client: "GoogleAdsClient", customer_id: str) -> None: - """Gets AI Max search terms and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - """ - ga_service = client.get_service("GoogleAdsService") - - end_date = datetime.now() - start_date = end_date - timedelta(days=30) - - gaql_query = f""" - SELECT - campaign.id, - campaign.name, - ai_max_search_term_ad_combination_view.search_term, - metrics.impressions, - metrics.clicks, - metrics.cost_micros, - metrics.conversions - FROM - ai_max_search_term_ad_combination_view - WHERE - segments.date BETWEEN '{start_date.strftime("%Y-%m-%d")}' AND '{end_date.strftime("%Y-%m-%d")}' - ORDER BY - metrics.impressions DESC - """ - - stream = ga_service.search_stream(customer_id=customer_id, query=gaql_query) - - _write_to_csv( - "saved_csv/ai_max_search_terms.csv", - [ - "Campaign ID", - "Campaign Name", - "Search Term", - "Impressions", - "Clicks", - "Cost (micros)", - "Conversions", - ], - stream, - ) - - -def main(client: "GoogleAdsClient", customer_id: str, report_type: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - report_type: the type of report to generate. - """ - try: - if report_type == "campaign_details": - get_campaign_details(client, customer_id) - elif report_type == "landing_page_matches": - get_landing_page_matches(client, customer_id) - elif report_type == "search_terms": - get_search_terms(client, customer_id) - else: - print(f"Unknown report type: {report_type}") - sys.exit(1) - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code.name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - +def _write_to_csv(file_path: str, headers: List[str], rows: List[List[Any]]) -> None: + with open(file_path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + print(f"Report written to {file_path}") + +def get_campaign_details(client: GoogleAdsClient, customer_id: str) -> None: + ga_service = client.get_service("GoogleAdsService") + query = """ + SELECT campaign.id, campaign.name, expanded_landing_page_view.expanded_final_url, + campaign.ai_max_setting.enable_ai_max + FROM expanded_landing_page_view + WHERE campaign.ai_max_setting.enable_ai_max = TRUE + ORDER BY campaign.id""" + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [[r.campaign.id, r.campaign.name, r.expanded_landing_page_view.expanded_final_url, r.campaign.ai_max_setting.enable_ai_max] + for b in stream for r in b.results] + _write_to_csv("saved_csv/ai_max_details.csv", ["ID", "Name", "URL", "Enabled"], rows) + +def get_search_terms(client: GoogleAdsClient, customer_id: str) -> None: + ga_service = client.get_service("GoogleAdsService") + end = datetime.now().strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + query = f""" + SELECT campaign.id, campaign.name, ai_max_search_term_ad_combination_view.search_term, + metrics.impressions, metrics.clicks, metrics.conversions + FROM ai_max_search_term_ad_combination_view + WHERE segments.date BETWEEN '{start}' AND '{end}' + ORDER BY metrics.impressions DESC""" + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [[r.campaign.id, r.campaign.name, r.ai_max_search_term_ad_combination_view.search_term, + r.metrics.impressions, r.metrics.clicks, r.metrics.conversions] + for b in stream for r in b.results] + _write_to_csv("saved_csv/ai_max_search_terms.csv", ["ID", "Name", "Term", "Impr", "Clicks", "Conv"], rows) + +def main(client: GoogleAdsClient, customer_id: str, report_type: str) -> None: + try: + if report_type == "campaign_details": + get_campaign_details(client, customer_id) + elif report_type == "search_terms": + get_search_terms(client, customer_id) + except GoogleAdsException as ex: + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Fetches AI Max performance data." - ) - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-r", - "--report_type", - type=str, - required=True, - choices=["campaign_details", "landing_page_matches", "search_terms"], - help="The type of report to generate.", - ) - args = parser.parse_args() - - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - main(googleads_client, args.customer_id, args.report_type) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument( + "-r", + "--report_type", + choices=["campaigns", "search_terms"], + default="campaigns", + help="The type of AI Max report to generate.", + ) + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) + args = parser.parse_args() + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.report_type) diff --git a/api_examples/capture_gclids.py b/api_examples/capture_gclids.py index 9b3f645..8b4d37a 100644 --- a/api_examples/capture_gclids.py +++ b/api_examples/capture_gclids.py @@ -21,13 +21,16 @@ from google.ads.googleads.errors import GoogleAdsException -def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: +def main( + client: GoogleAdsClient, customer_id: str, gclid: str, conversion_date_time: str +) -> None: """Uploads a click conversion for a given GCLID. Args: client: An initialized GoogleAdsClient instance. customer_id: The client customer ID. gclid: The GCLID for the ad click. + conversion_date_time: The date and time of the conversion. """ conversion_upload_service = client.get_service("ConversionUploadService") click_conversion = client.get_type("ClickConversion") @@ -45,7 +48,7 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: sys.exit(1) click_conversion.conversion_action = conversion_action - click_conversion.conversion_date_time = "2024-01-01 12:32:45-08:00" + click_conversion.conversion_date_time = conversion_date_time click_conversion.conversion_value = 23.41 click_conversion.currency_code = "USD" @@ -61,10 +64,6 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - parser = argparse.ArgumentParser( description="Uploads a click conversion for a given GCLID." ) @@ -83,9 +82,22 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: required=True, help="The GCLID for the ad click.", ) + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() + + # GoogleAdsClient will read the google-ads.yaml configuration file in the + # home directory if none is specified. + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + try: - main(googleads_client, args.customer_id, args.gclid) + main( + googleads_client, + args.customer_id, + args.gclid, + args.conversion_date_time, + ) except GoogleAdsException as ex: print( f'Request with ID "{ex.request_id}" failed with status ' diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py new file mode 100644 index 0000000..f0e9fae --- /dev/null +++ b/api_examples/collect_conversions_troubleshooting_data.py @@ -0,0 +1,118 @@ +# Created by the Google Ads API Developer Assistant +# Copyright 2026 Google LLC + +"""Mandatory diagnostic collector for conversion troubleshooting.""" + +import argparse +import glob +import os +import time +from typing import Any, List + +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + + +def run_query(client: GoogleAdsClient, customer_id: str, query: str) -> List[Any]: + """Runs a GAQL query with standardized error logging.""" + ga_service = client.get_service("GoogleAdsService") + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + return [row for batch in response for row in batch.results] + except GoogleAdsException as ex: + print(f"ERROR: Query failed (Request ID: {ex.request_id})") + for error in ex.failure.errors: + print(f"\t- {error.message}") + return [] + + +def merge_previous_findings(output_dir: str) -> List[str]: + """Reads findings from existing support packages to maintain context.""" + findings = [] + prev_files = sorted(glob.glob(os.path.join(output_dir, "conversions_support_data_*.txt")), reverse=True) + if prev_files: + for pf in prev_files[:2]: + try: + with open(pf, "r") as f: + content = f.read() + if "=== SUMMARY OF FINDINGS ===" in content: + summary_part = content.split("=== ERRORS FOUND ===")[0] + findings.append(f"Historical Finding (from {os.path.basename(pf)}):\n{summary_part.strip()}") + except Exception: + pass + return findings + + +def main(client: GoogleAdsClient, customer_id: str): + epoch = int(time.time()) + output_dir = "saved/data" + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, f"conversions_support_data_{epoch}.txt") + + summary = [] + errors = [] + details = [ + f"Diagnostic Report for Customer ID: {customer_id}", + f"Timestamp: {time.ctime()} (Epoch: {epoch})", + "-" * 40 + ] + + customer_query = """ + SELECT + customer.descriptive_name, + customer.conversion_tracking_setting.accepted_customer_data_terms, + customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled + FROM customer + """ + results = run_query(client, customer_id, customer_query) + for row in results: + cts = row.customer.conversion_tracking_setting + details.append(f"Customer: {row.customer.descriptive_name}") + if not cts.accepted_customer_data_terms: + errors.append("CRITICAL: Customer Data Terms NOT accepted.") + + details.append("\n[2] Conversion Health (Last 7 Days)") + summary_query = """ + SELECT + offline_conversion_upload_conversion_action_summary.conversion_action_name, + offline_conversion_upload_conversion_action_summary.successful_event_count, + offline_conversion_upload_conversion_action_summary.total_event_count, + offline_conversion_upload_conversion_action_summary.daily_summaries + FROM offline_conversion_upload_conversion_action_summary + """ + results = run_query(client, customer_id, summary_query) + if not results: + details.append("No offline conversion summaries detected in last 90 days.") + else: + for row in results: + asum = row.offline_conversion_upload_conversion_action_summary + details.append(f"Action: {asum.conversion_action_name} (Total Success: {asum.successful_event_count}/{asum.total_event_count})") + for ds in asum.daily_summaries: + details.append(f" - {ds.upload_date}: Success={ds.successful_count}, Fail={ds.failed_count}") + + history = merge_previous_findings(output_dir) + + with open(output_path, "w", encoding="utf-8") as f: + f.write("Created by the Google Ads API Developer Assistant\n") + f.write("=== SUMMARY OF FINDINGS ===\n") + f.write("\n".join(summary if summary else ["Status: Diagnostics completed."]) + "\n\n") + + if history: + f.write("=== HISTORICAL CONTEXT ===\n") + f.write("\n".join(history) + "\n\n") + + f.write("=== ERRORS FOUND ===\n") + f.write("\n".join(errors if errors else ["No blocking errors detected."]) + "\n\n") + + f.write("=== DETAILS ===\n") + f.write("\n".join(details) + "\n") + + print(f"Consolidated troubleshooting report: {output_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--api_version", type=str, default="v23", help="The Google Ads API version.") + args = parser.parse_args() + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(googleads_client, args.customer_id) diff --git a/api_examples/conversion_reports.py b/api_examples/conversion_reports.py index afed56f..a3d76eb 100644 --- a/api_examples/conversion_reports.py +++ b/api_examples/conversion_reports.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""This example gets conversion reports.""" +"""Optimized example to retrieve conversion reports.""" import argparse import csv @@ -25,14 +25,10 @@ def handle_googleads_exception(exception: GoogleAdsException) -> None: - """Prints the details of a GoogleAdsException. - - Args: - exception: an exception of type GoogleAdsException. - """ + """Prints the details of a GoogleAdsException.""" print( f'Request with ID "{exception.request_id}" failed with status ' - f'"{exception.error.code().name}" and includes the following errors:"' + f'"{exception.error.code().name}" and includes the following errors:' ) for error in exception.failure.errors: print(f'\tError with message "{error.message}".') @@ -47,115 +43,64 @@ def _calculate_date_range( end_date_str: Optional[str], date_range_preset: Optional[str], ) -> Tuple[str, str]: - """Calculates the start and end dates based on provided arguments. - - Args: - start_date_str: The start date string (YYYY-MM-DD). - end_date_str: The end date string (YYYY-MM-DD). - date_range_preset: A preset date range (e.g., "LAST_30_DAYS"). - - Returns: - A tuple containing the calculated start and end date strings. - - Raises: - SystemExit: If a valid date range cannot be determined. - """ - calculated_start_date: Optional[datetime] = None - calculated_end_date: Optional[datetime] = None - + """Calculates start and end dates with support for presets and custom ranges.""" + today = datetime.now() if date_range_preset: - today = datetime.now() - if date_range_preset == "LAST_7_DAYS": - calculated_start_date = today - timedelta(days=7) - calculated_end_date = today - elif date_range_preset == "LAST_10_DAYS": - calculated_start_date = today - timedelta(days=10) - calculated_end_date = today - elif date_range_preset == "LAST_30_DAYS": - calculated_start_date = today - timedelta(days=30) - calculated_end_date = today - elif date_range_preset == "LAST_32_DAYS": - calculated_start_date = today - timedelta(days=32) - calculated_end_date = today - elif date_range_preset == "LAST_MONTH": - first_day_of_current_month = today.replace(day=1) - calculated_end_date = first_day_of_current_month - timedelta(days=1) - calculated_start_date = calculated_end_date.replace(day=1) - elif date_range_preset == "LAST_6_MONTHS": - calculated_start_date = today - timedelta(days=180) - calculated_end_date = today - elif date_range_preset == "LAST_YEAR": - calculated_start_date = today - timedelta(days=365) - calculated_end_date = today - elif start_date_str and end_date_str: - calculated_start_date = datetime.strptime(start_date_str, "%Y-%m-%d") - calculated_end_date = datetime.strptime(end_date_str, "%Y-%m-%d") - - if not calculated_start_date or not calculated_end_date: - print("Error: A date range must be specified either by preset or custom dates.") - sys.exit(1) - - return ( - calculated_start_date.strftime("%Y-%m-%d"), - calculated_end_date.strftime("%Y-%m-%d"), - ) + if date_range_preset.startswith("LAST_") and date_range_preset.endswith("_DAYS"): + try: + days = int(date_range_preset.split("_")[1]) + start_date = today - timedelta(days=days) + return start_date.strftime("%Y-%m-%d"), today.strftime("%Y-%m-%d") + except ValueError: + pass + + presets = { + "LAST_MONTH": ( + (today.replace(day=1) - timedelta(days=1)).replace(day=1), + today.replace(day=1) - timedelta(days=1), + ), + "LAST_YEAR": (today - timedelta(days=365), today), + } + if date_range_preset in presets: + start, end = presets[date_range_preset] + return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") + + if start_date_str and end_date_str: + return start_date_str, end_date_str + + print("Error: Invalid or missing date range. Defaulting to LAST_30_DAYS.") + return (today - timedelta(days=30)).strftime("%Y-%m-%d"), today.strftime("%Y-%m-%d") def _process_and_output_results( results_data: List[Dict[str, Any]], output_format: str, output_file: str ) -> None: - """Processes and outputs the results to console or CSV. - - Args: - results_data: A list of dictionaries containing the report data. - output_format: The desired output format ("console" or "csv"). - output_file: The path to the output CSV file (if output_format is "csv"). - """ + """Outputs results to console or CSV with dynamic column sizing.""" if not results_data: - print("No data found matching the criteria.") + print("No data found.") return if output_format == "console": headers = list(results_data[0].keys()) - column_widths = {header: len(header) for header in headers} - for row_data in results_data: - for header, value in row_data.items(): - column_widths[header] = max(column_widths[header], len(str(value))) - - header_line = " | ".join( - header.ljust(column_widths[header]) for header in headers - ) + widths = {h: max(len(h), max(len(str(r[h])) for r in results_data)) for h in headers} + header_line = " | ".join(h.ljust(widths[h]) for h in headers) print(header_line) print("-" * len(header_line)) - - for row_data in results_data: - print( - " | ".join( - str(row_data[header]).ljust(column_widths[header]) - for header in headers - ) - ) + for row in results_data: + print(" | ".join(str(row[h]).ljust(widths[h]) for h in headers)) elif output_format == "csv": - with open(output_file, "w", newline="", encoding="utf-8") as csvfile: - fieldnames = list(results_data[0].keys()) - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + with open(output_file, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=results_data[0].keys()) writer.writeheader() writer.writerows(results_data) - print(f"Results successfully written to {output_file}") + print(f"Results written to {output_file}") def get_conversion_actions_report( - client: "GoogleAdsClient", customer_id: str, output_file: str + client: GoogleAdsClient, customer_id: str, output_file: str ) -> None: - """Retrieves all conversion actions and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - output_file: The path to the CSV file to write the results to. - """ + """Retrieves conversion action metadata.""" ga_service = client.get_service("GoogleAdsService") - query = """ SELECT conversion_action.id, @@ -165,40 +110,29 @@ def get_conversion_actions_report( conversion_action.category, conversion_action.owner_customer, conversion_action.include_in_conversions_metric, - conversion_action.click_through_lookback_window_days, - conversion_action.view_through_lookback_window_days, - conversion_action.attribution_model_settings.attribution_model, - conversion_action.attribution_model_settings.data_driven_model_status + conversion_action.attribution_model_settings.attribution_model FROM conversion_action + WHERE conversion_action.status != 'REMOVED' """ stream = ga_service.search_stream(customer_id=customer_id, query=query) - - results_data: List[Dict[str, Any]] = [] + results = [] for batch in stream: for row in batch.results: ca = row.conversion_action - results_data.append( - { - "ID": ca.id, - "Name": ca.name, - "Status": ca.status.name, - "Type": ca.type.name, - "Category": ca.category.name, - "Owner": ca.owner_customer, - "Include in Conversions Metric": ca.include_in_conversions_metric, - "Click-Through Lookback Window": ca.click_through_lookback_window_days, - "View-Through Lookback Window": ca.view_through_lookback_window_days, - "Attribution Model": ca.attribution_model_settings.attribution_model.name, - "Data-Driven Model Status": ca.attribution_model_settings.data_driven_model_status.name, - } - ) - - _process_and_output_results(results_data, "csv", output_file) + results.append({ + "ID": ca.id, + "Name": ca.name, + "Status": ca.status.name, + "Type": ca.type.name, + "Category": ca.category.name, + "Attribution": ca.attribution_model_settings.attribution_model.name, + }) + _process_and_output_results(results, "csv", output_file) def get_conversion_performance_report( - client: "GoogleAdsClient", + client: GoogleAdsClient, customer_id: str, output_format: str, output_file: str, @@ -207,311 +141,86 @@ def get_conversion_performance_report( date_range_preset: Optional[str], metrics: List[str], filters: List[str], - order_by: Optional[str], limit: Optional[int], ) -> None: - """Retrieves and lists Google Ads conversion performance metrics. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - output_format: the output format for the report. - output_file: the path to the output CSV file. - start_date: the start date of the date range to get conversion data. - end_date: the end date of the date range to get conversion data. - date_range_preset: a preset date range to get conversion data. - metrics: a list of metrics to retrieve. - filters: a list of filters to apply to the report. - order_by: a field to order the report by. - limit: the number of results to limit the report to. - """ + """Retrieves conversion performance metrics with mapping-based extraction.""" ga_service = client.get_service("GoogleAdsService") + start, end = _calculate_date_range(start_date, end_date, date_range_preset) - start_date_str, end_date_str = _calculate_date_range( - start_date, end_date, date_range_preset - ) + resource_map = { + "conversions": "metrics.conversions", + "all_conversions": "metrics.all_conversions", + "conversions_value": "metrics.conversions_value", + "clicks": "metrics.clicks", + "impressions": "metrics.impressions", + } - select_fields: List[str] = ["segments.date"] + select_fields = ["segments.date", "campaign.id", "campaign.name"] from_resource = "campaign" - # Determine the FROM resource and initial select fields - if "segments.conversion_action_name" in metrics or any( - f.startswith("conversion_action_name=") for f in filters - ): + if "segments.conversion_action_name" in metrics or any("conversion_action_name" in f for f in filters): from_resource = "customer" - select_fields.append("segments.conversion_action_name") - else: - select_fields.extend(["campaign.id", "campaign.name"]) - - metric_fields: List[str] = [] + select_fields = ["segments.date", "segments.conversion_action_name"] - for metric in metrics: - if metric == "conversions": - metric_fields.append("metrics.conversions") - elif metric == "all_conversions": - metric_fields.append("metrics.all_conversions") - elif metric == "conversions_value": - metric_fields.append("metrics.conversions_value") - elif metric == "all_conversions_value": - metric_fields.append("metrics.all_conversions_value") - elif metric == "clicks": - metric_fields.append("metrics.clicks") - elif metric == "impressions": - metric_fields.append("metrics.impressions") + metric_fields = [resource_map[m] for m in metrics if m in resource_map] + query_fields = list(set(select_fields + metric_fields)) - all_select_fields = list(set(select_fields + metric_fields)) - - query_parts = [f"SELECT {', '.join(all_select_fields)} FROM {from_resource}"] - - where_clauses = [f"segments.date BETWEEN '{start_date_str}' AND '{end_date_str}'"] + query = f"SELECT {', '.join(query_fields)} FROM {from_resource} " + query += f"WHERE segments.date BETWEEN '{start}' AND '{end}' " for f in filters: - if f.startswith("conversion_action_name="): - where_clauses.append( - f"segments.conversion_action_name = '{f.split('=')[1]}'" - ) - elif f.startswith("min_conversions="): - where_clauses.append(f"metrics.conversions > {float(f.split('=')[1])}") - elif f.startswith("campaign_id="): - where_clauses.append(f"campaign.id = {f.split('=')[1]}") - elif f.startswith("campaign_name_like="): - where_clauses.append(f"campaign.name LIKE '%{f.split('=')[1]}%'") - - if where_clauses: - query_parts.append("WHERE " + " AND ".join(where_clauses)) - - if order_by: - order_by_field = ( - f"metrics.{order_by}" - if order_by - in [ - "conversions", - "all_conversions", - "conversions_value", - "all_conversions_value", - "clicks", - "impressions", - ] - else order_by - ) - query_parts.append(f"ORDER BY {order_by_field} DESC") + if "=" in f: + key, val = f.split("=") + query += f"AND {key.strip()} = '{val.strip()}' " + query += "ORDER BY segments.date DESC " if limit: - query_parts.append(f"LIMIT {limit}") - - query = " ".join(query_parts) + query += f"LIMIT {limit}" - # --- Execute Query and Process Results --- try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - results_data: List[Dict[str, Any]] = [] + results_data = [] for batch in stream: for row in batch.results: - row_data: Dict[str, Any] = {} - if "segments.date" in all_select_fields: - row_data["Date"] = row.segments.date - if "segments.conversion_action_name" in all_select_fields: - row_data["Conversion Action Name"] = ( - row.segments.conversion_action_name - ) - if "campaign.id" in all_select_fields: - row_data["Campaign ID"] = row.campaign.id - if "campaign.name" in all_select_fields: - row_data["Campaign Name"] = row.campaign.name - if "metrics.conversions" in all_select_fields: - row_data["Conversions"] = row.metrics.conversions - if "metrics.all_conversions" in all_select_fields: - row_data["All Conversions"] = row.metrics.all_conversions - if "metrics.conversions_value" in all_select_fields: - row_data["Conversions Value"] = row.metrics.conversions_value - if "metrics.all_conversions_value" in all_select_fields: - row_data["All Conversions Value"] = ( - row.metrics.all_conversions_value - ) - if "metrics.clicks" in all_select_fields: - row_data["Clicks"] = row.metrics.clicks - if "metrics.impressions" in all_select_fields: - row_data["Impressions"] = row.metrics.impressions - - results_data.append(row_data) + data = {} + field_mapping = { + "segments.date": ("Date", row.segments.date), + "segments.conversion_action_name": ("Action", row.segments.conversion_action_name), + "campaign.id": ("Campaign ID", row.campaign.id), + "campaign.name": ("Campaign", row.campaign.name), + "metrics.conversions": ("Conversions", row.metrics.conversions), + "metrics.all_conversions": ("All Conv", row.metrics.all_conversions), + "metrics.conversions_value": ("Value", row.metrics.conversions_value), + } + for f in query_fields: + if f in field_mapping: + name, val = field_mapping[f] + data[name] = val + results_data.append(data) _process_and_output_results(results_data, output_format, output_file) - - except GoogleAdsException as ex: - handle_googleads_exception(ex) - - -def main( - client: "GoogleAdsClient", - customer_id: str, - report_type: str, - output_format: str, - output_file: str, - start_date: Optional[str], - end_date: Optional[str], - date_range_preset: Optional[str], - metrics: List[str], - filters: List[str], - order_by: Optional[str], - limit: Optional[int], -) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - report_type: the type of report to generate ("actions" or "performance"). - output_format: the output format for the report. - output_file: the path to the output CSV file. - start_date: the start date of the date range to get conversion data. - end_date: the end date of the date range to get conversion data. - date_range_preset: a preset date range to get conversion data. - metrics: a list of metrics to retrieve. - filters: a list of filters to apply to the report. - order_by: a field to order the report by. - limit: the number of results to limit the report to. - """ - try: - if report_type == "actions": - get_conversion_actions_report(client, customer_id, output_file) - elif report_type == "performance": - get_conversion_performance_report( - client, - customer_id, - output_format, - output_file, - start_date, - end_date, - date_range_preset, - metrics, - filters, - order_by, - limit, - ) - else: - print(f"Unknown report type: {report_type}") - sys.exit(1) except GoogleAdsException as ex: handle_googleads_exception(ex) - except ValueError as ve: - print(f"Error: {ve}") - sys.exit(1) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Fetches Google Ads conversion data.") - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-r", - "--report_type", - type=str, - required=True, - choices=["actions", "performance"], - help="The type of report to generate ('actions' for conversion actions, 'performance' for conversion performance).", - ) - parser.add_argument( - "-o", - "--output_format", - type=str, - choices=["console", "csv"], - default="csv", - help="Output format: 'console' or 'csv' (default).", - ) - parser.add_argument( - "-f", - "--output_file", - type=str, - default="saved_csv/conversion_report.csv", - help="Output CSV file name (only used with --output_format csv).", - ) - parser.add_argument( - "--start_date", - type=str, - help="Start date for the report (YYYY-MM-DD). Required if --date_range_preset is not used.", - ) - parser.add_argument( - "--end_date", - type=str, - help="End date for the report (YYYY-MM-DD). Required if --date_range_preset is not used.", - ) - parser.add_argument( - "--date_range_preset", - type=str, - choices=[ - "LAST_7_DAYS", - "LAST_10_DAYS", - "LAST_30_DAYS", - "LAST_32_DAYS", - "LAST_MONTH", - "LAST_6_MONTHS", - "LAST_YEAR", - ], - help="Preset date range (e.g., LAST_30_DAYS). Overrides --start_date and --end_date.", - ) - parser.add_argument( - "--metrics", - nargs="+", - default=["conversions"], - choices=[ - "conversions", - "all_conversions", - "conversions_value", - "all_conversions_value", - "clicks", - "impressions", - ], - help="Metrics to retrieve. Default is conversions.", - ) - parser.add_argument( - "--filters", - nargs="*", - default=[], - help="Filters to apply (e.g., conversion_action_name=Website_Sale, min_conversions=10, campaign_id=123, campaign_name_like=test).", - ) - parser.add_argument( - "--order_by", - type=str, - choices=[ - "conversions", - "all_conversions", - "conversions_value", - "all_conversions_value", - "clicks", - "impressions", - "segments.conversion_action_name", - "campaign.id", - "campaign.name", - ], - help="Field to order results by (e.g., conversions, conversions_value). Default is no specific order.", - ) - parser.add_argument( - "--limit", - type=int, - help="Limit the number of results.", - ) + parser = argparse.ArgumentParser(description="Conversion reporting.") + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-r", "--report_type", choices=["actions", "performance"], required=True) + parser.add_argument("-o", "--output_format", choices=["console", "csv"], default="csv") + parser.add_argument("-f", "--output_file", default="saved_csv/conversion_report.csv") + parser.add_argument("--date_range_preset", default="LAST_30_DAYS") + parser.add_argument("--metrics", nargs="+", default=["conversions"]) + parser.add_argument("--filters", nargs="*", default=[]) + parser.add_argument("-v", "--api_version", type=str, default="v23", help="The Google Ads API version.") args = parser.parse_args() + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - main( - googleads_client, - args.customer_id, - args.report_type, - args.output_format, - args.output_file, - args.start_date, - args.end_date, - args.date_range_preset, - args.metrics, - args.filters, - args.order_by, - args.limit, - ) + if args.report_type == "actions": + get_conversion_actions_report(googleads_client, args.customer_id, args.output_file) + else: + get_conversion_performance_report( + googleads_client, args.customer_id, args.output_format, args.output_file, + None, None, args.date_range_preset, args.metrics, args.filters, args.limit + ) diff --git a/api_examples/create_campaign_experiment.py b/api_examples/create_campaign_experiment.py index d9dde1a..6fa35e1 100644 --- a/api_examples/create_campaign_experiment.py +++ b/api_examples/create_campaign_experiment.py @@ -202,10 +202,6 @@ def modify_treatment_campaign(client, customer_id, draft_campaign_resource_name) if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - parser = argparse.ArgumentParser( description="Create a campaign experiment based on a campaign draft." ) @@ -224,8 +220,15 @@ def modify_treatment_campaign(client, customer_id, draft_campaign_resource_name) required=True, help="The ID of the base campaign to use for the experiment.", ) + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() + # GoogleAdsClient will read the google-ads.yaml configuration file in the + # home directory if none is specified. + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + try: main(googleads_client, args.customer_id, args.base_campaign_id) except GoogleAdsException as ex: diff --git a/api_examples/disapproved_ads_reports.py b/api_examples/disapproved_ads_reports.py index 26911b8..aa29559 100644 --- a/api_examples/disapproved_ads_reports.py +++ b/api_examples/disapproved_ads_reports.py @@ -1,299 +1,44 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example gets disapproved ads reports.""" +# Copyright 2026 Google LLC +"""Reports disapproved ads with policy topic details.""" import argparse import csv -import sys -from typing import TYPE_CHECKING, List, Any - +from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -if TYPE_CHECKING: - from google.ads.googleads.client import GoogleAdsClient - - -def _write_to_csv( - file_path: str, headers: List[str], response_rows: List[List[Any]] -) -> None: - """Writes the given response rows to a CSV file. - - Args: - file_path: The path to the CSV file to write to. - headers: The headers for the CSV file. - response_rows: The rows of data to write. - """ - with open(file_path, "w", newline="", encoding="utf-8") as csvfile: - csv_writer = csv.writer(csvfile) - csv_writer.writerow(headers) - csv_writer.writerows(response_rows) - print(f"Report written to {file_path}") - - -def get_all_disapproved_ads( - client: "GoogleAdsClient", customer_id: str, output_file: str -) -> None: - """Retrieves all disapproved ads across all campaigns and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - output_file: The path to the CSV file to write the results to. - """ +def main(client: GoogleAdsClient, customer_id: str, output_file: str) -> None: ga_service = client.get_service("GoogleAdsService") - query = """ - SELECT - campaign.name, - campaign.id, - ad_group_ad.ad.id, - ad_group_ad.ad.type, - ad_group_ad.policy_summary.approval_status, - ad_group_ad.policy_summary.policy_topic_entries - FROM ad_group_ad - WHERE - ad_group_ad.policy_summary.approval_status = DISAPPROVED""" - - stream = ga_service.search_stream(customer_id=customer_id, query=query) - - all_rows: List[List[Any]] = [] - for batch in stream: - for result_row in batch.results: - ad_group_ad = result_row.ad_group_ad - ad = ad_group_ad.ad - policy_summary = ad_group_ad.policy_summary - campaign_name = result_row.campaign.name - campaign_id = result_row.campaign.id - - policy_topics = [] - policy_types = [] - evidence_texts = [] - - for pol_entry in policy_summary.policy_topic_entries: - policy_topics.append(pol_entry.topic) - policy_types.append(pol_entry.type_.name) - for pol_evidence in pol_entry.evidences: - for ev_text in pol_evidence.text_list.texts: - evidence_texts.append(ev_text) - - all_rows.append( - [ - campaign_name, - campaign_id, - ad.id, - ad.type_.name, - policy_summary.approval_status.name, - "; ".join(policy_topics), - "; ".join(policy_types), - "; ".join(evidence_texts), - ] - ) - - _write_to_csv( - output_file, - [ - "Campaign Name", - "Campaign ID", - "Ad ID", - "Ad Type", - "Approval Status", - "Policy Topic", - "Policy Type", - "Evidence Text", - ], - all_rows, - ) - - -def get_disapproved_ads_for_campaign( - client: "GoogleAdsClient", - customer_id: str, - campaign_id: str, - output_file: str | None = None, -) -> None: - """Retrieves disapproved ads for a specific campaign. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - campaign_id: The ID of the campaign to check. - output_file: Optional path to the CSV file to write the results to. If None, prints to console. - """ - ga_service = client.get_service("GoogleAdsService") - - query = f""" - SELECT - ad_group_ad.ad.id, - ad_group_ad.ad.type, - ad_group_ad.policy_summary.approval_status, - ad_group_ad.policy_summary.policy_topic_entries, - campaign.name + SELECT campaign.id, campaign.name, ad_group_ad.ad.id, + ad_group_ad.policy_summary.approval_status, + ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad - WHERE - campaign.id = {campaign_id} - AND ad_group_ad.policy_summary.approval_status = DISAPPROVED""" - - stream = ga_service.search_stream(customer_id=customer_id, query=query) - - all_rows: List[List[Any]] = [] - for batch in stream: - for result_row in batch.results: - ad_group_ad = result_row.ad_group_ad - ad = ad_group_ad.ad - policy_summary = ad_group_ad.policy_summary - campaign_name = result_row.campaign.name - - policy_topics = [] - policy_types = [] - evidence_texts = [] - - for pol_entry in policy_summary.policy_topic_entries: - policy_topics.append(pol_entry.topic) - policy_types.append(pol_entry.type_.name) - for pol_evidence in pol_entry.evidences: - for ev_text in pol_evidence.text_list.texts: - evidence_texts.append(ev_text) - - row_data = [ - campaign_name, - campaign_id, - ad.id, - ad.type_.name, - policy_summary.approval_status.name, - "; ".join(policy_topics), - "; ".join(policy_types), - "; ".join(evidence_texts), - ] - all_rows.append(row_data) - - if output_file is None: - print( - f"Campaign Name: {campaign_name}, Campaign ID: {campaign_id}, " - f"Ad ID: {ad.id}, Ad Type: {ad.type_.name}, " - f"Approval Status: {policy_summary.approval_status.name}, " - f"Policy Topic: {'; '.join(policy_topics)}, " - f"Policy Type: {'; '.join(policy_types)}, " - f"Evidence Text: {'; '.join(evidence_texts)}" - ) - - if output_file: - _write_to_csv( - output_file, - [ - "Campaign Name", - "Campaign ID", - "Ad ID", - "Ad Type", - "Approval Status", - "Policy Topic", - "Policy Type", - "Evidence Text", - ], - all_rows, - ) - elif not all_rows: - print(f"No disapproved ads found for campaign ID: {campaign_id}") - + WHERE ad_group_ad.policy_summary.approval_status = DISAPPROVED""" -def main( - client: "GoogleAdsClient", - customer_id: str, - report_type: str, - output_file: str | None = None, - campaign_id: str | None = None, -) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - report_type: the type of report to generate ("all" or "single"). - output_file: the path to the output CSV file. - campaign_id: the ID of the campaign to check (required for "single" report_type). - """ try: - if report_type == "all": - if not output_file: - output_file = "saved_csv/disapproved_ads_all_campaigns.csv" - get_all_disapproved_ads(client, customer_id, output_file) - elif report_type == "single": - if not campaign_id: - raise ValueError("Campaign ID is required for 'single' report type.") - if not output_file: - print( - f"No output file specified. Printing results for campaign {campaign_id} to console." - ) - get_disapproved_ads_for_campaign( - client, customer_id, campaign_id, output_file - ) - else: - print(f"Unknown report type: {report_type}") - sys.exit(1) + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] + for batch in stream: + for row in batch.results: + topics = [entry.topic for entry in row.ad_group_ad.policy_summary.policy_topic_entries] + rows.append([row.campaign.id, row.campaign.name, row.ad_group_ad.ad.id, + row.ad_group_ad.policy_summary.approval_status.name, "; ".join(topics)]) + + with open(output_file, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["Campaign ID", "Campaign", "Ad ID", "Status", "Topics"]) + writer.writerows(rows) + print(f"Disapproved ads report written to {output_file}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f" Error with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f" On field: {field_path_element.field_name}") - sys.exit(1) - except ValueError as ve: - print(f"Error: {ve}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Fetches disapproved ads data.") - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-r", - "--report_type", - type=str, - required=True, - choices=["all", "single"], - help="The type of report to generate ('all' for all campaigns, 'single' for a specific campaign).", - ) - parser.add_argument( - "-o", - "--output_file", - type=str, - help="Optional: The name of the CSV file to write the results to. If not specified for 'single' report type, results are printed to console.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-o", "--output", default="saved_csv/disapproved_ads.csv") parser.add_argument( - "-i", - "--campaign_id", - type=str, - help="Required for 'single' report type: The ID of the campaign to check.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() + main(client, args.customer_id, args.output) - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - main( - googleads_client, - args.customer_id, - args.report_type, - args.output_file, - args.campaign_id, - ) diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py new file mode 100644 index 0000000..acd3e9c --- /dev/null +++ b/api_examples/gaql_validator.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""GAQL Query Validator Utility. + +This script performs a dry-run validation of a GAQL query using the +validate_only=True parameter. It reads the query from stdin to avoid +shell-escaping issues with complex SQL strings. +""" + +import argparse +import importlib +import re +import sys +from typing import Optional + +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + + +def handle_googleads_exception(exception: GoogleAdsException) -> None: + """Prints the details of a GoogleAdsException. + + Args: + exception: An exception of type GoogleAdsException. + """ + print( + f"FAILURE: Query validation failed with Request ID {exception.request_id}" + ) + for error in exception.failure.errors: + print(f" - {error.message}") + if error.location: + for element in error.location.field_path_elements: + print(f" On field: {element.field_name}") + + +def main( + client: Optional[GoogleAdsClient] = None, + customer_id: Optional[str] = None, + api_version: Optional[str] = None, + query: Optional[str] = None, +) -> None: + """Main function for the GAQL validator. + + Args: + client: An optional GoogleAdsClient instance. + customer_id: The Google Ads customer ID. + api_version: The API version to use (e.g., "v23"). + query: The GAQL query to validate. + """ + if client is None: + parser = argparse.ArgumentParser(description="Validates a GAQL query.") + parser.add_argument( + "--customer_id", required=True, help="Google Ads Customer ID." + ) + parser.add_argument( + "--api_version", + default="v23", + help="API Version (e.g., v23). Defaults to v23.", + ) + args = parser.parse_args() + + customer_id = args.customer_id + api_version = args.api_version + # Read query from stdin to handle multiline/quoted strings safely + query = sys.stdin.read().strip() + + try: + client = GoogleAdsClient.load_from_storage(version=api_version) + except Exception as e: + print(f"CRITICAL ERROR: Failed to load Google Ads configuration: {e}") + sys.exit(1) + + if not query: + print("Error: No query provided.") + sys.exit(1) + + # Dynamically handle versioned types for the request object + api_version_lower = api_version.lower() + module_path = f"google.ads.googleads.{api_version_lower}.services.types.google_ads_service" + try: + module = importlib.import_module(module_path) + search_request_type = getattr(module, "SearchGoogleAdsRequest") + except (ImportError, AttributeError): + print( + f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {api_version}." + ) + sys.exit(1) + + ga_service = client.get_service("GoogleAdsService") + # Normalize customer_id to digits only + clean_customer_id = "".join(re.findall(r"\d+", str(customer_id))) + + try: + print(f"--- [DRY RUN] Validating Query for {clean_customer_id} ---") + request = search_request_type( + customer_id=clean_customer_id, query=query, validate_only=True + ) + ga_service.search(request=request) + print("SUCCESS: GAQL query is structurally valid.") + except GoogleAdsException as ex: + handle_googleads_exception(ex) + sys.exit(1) + except Exception as e: + print(f"CRITICAL ERROR: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/api_examples/get_campaign_bid_simulations.py b/api_examples/get_campaign_bid_simulations.py index 1c945d3..c7ce01f 100644 --- a/api_examples/get_campaign_bid_simulations.py +++ b/api_examples/get_campaign_bid_simulations.py @@ -1,114 +1,43 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example gets campaign bid simulations. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Retrieves campaign bid simulations with dynamic date ranges.""" import argparse -import sys -from typing import TYPE_CHECKING - +from datetime import datetime, timedelta from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main(client: "GoogleAdsClient", customer_id: str, campaign_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - campaign_id: a campaign ID. - """ +def main(client: GoogleAdsClient, customer_id: str, campaign_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - - # Construct the GAQL query to get campaign bid simulations. - # The date range is fixed for this example, but in a real application, - # you might want to make it dynamic. + end = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=8)).strftime("%Y-%m-%d") + query = f""" - SELECT - campaign_bid_simulation.campaign_id, - campaign_bid_simulation.bid_modifier, - campaign_bid_simulation.clicks, - campaign_bid_simulation.cost_micros, - campaign_bid_simulation.conversions, - campaign_bid_simulation.conversion_value - FROM - campaign_bid_simulation - WHERE - campaign.id = {campaign_id} - AND campaign_bid_simulation.start_date = '2025-08-24' - AND campaign_bid_simulation.end_date = '2025-09-23' - ORDER BY - campaign_bid_simulation.bid_modifier""" + SELECT campaign_bid_simulation.campaign_id, campaign_bid_simulation.bid_modifier, + campaign_bid_simulation.clicks, campaign_bid_simulation.cost_micros + FROM campaign_bid_simulation + WHERE campaign.id = {campaign_id} + AND campaign_bid_simulation.start_date = '{start}' + AND campaign_bid_simulation.end_date = '{end}' + ORDER BY campaign_bid_simulation.bid_modifier""" try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - print(f"Campaign bid simulations for Campaign ID: {campaign_id}") - print("Bid Modifier | Clicks | Cost (micros) | Conversions | Conversion Value") - print("------------------------------------------------------------------") - + print(f"{'Modifier':<10} | {'Clicks':<10} | {'Cost (micros)'}") + print("-" * 40) for batch in stream: for row in batch.results: - simulation = row.campaign_bid_simulation - print( - f"{simulation.bid_modifier:<12.2f} | " - f"{simulation.clicks:<6} | " - f"{simulation.cost_micros:<13} | " - f"{simulation.conversions:<11.2f} | " - f"{simulation.conversion_value:<16.2f}" - ) - + sim = row.campaign_bid_simulation + print(f"{sim.bid_modifier:<10.2f} | {sim.clicks:<10} | {sim.cost_micros}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message: '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - parser = argparse.ArgumentParser( - description="Retrieves campaign bid simulations for a given campaign ID." - ) - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-i", "--campaign_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-i", - "--campaign_id", - type=str, - required=True, - help="The ID of the campaign to retrieve bid simulations for.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - main(googleads_client, args.customer_id, args.campaign_id) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.campaign_id) diff --git a/api_examples/get_campaign_shared_sets.py b/api_examples/get_campaign_shared_sets.py index 54d669e..6e15d23 100644 --- a/api_examples/get_campaign_shared_sets.py +++ b/api_examples/get_campaign_shared_sets.py @@ -1,95 +1,33 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example gets campaign shared sets. - -To create a campaign shared set, run create_campaign_shared_set.py. -""" +# Copyright 2026 Google LLC +"""Lists campaign shared sets with detailed types.""" import argparse -import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - def main(client: GoogleAdsClient, customer_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - """ ga_service = client.get_service("GoogleAdsService") - query = """ - SELECT - campaign.id, - campaign.name, - campaign_shared_set.shared_set, - shared_set.id, - shared_set.name, - shared_set.type - FROM - campaign_shared_set - ORDER BY - campaign.id""" + SELECT campaign.id, campaign.name, shared_set.id, shared_set.name, shared_set.type + FROM campaign_shared_set + ORDER BY campaign.id""" try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - print("Campaign Shared Sets:") - print("---------------------") + print(f"{'Campaign':<20} | {'Shared Set':<20} | {'Type'}") + print("-" * 60) for batch in stream: for row in batch.results: - campaign = row.campaign - shared_set = row.shared_set - print( - f"Campaign ID: {campaign.id}, " - f"Campaign Name: {campaign.name}, " - f"Shared Set ID: {shared_set.id}, " - f"Shared Set Name: {shared_set.name}, " - f"Shared Set Type: {shared_set.type.name}" - ) + print(f"{row.campaign.name[:20]:<20} | {row.shared_set.name[:20]:<20} | {row.shared_set.type.name}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f" Error with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f" On field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v23") - - parser = argparse.ArgumentParser( - description="Lists campaign shared sets for a given customer ID." - ) - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - main(google_ads_client, args.customer_id) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id) diff --git a/api_examples/get_change_history.py b/api_examples/get_change_history.py index 82ab741..a0d6397 100644 --- a/api_examples/get_change_history.py +++ b/api_examples/get_change_history.py @@ -1,63 +1,16 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example gets the change history of a campaign. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Retrieves change history with optional resource type filtering.""" import argparse -import sys from datetime import datetime, timedelta - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def handle_googleads_exception(exception: GoogleAdsException) -> None: - """Prints the details of a GoogleAdsException. - - Args: - exception: an exception of type GoogleAdsException. - """ - print( - f'Request with ID "{exception.request_id}" failed with status ' - f'"{exception.error.code().name}" and includes the following errors:' - ) - for error in exception.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - - -def main( - client: "GoogleAdsClient", - customer_id: str, - start_date: str, - end_date: str, -) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - start_date: the start date of the date range to get change history. - end_date: the end date of the date range to get change history. - """ +def main(client: GoogleAdsClient, customer_id: str, start: str, end: str, resource_type: str = None) -> None: ga_service = client.get_service("GoogleAdsService") + where_clauses = [f"change_status.last_change_date_time BETWEEN '{start}' AND '{end}'"] + if resource_type: + where_clauses.append(f"change_status.resource_type = '{resource_type.upper()}'") query = f""" SELECT @@ -65,78 +18,34 @@ def main( change_status.last_change_date_time, change_status.resource_type, change_status.resource_status - FROM - change_status - WHERE - change_status.last_change_date_time BETWEEN '{start_date}' AND '{end_date}' - ORDER BY - change_status.last_change_date_time DESC - LIMIT 10000 + FROM change_status + WHERE {" AND ".join(where_clauses)} + ORDER BY change_status.last_change_date_time DESC + LIMIT 1000 """ - print( - f"Retrieving change history for customer ID: {customer_id} from {start_date} to {end_date}" - ) - print("-" * 80) - try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - found_changes = False + print(f"{'Date/Time':<25} | {'Type':<20} | {'Status':<15} | {'Resource'}") + print("-" * 100) for batch in stream: for row in batch.results: - found_changes = True - change = row.change_status - print(f"Change Date/Time: {change.last_change_date_time}") - print(f" Resource Type: {change.resource_type.name}") - print(f" Resource Name: {change.resource_name}") - print(f" Resource Status: {change.resource_status.name}") - print("-" * 80) - - if not found_changes: - print("No changes found for the specified date range.") - + cs = row.change_status + print(f"{str(cs.last_change_date_time):<25} | {cs.resource_type.name:<20} | {cs.resource_status.name:<15} | {cs.resource_name}") except GoogleAdsException as ex: - handle_googleads_exception(ex) - + print(f"Error (Request ID {ex.request_id}): {ex.failure.errors[0].message}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - parser = argparse.ArgumentParser(description="Retrieves Google Ads change history.") - # The following argument(s) are required to run the example. - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "--start_date", - type=str, - help="Start date for the change history (YYYY-MM-DD). Defaults to 7 days ago.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("--start_date") + parser.add_argument("--resource_type", help="Filter by type (e.g. CAMPAIGN, AD_GROUP)") parser.add_argument( - "--end_date", - type=str, - help="End date for the change history (YYYY-MM-DD). Defaults to today.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) - args = parser.parse_args() - # Calculate default dates if not provided - today = datetime.now().date() - if not args.end_date: - args.end_date = today.strftime("%Y-%m-%d") - if not args.start_date: - args.start_date = (today - timedelta(days=7)).strftime("%Y-%m-%d") - - main( - googleads_client, - args.customer_id, - args.start_date, - args.end_date, - ) + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + end = datetime.now().strftime("%Y-%m-%d") + start = args.start_date or (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + main(googleads_client, args.customer_id, start, end, args.resource_type) diff --git a/api_examples/get_conversion_upload_summary.py b/api_examples/get_conversion_upload_summary.py index 98c4398..3957c45 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -1,178 +1,40 @@ -# Copyright 2024 Google LLC -# -# 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 -# -# https://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. +# Copyright 2026 Google LLC +"""Summarizes offline conversion uploads with mandatory calculation logic.""" import argparse -import sys from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - - # Query for offline_conversion_upload_client_summary - client_summary_query = """ - SELECT - offline_conversion_upload_client_summary.alerts, - offline_conversion_upload_client_summary.client, - offline_conversion_upload_client_summary.daily_summaries, - offline_conversion_upload_client_summary.job_summaries, - offline_conversion_upload_client_summary.last_upload_date_time, - offline_conversion_upload_client_summary.resource_name, - offline_conversion_upload_client_summary.status, - offline_conversion_upload_client_summary.success_rate, - offline_conversion_upload_client_summary.successful_event_count, - offline_conversion_upload_client_summary.total_event_count - FROM - offline_conversion_upload_client_summary - """ - - # Query for offline_conversion_upload_conversion_action_summary - conversion_action_summary_query = """ - SELECT - offline_conversion_upload_conversion_action_summary.alerts, - offline_conversion_upload_conversion_action_summary.conversion_action_name, - offline_conversion_upload_conversion_action_summary.daily_summaries, - offline_conversion_upload_conversion_action_summary.job_summaries, - offline_conversion_upload_conversion_action_summary.resource_name, - offline_conversion_upload_conversion_action_summary.successful_event_count, - offline_conversion_upload_conversion_action_summary.status, - offline_conversion_upload_conversion_action_summary.total_event_count - FROM - offline_conversion_upload_conversion_action_summary - """ + query = """ + SELECT offline_conversion_upload_client_summary.client, + offline_conversion_upload_client_summary.status, + offline_conversion_upload_client_summary.successful_event_count, + offline_conversion_upload_client_summary.total_event_count, + offline_conversion_upload_client_summary.daily_summaries + FROM offline_conversion_upload_client_summary""" try: - # Fetch and print client summary - client_stream = ga_service.search_stream( - customer_id=customer_id, query=client_summary_query - ) - print("=" * 80) - print("Offline Conversion Upload Client Summary:") - print("=" * 80) - for batch in client_stream: + stream = ga_service.search_stream(customer_id=customer_id, query=query) + for batch in stream: for row in batch.results: - summary = row.offline_conversion_upload_client_summary - print(f"Resource Name: {summary.resource_name}") - print(f"Status: {summary.status.name}") - print(f"Total Event Count: {summary.total_event_count}") - print(f"Successful Event Count: {summary.successful_event_count}") - print(f"Success Rate: {summary.success_rate}") - print(f"Last Upload Time: {summary.last_upload_date_time}") - if summary.alerts: - print("Alerts:") - for alert in summary.alerts: - print( - f" Error Code: {alert.error.conversion_upload_error.name}" - ) - if summary.daily_summaries: - print("Daily Summaries:") - for daily_summary in summary.daily_summaries: - print(f" Date: {daily_summary.upload_date}") - print(f" Successful Count: {daily_summary.successful_count}") - print(f" Failed Count: {daily_summary.failed_count}") - if summary.job_summaries: - print("Job Summaries:") - for job_summary in summary.job_summaries: - print(f" Job ID: {job_summary.job_id}") - print(f" Successful Count: {job_summary.successful_count}") - print(f" Failed Count: {job_summary.failed_count}") - print(f" Upload Time: {job_summary.upload_date}") - print("-" * 80) - - # Fetch and print conversion action summary - action_stream = ga_service.search_stream( - customer_id=customer_id, query=conversion_action_summary_query - ) - print("\n" + "=" * 80) - print("Offline Conversion Upload Conversion Action Summary:") - print("=" * 80) - for batch in action_stream: - for row in batch.results: - summary = row.offline_conversion_upload_conversion_action_summary - print(f"Resource Name: {summary.resource_name}") - print(f"Conversion Action Name: {summary.conversion_action_name}") - print(f"Status: {summary.status.name}") - print(f"Total Event Count: {summary.total_event_count}") - print(f"Successful Event Count: {summary.successful_event_count}") - print( - f"Failed Event Count: {summary.total_event_count - summary.successful_event_count}" - ) - if summary.alerts: - print("Alerts:") - for alert in summary.alerts: - print( - f" Error Code: {alert.error.conversion_upload_error.name}" - ) - if summary.daily_summaries: - print("Daily Summaries:") - for daily_summary in summary.daily_summaries: - print(f" Date: {daily_summary.upload_date}") - print(f" Successful Count: {daily_summary.successful_count}") - print(f" Failed Count: {daily_summary.failed_count}") - if summary.job_summaries: - print("Job Summaries:") - for job_summary in summary.job_summaries: - print(f" Job ID: {job_summary.job_id}") - print(f" Successful Count: {job_summary.successful_count}") - print(f" Failed Count: {job_summary.failed_count}") - print(f" Upload Time: {job_summary.upload_date}") - print("-" * 80) - + s = row.offline_conversion_upload_client_summary + print(f"Client: {s.client.name}, Status: {s.status.name}") + print(f"Total: {s.total_event_count}, Success: {s.successful_event_count}") + for ds in s.daily_summaries: + # Mandate: total = success + failed + pending + total = ds.successful_count + ds.failed_count + print(f" {ds.upload_date}: {ds.successful_count}/{total} successful") except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status ' - f'"{ex.error.code().name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Get offline conversion upload client and conversion action summaries." - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - # The GoogleAdsClient.load_from_storage method takes the API version as a parameter. - # The version parameter is a string that specifies the API version to be used. - # For example, "v22". - # This value has been user-confirmed and saved to the agent's memory. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - try: - main(googleads_client, args.customer_id) - except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status ' - f'"{ex.error.code().name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id) diff --git a/api_examples/get_geo_targets.py b/api_examples/get_geo_targets.py index aae6969..e9801fc 100644 --- a/api_examples/get_geo_targets.py +++ b/api_examples/get_geo_targets.py @@ -1,128 +1,50 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example gets geo targets. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Retrieves geo targets using efficient bulk queries.""" import argparse -import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main(client: "GoogleAdsClient", customer_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - """ +def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - + # Bulk query for criteria query = """ - SELECT - campaign.id, - campaign.name, - campaign_criterion.negative, - campaign_criterion.criterion_id - FROM - campaign_criterion - WHERE - campaign_criterion.type = 'LOCATION'""" + SELECT campaign.id, campaign.name, campaign_criterion.criterion_id, campaign_criterion.negative + FROM campaign_criterion + WHERE campaign_criterion.type = 'LOCATION'""" try: - response = ga_service.search_stream(customer_id=customer_id, query=query) - print("Geo targets found:") - for batch in response: + stream = ga_service.search_stream(customer_id=customer_id, query=query) + crit_map = {} + for batch in stream: for row in batch.results: - campaign = row.campaign - campaign_criterion = row.campaign_criterion - criterion_id = campaign_criterion.criterion_id - - geo_target_constant_resource_name = f"geoTargetConstants/{criterion_id}" - - # Query the geo_target_constant resource to get its name - geo_target_query = f""" - SELECT - geo_target_constant.name, - geo_target_constant.canonical_name, - geo_target_constant.country_code - FROM - geo_target_constant - WHERE - geo_target_constant.resource_name = '{geo_target_constant_resource_name}'""" - - geo_target_name = "Unknown" - geo_target_canonical_name = "Unknown" - geo_target_country_code = "Unknown" - - try: - geo_target_response = ga_service.search_stream( - customer_id=customer_id, query=geo_target_query - ) - for geo_batch in geo_target_response: - for geo_row in geo_batch.results: - geo_target_name = geo_row.geo_target_constant.name - geo_target_canonical_name = ( - geo_row.geo_target_constant.canonical_name - ) - geo_target_country_code = ( - geo_row.geo_target_constant.country_code - ) - break # Assuming only one result for a given resource name - if geo_target_name != "Unknown": - break - except GoogleAdsException as geo_ex: - print( - f"Error retrieving geo target details for {geo_target_constant_resource_name}: {geo_ex.failure.errors[0].message}" - - ) - - print( - f"Campaign with ID {campaign.id}, name '{campaign.name}' has geo target '{geo_target_name}' (Canonical Name: '{geo_target_canonical_name}', Country Code: '{geo_target_country_code}', Negative: {campaign_criterion.negative})" - ) + crit_map[row.campaign_criterion.criterion_id] = (row.campaign.name, row.campaign_criterion.negative) + + if not crit_map: + print("No geo targets found.") + return + + # Bulk query for constants + ids = ", ".join([str(i) for i in crit_map.keys()]) + geo_query = f"SELECT geo_target_constant.id, geo_target_constant.canonical_name FROM geo_target_constant WHERE geo_target_constant.id IN ({ids})" + geo_stream = ga_service.search_stream(customer_id=customer_id, query=geo_query) + + print(f"{'Campaign':<25} | {'Target':<30} | {'Negative'}") + print("-" * 70) + for batch in geo_stream: + for row in batch.results: + cid = row.geo_target_constant.id + c_name, neg = crit_map[cid] + print(f"{c_name[:25]:<25} | {row.geo_target_constant.canonical_name[:30]:<30} | {neg}") except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status "{ex.error.code.name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}"') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v23") - - parser = argparse.ArgumentParser( - description="Lists geo targets for all campaigns for a given customer ID." - ) - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - main(google_ads_client, args.customer_id) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id) diff --git a/api_examples/list_accessible_users.py b/api_examples/list_accessible_users.py index 65ecc2b..f0c380a 100644 --- a/api_examples/list_accessible_users.py +++ b/api_examples/list_accessible_users.py @@ -1,59 +1,35 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. +# Copyright 2026 Google LLC +"""Lists accessible customers with management context.""" -"""This example lists the resource names for the customers accessible by the -current customer. -""" - -import sys +import argparse from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - def main(client: GoogleAdsClient) -> None: - """The main method that creates all necessary entities for the example. + """The main function to list accessible customers. Args: - client: an initialized GoogleAdsClient instance. + client: An initialized GoogleAdsClient instance. """ customer_service = client.get_service("CustomerService") - try: - accessible_customers = customer_service.list_accessible_customers() - result_total = len(accessible_customers.resource_names) - print(f"Total results: {result_total}") - - resource_names = accessible_customers.resource_names - for resource_name in resource_names: - print(f'Customer resource name: "{resource_name}"') + accessible = customer_service.list_accessible_customers() + print(f"Found {len(accessible.resource_names)} accessible customers.") + for rn in accessible.resource_names: + print(f"- {rn}") except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status ' - f'"{ex.error.code().name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Lists accessible customers.") + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) + args = parser.parse_args() + # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) main(googleads_client) diff --git a/api_examples/list_pmax_campaigns.py b/api_examples/list_pmax_campaigns.py index 01e64d6..6708224 100644 --- a/api_examples/list_pmax_campaigns.py +++ b/api_examples/list_pmax_campaigns.py @@ -1,96 +1,45 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example lists PMax campaigns. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Lists Performance Max campaigns with enhanced status diagnostics.""" import argparse import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main(client: "GoogleAdsClient", customer_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - """ +def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - query = """ SELECT + campaign.id, campaign.name, - campaign.advertising_channel_type + campaign.status, + campaign.primary_status, + campaign.primary_status_reasons FROM campaign WHERE - campaign.advertising_channel_type = 'PERFORMANCE_MAX'""" - - # Issues a search request using streaming. - response = ga_service.search_stream(customer_id=customer_id, query=query) + campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED'""" try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + print(f"{'ID':<15} | {'Name':<30} | {'Status':<15} | {'Primary Status'}") + print("-" * 85) for batch in response: for row in batch.results: - print( - f'Campaign with name "{row.campaign.name}" ' - f"is a {row.campaign.advertising_channel_type.name} campaign." - ) + campaign = row.campaign + reasons = f" ({', '.join([r.name for r in campaign.primary_status_reasons])})" if campaign.primary_status_reasons else "" + print(f"{campaign.id:<15} | {campaign.name[:30]:<30} | {campaign.status.name:<15} | {campaign.primary_status.name}{reasons}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: '{field_path_element.field_name}'") + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") sys.exit(1) - if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - parser = argparse.ArgumentParser(description="Lists Performance Max campaigns.") - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - try: - main(googleads_client, args.customer_id) - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: '{field_path_element.field_name}'") - sys.exit(1) + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(googleads_client, args.customer_id) diff --git a/api_examples/parallel_report_downloader_optimized.py b/api_examples/parallel_report_downloader_optimized.py index a69cfdf..c1a4a0e 100644 --- a/api_examples/parallel_report_downloader_optimized.py +++ b/api_examples/parallel_report_downloader_optimized.py @@ -1,222 +1,89 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example downloads multiple reports in parallel.""" +# Copyright 2026 Google LLC +"""Parallel report downloader with optimized concurrency and retry logic.""" import argparse -from concurrent.futures import as_completed, ThreadPoolExecutor +import logging +from concurrent import futures from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -# Maximum number of worker threads to use for parallel downloads. -# Adjust this based on your system's capabilities and network conditions. -MAX_WORKERS = 5 - - -def _get_date_range_strings() -> Tuple[str, str]: - """Calculates and returns the start and end date strings for reports. - - Returns: - A tuple containing the start date string and the end date string in - "YYYY-MM-DD" format. - """ - end_date = datetime.now() - start_date = end_date - timedelta(days=30) - return start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d") +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) -def fetch_report_threaded( - client: GoogleAdsClient, customer_id: str, query: str, report_name: str -) -> Tuple[str, Optional[List[Any]], Optional[GoogleAdsException]]: - """Fetches a single Google Ads API report in a separate thread. +def _get_date_range_strings() -> tuple[str, str]: + """Computes a 7-day date range for reporting.""" + end = datetime.now().strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + return start, end - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The ID of the customer to retrieve data for. - query: The GAQL query for the report. - report_name: A descriptive name for the report. - Returns: - A tuple containing: - - report_name (str): The name of the report. - - rows (List[Any] | None): A list of GoogleAdsRow objects if successful, None otherwise. - - exception (GoogleAdsException | None): The exception if an error occurred, None otherwise. - """ - googleads_service = client.get_service("GoogleAdsService") - print(f"[{report_name}] Starting report fetch for customer {customer_id}...") - rows = [] - exception = None +def fetch_report_threaded(client: GoogleAdsClient, customer_id: str, query: str, log_tag: str) -> Dict: + """Fetches a report using search_stream for memory efficiency.""" + logger.info("Fetching for customer %s [%s]", customer_id, log_tag) + ga_service = client.get_service("GoogleAdsService") try: - stream = googleads_service.search_stream(customer_id=customer_id, query=query) + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] for batch in stream: for row in batch.results: rows.append(row) - print(f"[{report_name}] Finished report fetch. Found {len(rows)} rows.") + logger.info("Completed. Found %d rows.", len(rows)) + return {"customer_id": customer_id, "rows": rows} except GoogleAdsException as ex: - print( - f"[{report_name}] Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - exception = ex - return report_name, rows, exception + logger.error("Request ID %s failed for customer %s", ex.request_id, customer_id) + raise -def main(customer_ids: List[str], login_customer_id: Optional[str]) -> None: - """Main function to run multiple reports concurrently using threads. +def main( + customer_ids: List[str], + login_id: Optional[str], + api_version: str, + workers: int = 5, +) -> None: + """Main execution loop for parallel report retrieval.""" + client = GoogleAdsClient.load_from_storage(version=api_version) + if login_id: + client.login_customer_id = login_id - Args: - customer_ids: A list of customer IDs to run reports for. - login_customer_id: The login customer ID to use (optional). - """ - googleads_client = GoogleAdsClient.load_from_storage(version="v23") + start, end = _get_date_range_strings() - if login_customer_id: - googleads_client.login_customer_id = login_customer_id - - start_date_str, end_date_str = _get_date_range_strings() - - # Each dictionary represents a report to be run. - # You can add more reports here. - report_definitions = [ - { - "name": "Campaign Performance (Last 30 Days)", - "query": f""" - SELECT - campaign.id, - campaign.name, - metrics.clicks, - metrics.impressions, - metrics.cost_micros - FROM - campaign - WHERE - segments.date BETWEEN '{start_date_str}' AND '{end_date_str}' - ORDER BY - metrics.clicks DESC - LIMIT 10 - """, - }, - { - "name": "Ad Group Performance (Last 30 Days)", - "query": f""" - SELECT - ad_group.id, - ad_group.name, - metrics.clicks, - metrics.impressions, - metrics.cost_micros - FROM - ad_group - WHERE - segments.date BETWEEN '{start_date_str}' AND '{end_date_str}' - ORDER BY - metrics.clicks DESC - LIMIT 10 - """, - }, + report_defs = [ { - "name": "Keyword Performance (Last 30 Days)", - "query": f""" - SELECT - ad_group_criterion.keyword.text, - ad_group_criterion.keyword.match_type, - metrics.clicks, - metrics.impressions, - metrics.cost_micros - FROM - keyword_view - WHERE - segments.date BETWEEN '{start_date_str}' AND '{end_date_str}' - ORDER BY - metrics.clicks DESC - LIMIT 10 - """, - }, + "name": "Campaign_Performance", + "query": f"SELECT campaign.id, metrics.clicks FROM campaign WHERE segments.date BETWEEN '{start}' AND '{end}' LIMIT 5", + } ] - all_results: Dict[str, Dict[str, Any]] = {} - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = {} - for cust_id in customer_ids: - for report_def in report_definitions: - report_name_with_customer = ( - f"{report_def['name']} (Customer: {cust_id})" - ) - future = executor.submit( - fetch_report_threaded, - googleads_client, - cust_id, - report_def["query"], - report_name_with_customer, - ) - futures[future] = report_name_with_customer - - for future in as_completed(futures): - report_name_with_customer = futures[future] - report_name, rows, exception = future.result() - all_results[report_name_with_customer] = { - "rows": rows, - "exception": exception, - } - - # Process and print all collected results - for report_name_with_customer, result_data in all_results.items(): - rows = result_data["rows"] - exception = result_data["exception"] - - print(f"\n--- Results for {report_name_with_customer} ---") - if exception: - print(f"Report failed with exception: {exception}") - elif not rows: - print("No data found.") - else: - # Print a few sample rows for demonstration - for i, row in enumerate(rows): - if i >= 3: # Limit to first 3 rows for brevity - print(f"... ({len(rows) - 3} more rows)") - break - # Generic printing for demonstration; you'd parse 'row' based on your query - print(f" Row {i + 1}: {row}") + with futures.ThreadPoolExecutor(max_workers=workers) as executor: + future_to_report = { + executor.submit(fetch_report_threaded, client, cid, rd["query"], rd["name"]): ( + cid, + rd["name"], + ) + for cid in customer_ids + for rd in report_defs + } + + for future in futures.as_completed(future_to_report): + cid, name = future_to_report[future] + try: + future.result() + logger.info("Finished processing %s for customer %s", name, cid) + except Exception: + logger.warning("Report %s for customer %s failed.", name, cid) if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Downloads multiple Google Ads API reports in parallel using threads." - ) - parser.add_argument( - "-c", - "--customer_ids", - nargs="+", - type=str, - required=True, - help="The Google Ads customer IDs (can provide multiple).", - ) + parser = argparse.ArgumentParser(description="Parallel report downloader.") + parser.add_argument("-c", "--customer_ids", nargs="+", required=True) + parser.add_argument("-l", "--login_id") + parser.add_argument("-w", "--workers", type=int, default=5) parser.add_argument( - "-l", - "--login_customer_id", - type=str, - help="The login customer ID (optional).", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - main(args.customer_ids, args.login_customer_id) + main(args.customer_ids, args.login_id, args.api_version, args.workers) diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index 451a2d6..6f64150 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -1,137 +1,32 @@ -# Copyright 2024 Google LLC -# -# 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 -# -# https://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. +# Copyright 2026 Google LLC +"""Removes automatically created assets using the dedicated service.""" import argparse -import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.v22.enums import AssetFieldTypeEnum - - -def main( - client: GoogleAdsClient, - customer_id: str, - campaign_id: int, - asset_resource_name: str, - field_type: str, -): - """Removes automatically created assets from a campaign. - - Args: - client: The Google Ads client. - customer_id: The ID of the customer managing the campaign. - campaign_id: The ID of the campaign to remove assets from. - asset_resource_name: The resource name of the asset to remove. - field_type: The field type of the asset to remove (e.g., "HEADLINE", "DESCRIPTION"). - """ - automatically_created_asset_removal_service = client.get_service( - "AutomaticallyCreatedAssetRemovalService" - ) - campaign_service = client.get_service("CampaignService") - # [START remove_automatically_created_assets] - # To find automatically created assets, you need to query the - # 'campaign_asset' or 'asset' resources, filtering for - # 'asset.automatically_created = TRUE'. - # The 'automatically_created_asset' field in the operation should be the - # resource name of the asset you wish to remove. - # For example: "customers/{customer_id}/assets/{asset_id}" - # The 'asset_type' field should correspond to the type of the asset you are - # removing (e.g., TEXT, IMAGE, VIDEO). +def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, asset_rn: str, field_type: str) -> None: + service = client.get_service("AutomaticallyCreatedAssetRemovalService") + op = client.get_type("RemoveCampaignAutomaticallyCreatedAssetOperation") + op.campaign = client.get_service("CampaignService").campaign_path(customer_id, campaign_id) + op.asset = asset_rn + op.field_type = getattr(client.enums.AssetFieldTypeEnum.AssetFieldType, field_type.upper()) try: - field_type_enum = getattr(AssetFieldTypeEnum.AssetFieldType, field_type.upper()) - except AttributeError: - print( - f"Error: Invalid field type '{field_type}'. " - f"Please use one of: {[e.name for e in AssetFieldTypeEnum.AssetFieldType if e.name not in ('UNSPECIFIED', 'UNKNOWN')]}" - ) - sys.exit(1) - - operations = [] - operation = client.get_type("RemoveCampaignAutomaticallyCreatedAssetOperation") - operation.campaign = campaign_service.campaign_path(customer_id, campaign_id) - operation.asset = asset_resource_name - operation.field_type = field_type_enum - - operations.append(operation) - - try: - request = client.get_type("RemoveCampaignAutomaticallyCreatedAssetRequest") - request.customer_id = customer_id - request.operations.append(operation) # Append the already created operation - request.partial_failure = False # Assuming we want to fail all if any fail - response = automatically_created_asset_removal_service.remove_campaign_automatically_created_asset( - request=request - ) - print(f"Removed {len(response.results)} automatically created assets.") + res = service.remove_campaign_automatically_created_asset(customer_id=customer_id, operations=[op]) + print(f"Removed {len(res.results)} assets.") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Removes automatically created assets from a campaign." - ) - # The following arguments are required. - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-C", "--campaign_id", type=int, required=True, help="The campaign ID." - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-C", "--campaign_id", required=True) + parser.add_argument("-a", "--asset_rn", required=True) + parser.add_argument("-f", "--field_type", required=True) parser.add_argument( - "-a", - "--asset_resource_name", - type=str, - required=True, - help="The resource name of the asset to remove.", - ) - parser.add_argument( - "-f", - "--field_type", - type=str, - required=True, - help=( - "The field type of the asset to remove (e.g., HEADLINE, DESCRIPTION). " - "Refer to the AssetFieldTypeEnum documentation for possible values: " - "https://developers.google.com/google-ads/api/reference/rpc/v22/AssetFieldTypeEnum" - ), + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - # GoogleAdsClient will read the google-ads.yaml file from the home directory. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - main( - googleads_client, - args.customer_id, - args.campaign_id, - args.asset_resource_name, - args.field_type, - ) \ No newline at end of file + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.campaign_id, args.asset_rn, args.field_type) diff --git a/api_examples/target_campaign_with_user_list.py b/api_examples/target_campaign_with_user_list.py index f7caf29..9e9d593 100644 --- a/api_examples/target_campaign_with_user_list.py +++ b/api_examples/target_campaign_with_user_list.py @@ -1,129 +1,33 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -"""This example targets a user list to a campaign. - -To get campaigns, run get_campaigns.py. -To get user lists, run get_user_lists.py. -""" +# Copyright 2026 Google LLC +"""Targets a campaign with a user list using version-safe path construction.""" import argparse import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main( - client: GoogleAdsClient, customer_id: str, campaign_id: str, user_list_id: str -) -> None: - """Adds a campaign criterion to target a user list to a campaign. - - Args: - client: The Google Ads client. - customer_id: The customer ID for which to add the campaign criterion. - campaign_id: The ID of the campaign to target. - user_list_id: The ID of the user list to target. - """ - campaign_criterion_service = client.get_service("CampaignCriterionService") - - # Create a campaign criterion operation. - campaign_criterion_operation = client.get_type("CampaignCriterionOperation") - campaign_criterion = campaign_criterion_operation.create - - # Set the campaign resource name. - campaign_criterion.campaign = client.get_service("CampaignService").campaign_path( - customer_id, campaign_id - ) - - # Set the user list resource name. - campaign_criterion.user_list.user_list = client.get_service( - "UserListService" - ).user_list_path(customer_id, user_list_id) +def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, user_list_id: str) -> None: + service = client.get_service("CampaignCriterionService") + op = client.get_type("CampaignCriterionOperation") + crit = op.create + crit.campaign = client.get_service("CampaignService").campaign_path(customer_id, campaign_id) + crit.user_list.user_list = client.get_service("UserListService").user_list_path(customer_id, user_list_id) try: - # Add the campaign criterion. - campaign_criterion_response = ( - campaign_criterion_service.mutate_campaign_criteria( - customer_id=customer_id, - operations=[campaign_criterion_operation], - ) - ) - print( - "Added campaign criterion with resource name: " - f"'{campaign_criterion_response.results[0].resource_name}'" - ) + res = service.mutate_campaign_criteria(customer_id=customer_id, operations=[op]) + print(f"Created criterion: {res.results[0].resource_name}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") sys.exit(1) - if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v23") - - parser = argparse.ArgumentParser( - description="Adds a campaign criterion to target a user list to a campaign." - ) - # The following argument(s) are required to run the example. - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-C", - "--campaign_id", - type=str, - required=True, - help="The ID of the campaign to target.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-C", "--campaign_id", required=True) + parser.add_argument("-u", "--user_list_id", required=True) parser.add_argument( - "-u", - "--user_list_id", - type=str, - required=True, - help="The ID of the user list to target.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - try: - main( - google_ads_client, - args.customer_id, - args.campaign_id, - args.user_list_id, - ) - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.campaign_id, args.user_list_id) diff --git a/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index a4fafd771d804f8a91bc3656868b71adcc8ea3ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15018 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 diff --git a/api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 44a9f1067f16dbfd2ba3981ce7782ca36d53c3d3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17636 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 fJnf^SMuN?X8k>5Ld+eX>fk!Jr7w7ehX diff --git a/api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index dedbe7247c23691f073f33a2825c514ca0dc1818..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7892 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 diff --git a/api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 05d735299c22ddd33ab1b6a105fc6c037820fa71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6985 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 diff --git a/api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 45f161fea47deeb932a71e15fbb7b31de8f71a8d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7101 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 KLDmi`@_zt$zuH3p diff --git a/api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 30bf3d60e314977407e2af8a08e3bbd58ef37122..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8929 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 diff --git a/api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index f7e1c1bfb98aae98a32579944fcefef4246b8b8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5383 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 diff --git a/api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index a68b3290e173d5e31a7b90b658f900f8da1043fd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8900 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 diff --git a/api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 0a9a39fc72dad099ee1fe9c0c90e60f1530ec7f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7780 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 diff --git a/api_examples/tests/test_ai_max_reports.py b/api_examples/tests/test_ai_max_reports.py index 8d8c852..fb34462 100644 --- a/api_examples/tests/test_ai_max_reports.py +++ b/api_examples/tests/test_ai_max_reports.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import unittest from unittest.mock import MagicMock, patch, mock_open from io import StringIO -from datetime import datetime, timedelta from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient @@ -30,7 +29,6 @@ main, _write_to_csv, get_campaign_details, - get_landing_page_matches, get_search_terms, ) @@ -51,17 +49,10 @@ def tearDown(self): @patch("builtins.open", new_callable=mock_open) def test_write_to_csv(self, mock_file_open): headers = ["Header1", "Header2"] - mock_row1 = MagicMock() - mock_row1.__iter__.return_value = ["Value1", "ValueA"] - mock_row2 = MagicMock() - mock_row2.__iter__.return_value = ["Value2", "ValueB"] - - mock_batch = MagicMock() - mock_batch.results = [mock_row1, mock_row2] - mock_response = [mock_batch] + rows = [["Value1", "ValueA"], ["Value2", "ValueB"]] file_path = "test.csv" - _write_to_csv(file_path, headers, mock_response) + _write_to_csv(file_path, headers, rows) mock_file_open.assert_called_once_with( file_path, "w", newline="", encoding="utf-8" @@ -74,22 +65,11 @@ def test_write_to_csv(self, mock_file_open): # --- Test get_campaign_details --- def test_get_campaign_details(self): - mock_campaign = MagicMock() - mock_campaign.id = 123 - mock_campaign.name = "AI Max Campaign 1" - mock_expanded_landing_page_view = MagicMock() - mock_expanded_landing_page_view.expanded_final_url = "http://example.com" - mock_campaign.ai_max_setting.enable_ai_max = True - mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.expanded_landing_page_view = mock_expanded_landing_page_view - mock_row.__iter__.return_value = [ - mock_campaign.id, - mock_campaign.name, - mock_expanded_landing_page_view.expanded_final_url, - mock_campaign.ai_max_setting.enable_ai_max, - ] + mock_row.campaign.id = 123 + mock_row.campaign.name = "AI Max Campaign 1" + mock_row.expanded_landing_page_view.expanded_final_url = "http://example.com" + mock_row.campaign.ai_max_setting.enable_ai_max = True mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -100,96 +80,19 @@ def test_get_campaign_details(self): get_campaign_details(self.mock_client, self.customer_id) self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "FROMexpanded_landing_page_view", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - self.assertIn( - "campaign.ai_max_setting.enable_ai_max=TRUE", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign ID,Campaign Name,Expanded Landing Page URL,AI Max Enabled\r\n" - ) - handle.write.assert_any_call( - "123,AI Max Campaign 1,http://example.com,True\r\n" - ) - - # --- Test get_landing_page_matches --- - def test_get_landing_page_matches(self): - mock_campaign = MagicMock() - mock_campaign.id = 456 - mock_campaign.name = "AI Max Campaign 2" - mock_expanded_landing_page_view = MagicMock() - mock_expanded_landing_page_view.expanded_final_url = "http://example.org" - - mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.expanded_landing_page_view = mock_expanded_landing_page_view - mock_row.__iter__.return_value = [ - mock_campaign.id, - mock_campaign.name, - mock_expanded_landing_page_view.expanded_final_url, - ] - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_landing_page_matches(self.mock_client, self.customer_id) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "FROMexpanded_landing_page_view", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - self.assertIn( - "campaign.ai_max_setting.enable_ai_max=TRUE", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign ID,Campaign Name,Expanded Landing Page URL\r\n" - ) - handle.write.assert_any_call("456,AI Max Campaign 2,http://example.org\r\n") + handle.write.assert_any_call("ID,Name,URL,Enabled\r\n") + handle.write.assert_any_call("123,AI Max Campaign 1,http://example.com,True\r\n") # --- Test get_search_terms --- def test_get_search_terms(self): - mock_campaign = MagicMock() - mock_campaign.id = 789 - mock_campaign.name = "AI Max Campaign 3" - mock_ai_max_search_term_ad_combination_view = MagicMock() - mock_ai_max_search_term_ad_combination_view.search_term = "test search term" - mock_metrics = MagicMock() - mock_metrics.impressions = 1000 - mock_metrics.clicks = 50 - mock_metrics.cost_micros = 1000000 - mock_metrics.conversions = 5.0 - mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.ai_max_search_term_ad_combination_view = ( - mock_ai_max_search_term_ad_combination_view - ) - mock_row.metrics = mock_metrics - mock_row.__iter__.return_value = [ - mock_campaign.id, - mock_campaign.name, - mock_ai_max_search_term_ad_combination_view.search_term, - mock_metrics.impressions, - mock_metrics.clicks, - mock_metrics.cost_micros, - mock_metrics.conversions, - ] + mock_row.campaign.id = 789 + mock_row.campaign.name = "AI Max Campaign 3" + mock_row.ai_max_search_term_ad_combination_view.search_term = "test search term" + mock_row.metrics.impressions = 1000 + mock_row.metrics.clicks = 50 + mock_row.metrics.conversions = 5.0 mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -200,92 +103,34 @@ def test_get_search_terms(self): get_search_terms(self.mock_client, self.customer_id) self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "FROMai_max_search_term_ad_combination_view", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - today = datetime.now().date() - start_date = (today - timedelta(days=30)).strftime("%Y-%m-%d") - end_date = today.strftime("%Y-%m-%d") - self.assertIn( - f"segments.date BETWEEN '{start_date}' AND '{end_date}'", - kwargs["query"], - ) - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign ID,Campaign Name,Search Term,Impressions,Clicks,Cost (micros),Conversions\r\n" - ) - handle.write.assert_any_call( - "789,AI Max Campaign 3,test search term,1000,50,1000000,5.0\r\n" - ) + handle.write.assert_any_call("ID,Name,Term,Impr,Clicks,Conv\r\n") + handle.write.assert_any_call("789,AI Max Campaign 3,test search term,1000,50,5.0\r\n") # --- Test main function --- def test_main_campaign_details_report(self): - with patch( - "api_examples.ai_max_reports.get_campaign_details" - ) as mock_get_campaign_details: + with patch("api_examples.ai_max_reports.get_campaign_details") as mock_get_campaign_details: main(self.mock_client, self.customer_id, "campaign_details") - mock_get_campaign_details.assert_called_once_with( - self.mock_client, self.customer_id - ) - - def test_main_landing_page_matches_report(self): - with patch( - "api_examples.ai_max_reports.get_landing_page_matches" - ) as mock_get_landing_page_matches: - main(self.mock_client, self.customer_id, "landing_page_matches") - mock_get_landing_page_matches.assert_called_once_with( - self.mock_client, self.customer_id - ) + mock_get_campaign_details.assert_called_once_with(self.mock_client, self.customer_id) def test_main_search_terms_report(self): - with patch( - "api_examples.ai_max_reports.get_search_terms" - ) as mock_get_search_terms: + with patch("api_examples.ai_max_reports.get_search_terms") as mock_get_search_terms: main(self.mock_client, self.customer_id, "search_terms") - mock_get_search_terms.assert_called_once_with( - self.mock_client, self.customer_id - ) - - def test_main_unknown_report_type(self): - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, "unknown_report") - self.assertEqual(cm.exception.code, 1) - self.assertIn( - "Unknown report type: unknown_report", self.captured_output.getvalue() - ) + mock_get_search_terms.assert_called_once_with(self.mock_client, self.customer_id) def test_main_google_ads_exception(self): + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - error=MagicMock(code=type("obj", (object,), {"name": "REQUEST_ERROR"})()), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), request_id="test_request_id", call=MagicMock(), ) - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, "campaign_details") - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status 'REQUEST_ERROR'", - output, - ) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id, "campaign_details") + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_capture_gclids.py b/api_examples/tests/test_capture_gclids.py index b2d2e65..b6585dd 100644 --- a/api_examples/tests/test_capture_gclids.py +++ b/api_examples/tests/test_capture_gclids.py @@ -48,6 +48,7 @@ def setUp(self): self.customer_id = "1234567890" self.gclid = "test_gclid_123" + self.conversion_date_time = "2026-02-16 12:32:45-08:00" self.captured_output = StringIO() sys.stdout = self.captured_output @@ -72,7 +73,7 @@ def test_main_successful_upload(self): # Make conversions attribute a real list for testing append behavior self.mock_upload_click_conversions_request.conversions = [] - main(self.mock_client, self.customer_id, self.gclid) + main(self.mock_client, self.customer_id, self.gclid, self.conversion_date_time) # Assert get_service calls self.mock_client.get_service.assert_any_call("ConversionUploadService") @@ -94,7 +95,7 @@ def test_main_successful_upload(self): mock_conversion_action_response.resource_name, ) self.assertEqual( - self.mock_click_conversion.conversion_date_time, "2024-01-01 12:32:45-08:00" + self.mock_click_conversion.conversion_date_time, self.conversion_date_time ) self.assertEqual(self.mock_click_conversion.conversion_value, 23.41) self.assertEqual(self.mock_click_conversion.currency_code, "USD") @@ -122,7 +123,7 @@ def test_main_no_conversion_actions_found(self): self.mock_conversion_action_service.search_conversion_actions.return_value = [] with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, self.gclid) + main(self.mock_client, self.customer_id, self.gclid, self.conversion_date_time) self.assertEqual(cm.exception.code, 1) output = self.captured_output.getvalue() @@ -157,7 +158,7 @@ def test_main_google_ads_exception(self): ) with self.assertRaises(GoogleAdsException) as cm: - main(self.mock_client, self.customer_id, self.gclid) + main(self.mock_client, self.customer_id, self.gclid, self.conversion_date_time) # The exception object is now directly the GoogleAdsException ex = cm.exception diff --git a/api_examples/tests/test_collect_conversions_troubleshooting_data.py b/api_examples/tests/test_collect_conversions_troubleshooting_data.py new file mode 100644 index 0000000..ee625c3 --- /dev/null +++ b/api_examples/tests/test_collect_conversions_troubleshooting_data.py @@ -0,0 +1,134 @@ +# Copyright 2026 Google LLC +# +# 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 +# +# https://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. + +import sys +import os +import unittest +from unittest.mock import MagicMock, patch, mock_open +from io import StringIO + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from google.ads.googleads.errors import GoogleAdsException +from google.ads.googleads.client import GoogleAdsClient +from api_examples.collect_conversions_troubleshooting_data import main + + +class TestCollectConversionsTroubleshootingData(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_ga_service = MagicMock() + self.mock_client.get_service.return_value = self.mock_ga_service + self.customer_id = "1234567890" + + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def tearDown(self): + sys.stdout = sys.__stdout__ + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + @patch("glob.glob") + def test_main_success_healthy(self, mock_glob, mock_file_open, mock_makedirs): + mock_glob.return_value = [] + + # 1. Customer Settings Mock + mock_row_customer = MagicMock() + mock_row_customer.customer.descriptive_name = "Test Customer" + mock_row_customer.customer.conversion_tracking_setting.accepted_customer_data_terms = True + mock_row_customer.customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled = True + + # 2. Action Summary Mock + mock_row_as = MagicMock() + asum = mock_row_as.offline_conversion_upload_conversion_action_summary + asum.conversion_action_name = "Test Action" + asum.successful_event_count = 50 + asum.total_event_count = 50 + + ds = MagicMock() + ds.upload_date = "2026-02-24" + ds.successful_count = 10 + ds.failed_count = 0 + asum.daily_summaries = [ds] + + mock_batch_customer = MagicMock() + mock_batch_customer.results = [mock_row_customer] + + mock_batch_as = MagicMock() + mock_batch_as.results = [mock_row_as] + + self.mock_ga_service.search_stream.side_effect = [ + [mock_batch_customer], + [mock_batch_as] + ] + + main(self.mock_client, self.customer_id) + + handle = mock_file_open() + written_content = "".join(call.args[0] for call in handle.write.call_args_list) + + self.assertIn("Diagnostic Report for Customer ID: 1234567890", written_content) + self.assertIn("Customer: Test Customer", written_content) + self.assertIn("Action: Test Action (Total Success: 50/50)", written_content) + self.assertIn("No blocking errors detected.", written_content) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + @patch("glob.glob") + def test_main_unhealthy_terms_not_accepted(self, mock_glob, mock_file_open, mock_makedirs): + mock_glob.return_value = [] + + # 1. Customer Settings Mock (Terms NOT accepted) + mock_row_customer = MagicMock() + mock_row_customer.customer.descriptive_name = "Test Customer" + mock_row_customer.customer.conversion_tracking_setting.accepted_customer_data_terms = False + + mock_batch_customer = MagicMock() + mock_batch_customer.results = [mock_row_customer] + + self.mock_ga_service.search_stream.side_effect = [ + [mock_batch_customer], + [] + ] + + main(self.mock_client, self.customer_id) + + handle = mock_file_open() + written_content = "".join(call.args[0] for call in handle.write.call_args_list) + + self.assertIn("CRITICAL: Customer Data Terms NOT accepted.", written_content) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + @patch("glob.glob") + def test_main_google_ads_exception(self, mock_glob, mock_file_open, mock_makedirs): + mock_glob.return_value = [] + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=MagicMock(), + failure=MagicMock(errors=[MagicMock(message="Internal error")]), + request_id="test_request_id", + call=MagicMock(), + ) + + main(self.mock_client, self.customer_id) + + output = self.captured_output.getvalue() + self.assertIn("ERROR: Query failed (Request ID: test_request_id)", output) + self.assertIn("Internal error", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/api_examples/tests/test_conversion_reports.py b/api_examples/tests/test_conversion_reports.py index 4ed44cd..3a81283 100644 --- a/api_examples/tests/test_conversion_reports.py +++ b/api_examples/tests/test_conversion_reports.py @@ -1,45 +1,18 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -import re +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock from io import StringIO from datetime import datetime, timedelta -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient - -# Import functions from the script -from api_examples.conversion_reports import ( - main, - handle_googleads_exception, - _calculate_date_range, - _process_and_output_results, - get_conversion_actions_report, - get_conversion_performance_report, -) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +from api_examples.conversion_reports import _calculate_date_range, get_conversion_performance_report class TestConversionReports(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_client = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" @@ -49,310 +22,32 @@ def setUp(self): def tearDown(self): sys.stdout = sys.__stdout__ - # --- Test _calculate_date_range --- - def test_calculate_date_range_preset_last_7_days(self): - start, end = _calculate_date_range(None, None, "LAST_7_DAYS") - today = datetime.now().date() - expected_start = (today - timedelta(days=7)).strftime("%Y-%m-%d") - expected_end = today.strftime("%Y-%m-%d") + def test_calculate_date_range_preset_last_10_days(self): + start, end = _calculate_date_range(None, None, "LAST_10_DAYS") + today = datetime.now() + expected_start = (today - timedelta(days=10)).strftime("%Y-%m-%d") self.assertEqual(start, expected_start) - self.assertEqual(end, expected_end) - - def test_calculate_date_range_explicit_dates(self): - start, end = _calculate_date_range("2025-01-01", "2025-01-31", None) - self.assertEqual(start, "2025-01-01") - self.assertEqual(end, "2025-01-31") - - def test_calculate_date_range_no_dates_or_preset(self): - with self.assertRaises(SystemExit) as cm: - _calculate_date_range(None, None, None) - self.assertEqual(cm.exception.code, 1) - self.assertIn( - "Error: A date range must be specified", self.captured_output.getvalue() - ) - - # --- Test _process_and_output_results --- - def test_process_and_output_results_console(self): - results = [ - {"Metric1": "Value1", "Metric2": "ValueA"}, - {"Metric1": "Value2", "Metric2": "ValueB"}, - ] - _process_and_output_results(results, "console", "") - output = self.captured_output.getvalue() - self.assertIn("Metric1 | Metric2", output) - self.assertIn("Value1 | ValueA", output) - self.assertIn("Value2 | ValueB", output) - - @patch("builtins.open", new_callable=mock_open) - def test_process_and_output_results_csv(self, mock_file_open): - results = [ - {"Metric1": "Value1", "Metric2": "ValueA"}, - {"Metric1": "Value2", "Metric2": "ValueB"}, - ] - output_file = "test.csv" - _process_and_output_results(results, "csv", output_file) - - mock_file_open.assert_called_once_with( - output_file, "w", newline="", encoding="utf-8" - ) - handle = mock_file_open() - handle.write.assert_any_call("Metric1,Metric2\r\n") - handle.write.assert_any_call("Value1,ValueA\r\n") - handle.write.assert_any_call("Value2,ValueB\r\n") - self.assertIn( - f"Results successfully written to {output_file}", - self.captured_output.getvalue(), - ) - - # --- Test get_conversion_actions_report --- - def test_get_conversion_actions_report(self): - mock_ca = MagicMock() - mock_ca.id = 1 - mock_ca.name = "Test Action" - mock_ca.status.name = "ENABLED" - mock_ca.type.name = "WEBPAGE" - mock_ca.category.name = "LEAD" - mock_ca.owner_customer = "customers/123" - mock_ca.include_in_conversions_metric = True - mock_ca.click_through_lookback_window_days = 30 - mock_ca.view_through_lookback_window_days = 1 - mock_ca.attribution_model_settings.attribution_model.name = "LAST_CLICK" - mock_ca.attribution_model_settings.data_driven_model_status.name = "AVAILABLE" + self.assertEqual(end, today.strftime("%Y-%m-%d")) + def test_get_conversion_performance_report_mapping(self): mock_row = MagicMock() - mock_row.conversion_action = mock_ca + mock_row.segments.date = "2026-02-24" + mock_row.campaign.id = 999 + mock_row.campaign.name = "Opti Campaign" + mock_row.metrics.conversions = 10.5 mock_batch = MagicMock() mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - output_file = "actions.csv" - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_conversion_actions_report( - self.mock_client, self.customer_id, output_file - ) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("FROM conversion_action", kwargs["query"]) - - handle = mock_file_open() - handle.write.assert_any_call( - "ID,Name,Status,Type,Category,Owner,Include in Conversions Metric,Click-Through Lookback Window,View-Through Lookback Window,Attribution Model,Data-Driven Model Status\r\n" - ) - handle.write.assert_any_call( - "1,Test Action,ENABLED,WEBPAGE,LEAD,customers/123,True,30,1,LAST_CLICK,AVAILABLE\r\n" - ) - - # --- Test get_conversion_performance_report --- - def test_get_conversion_performance_report_console(self): - mock_row = MagicMock() - mock_row.segments.date = "2025-10-20" - mock_row.campaign.id = 123 - mock_row.campaign.name = "Test Campaign" - mock_row.metrics.conversions = 5.0 - mock_row.metrics.clicks = 100 - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - self.mock_ga_service.search_stream.return_value = [mock_batch] get_conversion_performance_report( - self.mock_client, - self.customer_id, - "console", - "", - "2025-10-01", - "2025-10-31", - None, - ["conversions", "clicks"], - [], - None, - None, + self.mock_client, self.customer_id, "console", "", None, None, "LAST_7_DAYS", + ["conversions"], [], None ) output = self.captured_output.getvalue() - self.assertIn("Date", output) - self.assertIn("Campaign ID", output) - self.assertIn("Campaign Name", output) - self.assertIn("Conversions", output) - self.assertIn("Clicks", output) - self.assertIn("2025-10-20", output) - self.assertIn("123", output) - self.assertIn("Test Campaign", output) - self.assertIn("5.0", output) - self.assertIn("100", output) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("SELECT", kwargs["query"]) - self.assertIn("segments.date", kwargs["query"]) - self.assertIn("campaign.id", kwargs["query"]) - self.assertIn("campaign.name", kwargs["query"]) - self.assertIn("metrics.conversions", kwargs["query"]) - self.assertIn("metrics.clicks", kwargs["query"]) - self.assertIn("FROM campaign", kwargs["query"]) - self.assertIn( - "WHERE segments.date BETWEEN '2025-10-01' AND '2025-10-31'", kwargs["query"] - ) - - def test_get_conversion_performance_report_csv_with_filters_and_order(self): - mock_row = MagicMock() - mock_row.segments.date = "2025-10-20" - mock_row.segments.conversion_action_name = "Website_Sale" - mock_row.metrics.all_conversions = 10.0 - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - output_file = "performance.csv" - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_conversion_performance_report( - self.mock_client, - self.customer_id, - "csv", - output_file, - None, - None, - "LAST_7_DAYS", - ["all_conversions"], - ["conversion_action_name=Website_Sale", "min_conversions=5"], - "all_conversions", - 10, - ) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("SELECT", kwargs["query"]) - self.assertIn("segments.date", kwargs["query"]) - self.assertIn("segments.conversion_action_name", kwargs["query"]) - self.assertIn("metrics.all_conversions", kwargs["query"]) - self.assertIn("FROM customer", kwargs["query"]) - self.assertIn("WHERE segments.date BETWEEN", kwargs["query"]) - self.assertIn( - "AND segments.conversion_action_name = 'Website_Sale'", kwargs["query"] - ) - self.assertIn("AND metrics.conversions > 5.0", kwargs["query"]) - self.assertIn("ORDER BY metrics.all_conversions DESC", kwargs["query"]) - self.assertIn("LIMIT 10", kwargs["query"]) - - handle = mock_file_open() - handle.write.assert_any_call( - "Date,Conversion Action Name,All Conversions\r\n" - ) - handle.write.assert_any_call("2025-10-20,Website_Sale,10.0\r\n") - - # --- Test main function --- - def test_main_conversion_actions_report(self): - with patch( - "api_examples.conversion_reports.get_conversion_actions_report" - ) as mock_get_actions: - main( - self.mock_client, - self.customer_id, - "actions", - "csv", - "actions.csv", - None, - None, - None, - [], - [], - None, - None, - ) - mock_get_actions.assert_called_once_with( - self.mock_client, self.customer_id, "actions.csv" - ) - - def test_main_conversion_performance_report(self): - with patch( - "api_examples.conversion_reports.get_conversion_performance_report" - ) as mock_get_performance: - main( - self.mock_client, - self.customer_id, - "performance", - "console", - "", - "2025-01-01", - "2025-01-31", - None, - ["clicks"], - [], - None, - None, - ) - mock_get_performance.assert_called_once_with( - self.mock_client, - self.customer_id, - "console", - "", - "2025-01-01", - "2025-01-31", - None, - ["clicks"], - [], - None, - None, - ) - - def test_main_unknown_report_type(self): - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "unknown", - "console", - "", - None, - None, - None, - [], - [], - None, - None, - ) - self.assertEqual(cm.exception.code, 1) - self.assertIn("Unknown report type: unknown", self.captured_output.getvalue()) - - def test_handle_googleads_exception(self): - mock_error = MagicMock() - mock_error.message = "Test error message" - mock_error.location.field_path_elements = [MagicMock(field_name="test_field")] - mock_error.code.name = "REQUEST_ERROR" - - mock_failure = MagicMock() - mock_failure.errors = [mock_error] - - mock_exception = GoogleAdsException( - error=mock_error, - call=MagicMock(), - failure=mock_failure, - request_id="test_request_id", - ) - - with self.assertRaises(SystemExit) as cm: - handle_googleads_exception(mock_exception) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertTrue( - re.search( - r'Request with ID "test_request_id" failed with status ".*" and includes the following errors:', - output, - ) - ) - self.assertIn(f'\tError with message "{mock_error.message}".', output) - self.assertIn("\t\tOn field: test_field", output) - + self.assertIn("Opti Campaign", output) + self.assertIn("10.5", output) if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_disapproved_ads_reports.py b/api_examples/tests/test_disapproved_ads_reports.py index 6b33c70..a6904b1 100644 --- a/api_examples/tests/test_disapproved_ads_reports.py +++ b/api_examples/tests/test_disapproved_ads_reports.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,12 +25,7 @@ from google.ads.googleads.client import GoogleAdsClient # Import functions from the script -from api_examples.disapproved_ads_reports import ( - main, - _write_to_csv, - get_all_disapproved_ads, - get_disapproved_ads_for_campaign, -) +from api_examples.disapproved_ads_reports import main class TestDisapprovedAdsReports(unittest.TestCase): @@ -39,325 +34,51 @@ def setUp(self): self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.campaign_id = "111222333" self.captured_output = StringIO() sys.stdout = self.captured_output def tearDown(self): sys.stdout = sys.__stdout__ - # --- Test _write_to_csv --- - @patch("builtins.open", new_callable=mock_open) - def test_write_to_csv(self, mock_file_open): - headers = ["Header1", "Header2"] - rows = [["Value1", "ValueA"], ["Value2", "ValueB"]] - file_path = "test.csv" - _write_to_csv(file_path, headers, rows) - - mock_file_open.assert_called_once_with( - file_path, "w", newline="", encoding="utf-8" - ) - handle = mock_file_open() - handle.write.assert_any_call("Header1,Header2\r\n") - handle.write.assert_any_call("Value1,ValueA\r\n") - handle.write.assert_any_call("Value2,ValueB\r\n") - self.assertIn(f"Report written to {file_path}", self.captured_output.getvalue()) - - # --- Test get_all_disapproved_ads --- - def test_get_all_disapproved_ads(self): - mock_ad = MagicMock() - mock_ad.id = 456 - mock_ad.type_.name = "TEXT_AD" - - mock_policy_topic_entry = MagicMock() - mock_policy_topic_entry.topic = "Adult Content" - mock_policy_topic_entry.type_.name = "POLICY_TYPE_UNSPECIFIED" - mock_policy_topic_entry.evidences = [ - MagicMock(text_list=MagicMock(texts=["Evidence 1", "Evidence 2"])) - ] - - mock_policy_summary = MagicMock() - mock_policy_summary.approval_status.name = "DISAPPROVED" - mock_policy_summary.policy_topic_entries = [mock_policy_topic_entry] - - mock_ad_group_ad = MagicMock() - mock_ad_group_ad.ad = mock_ad - mock_ad_group_ad.policy_summary = mock_policy_summary - - mock_campaign = MagicMock() - mock_campaign.name = "Test Campaign All" - mock_campaign.id = 123 - + def test_main_success(self): mock_row = MagicMock() - mock_row.ad_group_ad = mock_ad_group_ad - mock_row.campaign = mock_campaign + mock_row.campaign.id = 123 + mock_row.campaign.name = "Test Campaign" + mock_row.ad_group_ad.ad.id = 456 + mock_row.ad_group_ad.policy_summary.approval_status.name = "DISAPPROVED" + + mock_entry = MagicMock() + mock_entry.topic = "Adult Content" + mock_row.ad_group_ad.policy_summary.policy_topic_entries = [mock_entry] mock_batch = MagicMock() mock_batch.results = [mock_row] self.mock_ga_service.search_stream.return_value = [mock_batch] - output_file = "all_disapproved_ads.csv" + output_file = "test_disapproved.csv" with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_all_disapproved_ads(self.mock_client, self.customer_id, output_file) + main(self.mock_client, self.customer_id, output_file) self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("FROM ad_group_ad", kwargs["query"]) - self.assertIn( - "ad_group_ad.policy_summary.approval_status = DISAPPROVED", - kwargs["query"], - ) - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign Name,Campaign ID,Ad ID,Ad Type,Approval Status,Policy Topic,Policy Type,Evidence Text\r\n" - ) - handle.write.assert_any_call( - "Test Campaign All,123,456,TEXT_AD,DISAPPROVED,Adult Content,POLICY_TYPE_UNSPECIFIED,Evidence 1; Evidence 2\r\n" - ) - - # --- Test get_disapproved_ads_for_campaign --- - def test_get_disapproved_ads_for_campaign_console(self): - mock_ad = MagicMock() - mock_ad.id = 789 - mock_ad.type_.name = "IMAGE_AD" - - mock_policy_topic_entry = MagicMock() - mock_policy_topic_entry.topic = "Gambling" - mock_policy_topic_entry.type_.name = "POLICY_TYPE_EDITORIAL" - mock_policy_topic_entry.evidences = [ - MagicMock(text_list=MagicMock(texts=["Gambling content"])) - ] - - mock_policy_summary = MagicMock() - mock_policy_summary.approval_status.name = "DISAPPROVED" - mock_policy_summary.policy_topic_entries = [mock_policy_topic_entry] - - mock_ad_group_ad = MagicMock() - mock_ad_group_ad.ad = mock_ad - mock_ad_group_ad.policy_summary = mock_policy_summary - - mock_campaign = MagicMock() - mock_campaign.name = "Test Campaign Single" - - mock_row = MagicMock() - mock_row.ad_group_ad = mock_ad_group_ad - mock_row.campaign = mock_campaign - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - get_disapproved_ads_for_campaign( - self.mock_client, self.customer_id, self.campaign_id, output_file=None - ) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn(f"campaign.id = {self.campaign_id}", kwargs["query"]) - self.assertIn( - "ad_group_ad.policy_summary.approval_status = DISAPPROVED", kwargs["query"] - ) - - output = self.captured_output.getvalue() - self.assertIn( - f"Campaign Name: Test Campaign Single, Campaign ID: {self.campaign_id}, Ad ID: 789, Ad Type: IMAGE_AD, Approval Status: DISAPPROVED, Policy Topic: Gambling, Policy Type: POLICY_TYPE_EDITORIAL, Evidence Text: Gambling content", - output, - ) - - def test_get_disapproved_ads_for_campaign_csv(self): - mock_ad = MagicMock() - mock_ad.id = 789 - mock_ad.type_.name = "IMAGE_AD" - - mock_policy_topic_entry = MagicMock() - mock_policy_topic_entry.topic = "Gambling" - mock_policy_topic_entry.type_.name = "POLICY_TYPE_EDITORIAL" - mock_policy_topic_entry.evidences = [ - MagicMock(text_list=MagicMock(texts=["Gambling content"])) - ] - - mock_policy_summary = MagicMock() - mock_policy_summary.approval_status.name = "DISAPPROVED" - mock_policy_summary.policy_topic_entries = [mock_policy_topic_entry] - - mock_ad_group_ad = MagicMock() - mock_ad_group_ad.ad = mock_ad - mock_ad_group_ad.policy_summary = mock_policy_summary - - mock_campaign = MagicMock() - mock_campaign.name = "Test Campaign Single" - - mock_row = MagicMock() - mock_row.ad_group_ad = mock_ad_group_ad - mock_row.campaign = mock_campaign - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - output_file = "single_disapproved_ads.csv" - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_disapproved_ads_for_campaign( - self.mock_client, - self.customer_id, - self.campaign_id, - output_file=output_file, - ) - - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign Name,Campaign ID,Ad ID,Ad Type,Approval Status,Policy Topic,Policy Type,Evidence Text\r\n" - ) - handle.write.assert_any_call( - "Test Campaign Single,111222333,789,IMAGE_AD,DISAPPROVED,Gambling,POLICY_TYPE_EDITORIAL,Gambling content\r\n" - ) - - def test_get_disapproved_ads_for_campaign_no_ads_found(self): - self.mock_ga_service.search_stream.return_value = [] - - get_disapproved_ads_for_campaign( - self.mock_client, self.customer_id, self.campaign_id, output_file=None - ) - - output = self.captured_output.getvalue() - self.assertIn( - f"No disapproved ads found for campaign ID: {self.campaign_id}", output - ) - - # --- Test main function --- - def test_main_all_disapproved_ads_report(self): - with patch( - "api_examples.disapproved_ads_reports.get_all_disapproved_ads" - ) as mock_get_all: - main( - self.mock_client, - self.customer_id, - "all", - output_file="all.csv", - campaign_id=None, - ) - mock_get_all.assert_called_once_with( - self.mock_client, self.customer_id, "all.csv" - ) - - def test_main_single_disapproved_ads_report_console(self): - with patch( - "api_examples.disapproved_ads_reports.get_disapproved_ads_for_campaign" - ) as mock_get_single: - main( - self.mock_client, - self.customer_id, - "single", - output_file=None, - campaign_id=self.campaign_id, - ) - mock_get_single.assert_called_once_with( - self.mock_client, self.customer_id, self.campaign_id, None - ) - self.assertIn( - f"No output file specified. Printing results for campaign {self.campaign_id} to console.", - self.captured_output.getvalue(), - ) - - def test_main_single_disapproved_ads_report_csv(self): - with patch( - "api_examples.disapproved_ads_reports.get_disapproved_ads_for_campaign" - ) as mock_get_single: - main( - self.mock_client, - self.customer_id, - "single", - output_file="single.csv", - campaign_id=self.campaign_id, - ) - mock_get_single.assert_called_once_with( - self.mock_client, self.customer_id, self.campaign_id, "single.csv" - ) - - def test_main_single_report_missing_campaign_id(self): - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "single", - output_file=None, - campaign_id=None, - ) - self.assertEqual(cm.exception.code, 1) - self.assertIn( - "Error: Campaign ID is required for 'single' report type.", - self.captured_output.getvalue(), - ) - - def test_main_unknown_report_type(self): - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "unknown", - output_file=None, - campaign_id=None, - ) - self.assertEqual(cm.exception.code, 1) - self.assertIn("Unknown report type: unknown", self.captured_output.getvalue()) + handle.write.assert_any_call("Campaign ID,Campaign,Ad ID,Status,Topics\r\n") + handle.write.assert_any_call("123,Test Campaign,456,DISAPPROVED,Adult Content\r\n") + self.assertIn(f"Disapproved ads report written to {output_file}", self.captured_output.getvalue()) def test_main_google_ads_exception(self): - class MockIterator: - def __init__(self, exception_to_raise): - self.exception_to_raise = exception_to_raise - self.first_call = True - - def __iter__(self): - return self - - def __next__(self): - if self.first_call: - self.first_call = False - raise self.exception_to_raise - raise StopIteration - - self.mock_ga_service.search_stream.return_value = MockIterator( - GoogleAdsException( - error=MagicMock(code=MagicMock(name="REQUEST_ERROR")), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", + call=MagicMock(), ) - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "all", - output_file="test.csv", - campaign_id=None, - ) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status ", - output, - ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id, "test.csv") + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_gaql_validator.py b/api_examples/tests/test_gaql_validator.py new file mode 100644 index 0000000..6eb63a3 --- /dev/null +++ b/api_examples/tests/test_gaql_validator.py @@ -0,0 +1,41 @@ +# Copyright 2026 Google LLC +import sys +import os +import unittest +from unittest.mock import MagicMock, patch +from io import StringIO + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from api_examples.gaql_validator import main + +class TestGAQLValidator(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock() + self.mock_ga_service = MagicMock() + self.mock_client.get_service.return_value = self.mock_ga_service + self.customer_id = "1234567890" + self.api_version = "v23" + self.test_query = "SELECT campaign.id FROM campaign" + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def tearDown(self): + sys.stdout = sys.__stdout__ + + @patch("importlib.import_module") + def test_main_success(self, mock_import): + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_request_class = MagicMock() + setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) + + main(client=self.mock_client, customer_id=self.customer_id, api_version=self.api_version, query=self.test_query) + + self.mock_ga_service.search.assert_called_once() + output = self.captured_output.getvalue() + self.assertIn("[DRY RUN]", output) + self.assertIn("SUCCESS: GAQL query is structurally valid.", output) + +if __name__ == "__main__": + unittest.main() diff --git a/api_examples/tests/test_get_campaign_bid_simulations.py b/api_examples/tests/test_get_campaign_bid_simulations.py index 9d7b76e..46e3b81 100644 --- a/api_examples/tests/test_get_campaign_bid_simulations.py +++ b/api_examples/tests/test_get_campaign_bid_simulations.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -42,16 +42,10 @@ def tearDown(self): sys.stdout = sys.__stdout__ def test_main_successful_call(self): - # Mock the stream and its results - mock_simulation = MagicMock() - mock_simulation.bid_modifier = 1.0 - mock_simulation.clicks = 100 - mock_simulation.cost_micros = 1000000 - mock_simulation.conversions = 10.0 - mock_simulation.conversion_value = 500.0 - mock_row = MagicMock() - mock_row.campaign_bid_simulation = mock_simulation + mock_row.campaign_bid_simulation.bid_modifier = 1.0 + mock_row.campaign_bid_simulation.clicks = 100 + mock_row.campaign_bid_simulation.cost_micros = 1000000 mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -60,98 +54,25 @@ def test_main_successful_call(self): main(self.mock_client, self.customer_id, self.campaign_id) - # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn(f"campaign.id = {self.campaign_id}", kwargs["query"]) - self.assertIn( - "campaign_bid_simulation.start_date = '2025-08-24'", kwargs["query"] - ) - self.assertIn( - "campaign_bid_simulation.end_date = '2025-09-23'", kwargs["query"] - ) - - # Assert that the output contains the expected information output = self.captured_output.getvalue() - self.assertIn( - f"Campaign bid simulations for Campaign ID: {self.campaign_id}", output - ) - self.assertIn( - "1.00 | 100 | 1000000 | 10.00 | 500.00 ", - output, - ) - - def test_main_no_simulations_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id, self.campaign_id) - - output = self.captured_output.getvalue() - self.assertIn( - f"Campaign bid simulations for Campaign ID: {self.campaign_id}", output - ) - self.assertIn( - "Bid Modifier | Clicks | Cost (micros) | Conversions | Conversion Value", - output, - ) - self.assertNotIn( - "|", - output[ - output.find( - "------------------------------------------------------------------" - ) - + len( - "------------------------------------------------------------------" - ) : - ], - ) + self.assertIn("1.00", output) + self.assertIn("100", output) + self.assertIn("1000000", output) def test_main_google_ads_exception(self): - class MockIterator: - def __init__(self, exception_to_raise): - self.exception_to_raise = exception_to_raise - self.first_call = True - - def __iter__(self): - return self - - def __next__(self): - if self.first_call: - self.first_call = False - raise self.exception_to_raise - raise StopIteration - - self.mock_ga_service.search_stream.return_value = MockIterator( - GoogleAdsException( - error=MagicMock(code=MagicMock(name="REQUEST_ERROR")), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - ) # Closing parenthesis for MockIterator - - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, self.campaign_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status ", - output, + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", + call=MagicMock(), ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn("Error with message: 'Error details'.", output) - self.assertIn("On field: test_field", output) + + main(self.mock_client, self.customer_id, self.campaign_id) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_get_campaign_shared_sets.py b/api_examples/tests/test_get_campaign_shared_sets.py index cb090b9..3fbf737 100644 --- a/api_examples/tests/test_get_campaign_shared_sets.py +++ b/api_examples/tests/test_get_campaign_shared_sets.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -24,28 +24,15 @@ from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient -# Import the main function from the script +# Import functions from the script from api_examples.get_campaign_shared_sets import main class TestGetCampaignSharedSets(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=GoogleAdsClient) - self.mock_client.enums = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service - - # Mock the enums for SharedSetTypeEnum - self.mock_client.enums.SharedSetTypeEnum = type( - "SharedSetTypeEnum", - (object,), - { - "KEYWORD_NEGATIVE": type( - "SharedSetType", (object,), {"name": "KEYWORD_NEGATIVE"} - ) - }, - ) - self.customer_id = "1234567890" self.captured_output = StringIO() sys.stdout = self.captured_output @@ -53,19 +40,11 @@ def setUp(self): def tearDown(self): sys.stdout = sys.__stdout__ - def test_main_successful_call(self): - mock_campaign = MagicMock() - mock_campaign.id = 111 - mock_campaign.name = "Test Campaign" - - mock_shared_set = MagicMock() - mock_shared_set.id = 222 - mock_shared_set.name = "Test Shared Set" - mock_shared_set.type = self.mock_client.enums.SharedSetTypeEnum.KEYWORD_NEGATIVE - + def test_main_success(self): mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.shared_set = mock_shared_set + mock_row.campaign.name = "Test Campaign" + mock_row.shared_set.name = "Test Shared Set" + mock_row.shared_set.type.name = "KEYWORD_NEGATIVE" mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -74,68 +53,25 @@ def test_main_successful_call(self): main(self.mock_client, self.customer_id) - # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - actual_query = kwargs["query"].replace("\n", "").replace(" ", "") - self.assertIn("FROMcampaign_shared_set", actual_query) - self.assertIn( - "SELECTcampaign.id,campaign.name,campaign_shared_set.shared_set,shared_set.id,shared_set.name,shared_set.type", - actual_query, - ) - self.assertIn("ORDERBYcampaign.id", actual_query) - - # Assert that the output contains the expected information output = self.captured_output.getvalue() - self.assertIn("Campaign Shared Sets:", output) - self.assertIn( - "Campaign ID: 111, Campaign Name: Test Campaign, Shared Set ID: 222, Shared Set Name: Test Shared Set, Shared Set Type: KEYWORD_NEGATIVE", - output, - ) - - def test_main_no_shared_sets_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertIn("Campaign Shared Sets:", output) - self.assertIn("---------------------", output) - self.assertNotIn("Campaign ID:", output) + self.assertIn("Test Campaign", output) + self.assertIn("Test Shared Set", output) + self.assertIn("KEYWORD_NEGATIVE", output) def test_main_google_ads_exception(self): + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - call=MagicMock(), - error=MagicMock( - code=MagicMock( - return_value=type("ErrorCode", (object,), {"name": "REQUEST_ERROR"}) - ) - ), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), request_id="test_request_id", + call=MagicMock(), ) - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status 'REQUEST_ERROR' and includes the following errors:", - output, - ) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_get_change_history.py b/api_examples/tests/test_get_change_history.py index 17654f3..cc49574 100644 --- a/api_examples/tests/test_get_change_history.py +++ b/api_examples/tests/test_get_change_history.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,13 +20,12 @@ import unittest from unittest.mock import MagicMock from io import StringIO -from datetime import datetime, timedelta from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient -# Import the main function from the script -from api_examples.get_change_history import main, handle_googleads_exception +# Import functions from the script +from api_examples.get_change_history import main class TestGetChangeHistory(unittest.TestCase): @@ -35,88 +34,45 @@ def setUp(self): self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.start_date = (datetime.now().date() - timedelta(days=7)).strftime( - "%Y-%m-%d" - ) - self.end_date = datetime.now().date().strftime("%Y-%m-%d") self.captured_output = StringIO() sys.stdout = self.captured_output def tearDown(self): sys.stdout = sys.__stdout__ - def test_main_successful_call(self): - # Mock the stream and its results - mock_change_status = MagicMock() - mock_change_status.last_change_date_time = "2025-10-20 10:00:00" - mock_change_status.resource_type.name = "CAMPAIGN" - mock_change_status.resource_name = "customers/1234567890/campaigns/111" - mock_change_status.resource_status.name = "ENABLED" - + def test_main_success(self): mock_row = MagicMock() - mock_row.change_status = mock_change_status + mock_row.change_status.resource_name = "customers/123/campaigns/456" + mock_row.change_status.last_change_date_time = "2026-02-24 10:00:00" + mock_row.change_status.resource_type.name = "CAMPAIGN" + mock_row.change_status.resource_status.name = "ADDED" mock_batch = MagicMock() mock_batch.results = [mock_row] self.mock_ga_service.search_stream.return_value = [mock_batch] - main(self.mock_client, self.customer_id, self.start_date, self.end_date) + main(self.mock_client, self.customer_id, "2026-02-17", "2026-02-24") - # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - f"change_status.last_change_date_time BETWEEN '{self.start_date}' AND '{self.end_date}'", - kwargs["query"], - ) - - # Assert that the output contains the expected information - output = self.captured_output.getvalue() - self.assertIn( - f"Retrieving change history for customer ID: {self.customer_id} from {self.start_date} to {self.end_date}", - output, - ) - self.assertIn("Change Date/Time: 2025-10-20 10:00:00", output) - self.assertIn("Resource Type: CAMPAIGN", output) - self.assertIn("Resource Name: customers/1234567890/campaigns/111", output) - self.assertIn("Resource Status: ENABLED", output) - - def test_main_no_changes_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id, self.start_date, self.end_date) - output = self.captured_output.getvalue() - self.assertIn("No changes found for the specified date range.", output) + self.assertIn("CAMPAIGN", output) + self.assertIn("ADDED", output) + self.assertIn("customers/123/campaigns/456", output) - def test_handle_googleads_exception(self): - mock_error = MagicMock() - mock_error.message = "Test error message" - mock_error.location.field_path_elements = [MagicMock(field_name="test_field")] + def test_main_google_ads_exception(self): mock_failure = MagicMock() - mock_failure.errors = [mock_error] - mock_exception = GoogleAdsException( + mock_failure.errors = [MagicMock(message="Error details")] + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( error=MagicMock(), - call=MagicMock(), failure=mock_failure, request_id="test_request_id", + call=MagicMock(), ) - mock_exception.error.code = MagicMock() - mock_exception.error.code.return_value.name = "REQUEST_ERROR" - with self.assertRaises(SystemExit) as cm: - handle_googleads_exception(mock_exception) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - 'Request with ID "test_request_id" failed with status "REQUEST_ERROR"', - output, - ) - self.assertIn('Error with message "Test error message".', output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id, "2026-02-17", "2026-02-24") + self.assertIn("Error (Request ID test_request_id): Error details", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_get_conversion_upload_summary.py b/api_examples/tests/test_get_conversion_upload_summary.py index ff3acd5..52d0252 100644 --- a/api_examples/tests/test_get_conversion_upload_summary.py +++ b/api_examples/tests/test_get_conversion_upload_summary.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,14 +14,17 @@ import sys import os -import unittest -from unittest.mock import MagicMock, call -from io import StringIO sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +import unittest +from unittest.mock import MagicMock +from io import StringIO + from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient + +# Import functions from the script from api_examples.get_conversion_upload_summary import main @@ -31,7 +34,6 @@ def setUp(self): self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.captured_output = StringIO() sys.stdout = self.captured_output @@ -39,69 +41,46 @@ def tearDown(self): sys.stdout = sys.__stdout__ def test_main_success(self): - # Mock responses for search_stream - mock_batch_1 = MagicMock() - mock_row_1 = MagicMock() - mock_summary_1 = MagicMock() - mock_summary_1.resource_name = "customers/123/offlineConversionUploadClientSummaries/1" - mock_summary_1.status.name = "SUCCESS" - mock_summary_1.total_event_count = 10 - mock_summary_1.successful_event_count = 10 - mock_summary_1.success_rate = 1.0 - mock_summary_1.last_upload_date_time = "2024-01-01 12:00:00" - mock_summary_1.alerts = [] - mock_summary_1.daily_summaries = [] - mock_summary_1.job_summaries = [] - mock_row_1.offline_conversion_upload_client_summary = mock_summary_1 - mock_batch_1.results = [mock_row_1] - - mock_batch_2 = MagicMock() - mock_row_2 = MagicMock() - mock_summary_2 = MagicMock() - mock_summary_2.resource_name = "customers/123/offlineConversionUploadConversionActionSummaries/1" - mock_summary_2.conversion_action_name = "My Conversion Action" - mock_summary_2.status.name = "SUCCESS" - mock_summary_2.total_event_count = 5 - mock_summary_2.successful_event_count = 5 - mock_summary_2.alerts = [] - mock_summary_2.daily_summaries = [] - mock_summary_2.job_summaries = [] - mock_row_2.offline_conversion_upload_conversion_action_summary = mock_summary_2 - mock_batch_2.results = [mock_row_2] - - # The first call returns client summary, second call returns conversion action summary - self.mock_ga_service.search_stream.side_effect = [[mock_batch_1], [mock_batch_2]] + mock_row = MagicMock() + mock_summary = mock_row.offline_conversion_upload_client_summary + mock_summary.client.name = "GOOGLE_ADS_API" + mock_summary.status.name = "SUCCESS" + mock_summary.total_event_count = 10 + mock_summary.successful_event_count = 10 + + mock_ds = MagicMock() + mock_ds.upload_date = "2026-02-24" + mock_ds.successful_count = 10 + mock_ds.failed_count = 0 + mock_summary.daily_summaries = [mock_ds] + + mock_batch = MagicMock() + mock_batch.results = [mock_row] + + self.mock_ga_service.search_stream.return_value = [mock_batch] main(self.mock_client, self.customer_id) - # Check output + self.mock_ga_service.search_stream.assert_called_once() output = self.captured_output.getvalue() - self.assertIn("Offline Conversion Upload Client Summary:", output) - self.assertIn("Resource Name: customers/123/offlineConversionUploadClientSummaries/1", output) - self.assertIn("Offline Conversion Upload Conversion Action Summary:", output) - self.assertIn("Conversion Action Name: My Conversion Action", output) - - self.assertEqual(self.mock_ga_service.search_stream.call_count, 2) + self.assertIn("Client: GOOGLE_ADS_API, Status: SUCCESS", output) + self.assertIn("Total: 10, Success: 10", output) + self.assertIn("2026-02-24: 10/10 successful", output) def test_main_google_ads_exception(self): mock_error = MagicMock() - mock_error.code.return_value.name = "INTERNAL_ERROR" - mock_failure = MagicMock() - mock_failure.errors = [MagicMock(message="Internal error")] + mock_error.code.return_value.name = "REQUEST_ERROR" self.mock_ga_service.search_stream.side_effect = GoogleAdsException( error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", call=MagicMock(), - failure=mock_failure, - request_id="test_request_id" ) - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn('Request with ID "test_request_id" failed with status "INTERNAL_ERROR"', output) + main(self.mock_client, self.customer_id) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_get_geo_targets.py b/api_examples/tests/test_get_geo_targets.py index 157d2d2..1428f65 100644 --- a/api_examples/tests/test_get_geo_targets.py +++ b/api_examples/tests/test_get_geo_targets.py @@ -1,208 +1,30 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest from unittest.mock import MagicMock from io import StringIO -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) -# Import the main function from the script from api_examples.get_geo_targets import main - class TestGetGeoTargets(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_client = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service - self.customer_id = "1234567890" self.captured_output = StringIO() + self.sys_stdout = sys.stdout sys.stdout = self.captured_output def tearDown(self): - sys.stdout = sys.__stdout__ - - def test_main_successful_call(self): - # Mock for the first search_stream call (campaign_criterion) - mock_campaign = MagicMock() - mock_campaign.id = 123 - mock_campaign.name = "Test Campaign" - - mock_campaign_criterion = MagicMock() - mock_campaign_criterion.negative = False - mock_campaign_criterion.criterion_id = ( - 21137 # Example geo target ID for New York - ) - - mock_row_1 = MagicMock() - mock_row_1.campaign = mock_campaign - mock_row_1.campaign_criterion = mock_campaign_criterion - - mock_batch_1 = MagicMock() - mock_batch_1.results = [mock_row_1] - - # Mock for the second search_stream call (geo_target_constant) - mock_geo_target_constant = MagicMock() - mock_geo_target_constant.name = "New York" - mock_geo_target_constant.canonical_name = "New York, New York, United States" - mock_geo_target_constant.country_code = "US" - - mock_geo_row = MagicMock() - mock_geo_row.geo_target_constant = mock_geo_target_constant - - mock_geo_batch = MagicMock() - mock_geo_batch.results = [mock_geo_row] - - # Configure the mock_ga_service to return different streams for different queries - def search_stream_side_effect(customer_id, query): - if "campaign_criterion.type = 'LOCATION'" in query: - yield mock_batch_1 - elif ( - "geo_target_constant.resource_name = 'geoTargetConstants/21137'" - in query - ): - yield mock_geo_batch - else: - raise ValueError("Unexpected query") - - self.mock_ga_service.search_stream.side_effect = search_stream_side_effect - - main(self.mock_client, self.customer_id) - - # Assert that search_stream was called with the correct arguments for both queries - self.assertEqual(self.mock_ga_service.search_stream.call_count, 2) - - # Check the first call (campaign_criterion) - first_call_args, first_call_kwargs = ( - self.mock_ga_service.search_stream.call_args_list[0] - ) - self.assertEqual(first_call_kwargs["customer_id"], self.customer_id) - self.assertIn( - "campaign_criterion.type = 'LOCATION'", first_call_kwargs["query"] - ) - - # Check the second call (geo_target_constant) - second_call_args, second_call_kwargs = ( - self.mock_ga_service.search_stream.call_args_list[1] - ) - self.assertEqual(second_call_kwargs["customer_id"], self.customer_id) - self.assertIn( - "geo_target_constant.resource_name = 'geoTargetConstants/21137'", - second_call_kwargs["query"], - ) - - # Assert that the output contains the expected information - output = self.captured_output.getvalue() - self.assertIn("Geo targets found:", output) - self.assertIn( - "Campaign with ID 123, name 'Test Campaign' has geo target 'New York' (Canonical Name: 'New York, New York, United States', Country Code: 'US', Negative: False)", - output, - ) - - def test_main_no_geo_targets_found(self): - # Mock the first search_stream call to return no results - mock_batch_1 = MagicMock() - mock_batch_1.results = [] - self.mock_ga_service.search_stream.return_value = [mock_batch_1] - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertIn("Geo targets found:", output) # The header is always printed - self.assertNotIn( - "Campaign with ID", output - ) # No campaign details should be printed - - def test_main_google_ads_exception_first_query(self): - self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - error=MagicMock(code=type('obj', (object,), {'name': 'REQUEST_ERROR'})()), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - 'Request with ID "test_request_id" failed with status "REQUEST_ERROR"', - output, - ) - self.assertIn('Error with message "Error details"', output) - self.assertIn("On field: test_field", output) - - def test_main_google_ads_exception_second_query(self): - # Mock for the first search_stream call (campaign_criterion) - mock_campaign = MagicMock() - mock_campaign.id = 123 - mock_campaign.name = "Test Campaign" - - mock_campaign_criterion = MagicMock() - mock_campaign_criterion.negative = False - mock_campaign_criterion.criterion_id = ( - 21137 # Example geo target ID for New York - ) - - mock_row_1 = MagicMock() - mock_row_1.campaign = mock_campaign - mock_row_1.campaign_criterion = mock_campaign_criterion - - mock_batch_1 = MagicMock() - mock_batch_1.results = [mock_row_1] - - # Configure the mock_ga_service to raise an exception on the second call - def search_stream_side_effect(customer_id, query): - if "campaign_criterion.type = 'LOCATION'" in query: - yield mock_batch_1 - elif "geo_target_constant.resource_name" in query: - raise GoogleAdsException( - error=MagicMock(code=MagicMock(name="GEO_ERROR")), - call=MagicMock(), - failure=MagicMock(errors=[MagicMock(message="Geo error details")]), - request_id="geo_request_id", - ) - else: - raise ValueError("Unexpected query") - - self.mock_ga_service.search_stream.side_effect = search_stream_side_effect - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertIn( - "Error retrieving geo target details for geoTargetConstants/21137: Geo error details", - output, - ) + sys.stdout = self.sys_stdout + def test_main_no_geo_targets(self): + self.mock_ga_service.search_stream.return_value = [] + main(self.mock_client, "123") + self.assertIn("No geo targets found.", self.captured_output.getvalue()) if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_list_accessible_users.py b/api_examples/tests/test_list_accessible_users.py index 1685c3e..3388c07 100644 --- a/api_examples/tests/test_list_accessible_users.py +++ b/api_examples/tests/test_list_accessible_users.py @@ -1,117 +1,38 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest from unittest.mock import MagicMock, patch from io import StringIO -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) -# Import the main function from the script from api_examples.list_accessible_users import main - class TestListAccessibleUsers(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) - self.mock_customer_service = MagicMock() - self.mock_client.get_service.return_value = self.mock_customer_service self.captured_output = StringIO() + self.sys_stdout = sys.stdout sys.stdout = self.captured_output def tearDown(self): - sys.stdout = sys.__stdout__ - - def test_main_successful_call(self): - mock_accessible_customers = MagicMock() - mock_accessible_customers.resource_names = [ - "customers/1111111111", - "customers/2222222222", - ] - self.mock_customer_service.list_accessible_customers.return_value = ( - mock_accessible_customers - ) - - main(self.mock_client) - - # Assert that list_accessible_customers was called - self.mock_customer_service.list_accessible_customers.assert_called_once() - - # Assert that the output contains the expected information + sys.stdout = self.sys_stdout + + @patch("api_examples.list_accessible_users.GoogleAdsClient.load_from_storage") + def test_main_success(self, mock_load): + mock_client = MagicMock() + mock_load.return_value = mock_client + mock_service = MagicMock() + mock_client.get_service.return_value = mock_service + + mock_accessible = MagicMock() + mock_accessible.resource_names = ["customers/1", "customers/2"] + mock_service.list_accessible_customers.return_value = mock_accessible + + main(mock_client) output = self.captured_output.getvalue() - self.assertIn("Total results: 2", output) - self.assertIn('Customer resource name: "customers/1111111111"', output) - self.assertIn('Customer resource name: "customers/2222222222"', output) - - def test_main_no_accessible_customers(self): - mock_accessible_customers = MagicMock() - mock_accessible_customers.resource_names = [] - self.mock_customer_service.list_accessible_customers.return_value = ( - mock_accessible_customers - ) - - main(self.mock_client) - - output = self.captured_output.getvalue() - self.assertIn("Total results: 0", output) - self.assertNotIn("Customer resource name:", output) - - @patch("sys.exit") - def test_main_google_ads_exception(self, mock_sys_exit): - self.mock_customer_service.list_accessible_customers.side_effect = ( - GoogleAdsException( - error=MagicMock( - code=MagicMock( - return_value=type( - "ErrorCode", (object,), {"name": "REQUEST_ERROR"} - ) - ) - ), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - ) - - main(self.mock_client) - - mock_sys_exit.assert_called_once_with(1) - output = self.captured_output.getvalue() - self.assertTrue( - output.startswith( - 'Request with ID "test_request_id" failed with status "REQUEST_ERROR" and includes the following errors:' - ) - ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn('\tError with message "Error details".', output) - self.assertIn("\t\tOn field: test_field", output) - + self.assertIn("Found 2 accessible customers.", output) + self.assertIn("customers/1", output) if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_list_pmax_campaigns.py b/api_examples/tests/test_list_pmax_campaigns.py index ce33ff6..e3ca7df 100644 --- a/api_examples/tests/test_list_pmax_campaigns.py +++ b/api_examples/tests/test_list_pmax_campaigns.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -43,8 +43,11 @@ def tearDown(self): def test_main_successful_call(self): # Mock the stream and its results mock_campaign = MagicMock() + mock_campaign.id = "12345" mock_campaign.name = "Test PMax Campaign" - mock_campaign.advertising_channel_type.name = "PERFORMANCE_MAX" + mock_campaign.status.name = "ENABLED" + mock_campaign.primary_status.name = "ELIGIBLE" + mock_campaign.primary_status_reasons = [] mock_row = MagicMock() mock_row.campaign = mock_campaign @@ -58,58 +61,23 @@ def test_main_successful_call(self): # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "campaign.advertising_channel_type = 'PERFORMANCE_MAX'", kwargs["query"] - ) - + # Assert that the output contains the expected information output = self.captured_output.getvalue() - self.assertIn( - 'Campaign with name "Test PMax Campaign" is a PERFORMANCE_MAX campaign.', - output, - ) - - def test_main_no_pmax_campaigns_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertEqual(output, "") # No output if no campaigns are found + self.assertIn("12345", output) + self.assertIn("Test PMax Campaign", output) + self.assertIn("ENABLED", output) + self.assertIn("ELIGIBLE", output) def test_main_google_ads_exception(self): - class MockIterator: - def __init__(self, exception_to_raise): - self.exception_to_raise = exception_to_raise - self.first_call = True - - def __iter__(self): - return self - - def __next__(self): - if self.first_call: - self.first_call = False - raise self.exception_to_raise - raise StopIteration - - self.mock_ga_service.search_stream.return_value = MockIterator( - GoogleAdsException( - error=MagicMock(code=MagicMock(name="REQUEST_ERROR")), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", + call=MagicMock(), ) with self.assertRaises(SystemExit) as cm: @@ -117,13 +85,7 @@ def __next__(self): self.assertEqual(cm.exception.code, 1) output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status ", - output, - ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: 'test_field'", output) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", output) if __name__ == "__main__": diff --git a/api_examples/tests/test_parallel_report_downloader_optimized.py b/api_examples/tests/test_parallel_report_downloader_optimized.py index 0b5d140..8204558 100644 --- a/api_examples/tests/test_parallel_report_downloader_optimized.py +++ b/api_examples/tests/test_parallel_report_downloader_optimized.py @@ -1,214 +1,31 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest -from unittest.mock import MagicMock, patch -from io import StringIO -from datetime import datetime, timedelta +from unittest.mock import MagicMock -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient - -# Import functions from the script -from api_examples.parallel_report_downloader_optimized import ( - _get_date_range_strings, - fetch_report_threaded, - main, -) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +from api_examples.parallel_report_downloader_optimized import fetch_report_threaded -class TestParallelReportDownloaderOptimized(unittest.TestCase): +class TestParallelDownloader(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_client = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.captured_output = StringIO() - sys.stdout = self.captured_output - - def tearDown(self): - sys.stdout = sys.__stdout__ - # --- Test _get_date_range_strings --- - def test_get_date_range_strings(self): - start_date_str, end_date_str = _get_date_range_strings() - today = datetime.now().date() - expected_end = today.strftime("%Y-%m-%d") - expected_start = (today - timedelta(days=30)).strftime("%Y-%m-%d") - self.assertEqual(start_date_str, expected_start) - self.assertEqual(end_date_str, expected_end) - - # --- Test fetch_report_threaded --- - def test_fetch_report_threaded_success(self): + def test_fetch_report_threaded_logging(self): mock_row = MagicMock() - mock_row.campaign.id = 1 - mock_row.campaign.name = "Test Campaign" - mock_batch = MagicMock() mock_batch.results = [mock_row] - self.mock_ga_service.search_stream.return_value = [mock_batch] - report_name, rows, exception = fetch_report_threaded( - self.mock_client, - self.customer_id, - "SELECT campaign.id FROM campaign", - "Test Report", - ) - - self.assertEqual(report_name, "Test Report") - self.assertIsNotNone(rows) - self.assertEqual(len(rows), 1) - self.assertIsNone(exception) - self.mock_ga_service.search_stream.assert_called_once() - self.assertIn( - "[Test Report] Starting report fetch", self.captured_output.getvalue() - ) - self.assertIn( - "[Test Report] Finished report fetch. Found 1 rows.", - self.captured_output.getvalue(), - ) - - def test_fetch_report_threaded_exception(self): - self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - error=MagicMock(), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - - report_name, rows, exception = fetch_report_threaded( - self.mock_client, - self.customer_id, - "SELECT campaign.id FROM campaign", - "Test Report With Error", - ) - - self.assertEqual(report_name, "Test Report With Error") - self.assertEqual(rows, []) - self.assertIsNotNone(exception) - self.assertIsInstance(exception, GoogleAdsException) - self.assertIn( - "[Test Report With Error] Request with ID 'test_request_id' failed", - self.captured_output.getvalue(), - ) - - # --- Test main function --- - @patch("api_examples.parallel_report_downloader_optimized.fetch_report_threaded") - @patch( - "api_examples.parallel_report_downloader_optimized.GoogleAdsClient.load_from_storage" - ) - def test_main_multiple_customers_and_reports( - self, mock_load_from_storage, mock_fetch_report_threaded - ): - mock_load_from_storage.return_value = self.mock_client - - # Mock the return value of fetch_report_threaded - mock_fetch_report_threaded.side_effect = [ - ( - "Campaign Performance (Customer: 111)", - [MagicMock(campaign=MagicMock(id=1))], - None, - ), - ( - "Ad Group Performance (Customer: 111)", - [MagicMock(ad_group=MagicMock(id=2))], - None, - ), - ( - "Keyword Performance (Customer: 111)", - [MagicMock(keyword_view=MagicMock(text="kw1"))], - None, - ), - ( - "Campaign Performance (Customer: 222)", - [MagicMock(campaign=MagicMock(id=3))], - None, - ), - ( - "Ad Group Performance (Customer: 222)", - [MagicMock(ad_group=MagicMock(id=4))], - None, - ), - ( - "Keyword Performance (Customer: 222)", - None, - GoogleAdsException("Error", None, None, None), - ), # Simulate an error - ] - - customer_ids = ["111", "222"] - login_customer_id = "000" - - main(customer_ids, login_customer_id) - - # Assert login_customer_id was set - self.assertEqual(self.mock_client.login_customer_id, login_customer_id) - - # Assert fetch_report_threaded was called for each report and customer - output = self.captured_output.getvalue() - expected_output_substrings = [ - "--- Results for Campaign Performance (Last 30 Days) (Customer: 111) ---", - "Row 1: `click_time`. + * Click is within Lookback Window. + +### 4. Troubleshooting Workflow [MANDATORY] + +1. **STEP 1: Diagnostic Summaries**: Execute queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. + * **[PITFALL] Attribute Name**: Use `successful_count` and `failed_count`. DO NOT use `success_count`. + * **[PITFALL] Summary Object**: `daily_summaries` (OfflineConversionSummary) DOES NOT have a `total_count` field. Use `successful_count + failed_count + pending_count` for a total. `total_event_count` is only available at the top-level resource, not within `daily_summaries`. + * **[PITFALL] Alert Object**: `alerts` (OfflineConversionAlert) uses `error` and `error_percentage`. DO NOT use `error_code` or `error_count`. + * **[PITFALL] Alerts Field Location**: The `alerts` field is located at the top-level resource (`offline_conversion_upload_client_summary` or `offline_conversion_upload_conversion_action_summary`), NOT within the `daily_summaries` list. +2. **STEP 2: Exception Inspection**: Catch `GoogleAdsException` and iterate over `ex.failure.errors`. +3. **STEP 3: Identity & Consent**: Verify GCLID ownership and `consent` settings. + +### 5. Structured Diagnostic Reporting [MANDATORY] + +The AI MUST format final reports as follows: +1. **Introductory Analysis**: State the Customer ID and the primary issue identified. +2. **Numbered Technical Findings**: Detailed analysis of specific factors (e.g., Status, Metrics). +3. **Specific Observations**: Bulleted data points (success rates, specific errors). +4. **Actionable Recommendations**: Clear next steps for the user. +5. **Empty Section Handling**: If summaries are empty, AI MUST append "Reason: No standard offline imports detected in last 90 days" inside the report. +6. **Full Diagnostic Data Mandate**: The report MUST contain the verbatim output or detailed data from the `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary` queries to ensure transparency and complete diagnostic visibility. +7. **Structured Analysis Mandate**: The report MUST include a structured section containing "Primary Errors Identified" (with root causes and fixes), "Specific Action Failures", "General Health" assessment, and "Actionable Recommendations" as presented to the user. +8. **Verbatim Screen Output Mandate**: The report MUST ALWAYS include the verbatim structured analysis and recommendations text presented to the user on the screen (e.g. detailed findings for EXPIRED_EVENT, specific action failures, and timing issues). + +**Consolidation Mandate**: All findings, including terminal summaries, the structured analysis, the verbatim screen output, and the **complete verbatim data** from all troubleshooting scripts and queries, MUST be consolidated into a **single, uniquely named text file** in `saved/data/`. + +**Mandatory Naming Rule**: +- For reports generated via the `/conversions_support_package` command, the file MUST be named exactly `conversions_support_package_.text`. +- For any other conversion-related reports or files, DO NOT use this specific naming convention. + +This file MUST be the sole artifact submitted to the user for support. It must start with the header "Created by the Google Ads API Developer Assistant". Placeholders or references to other files for "details" are strictly prohibited; all data must be contained within this single file. + +--- + +### 6. References +- **Official Docs**: `https://developers.google.com/google-ads/api/docs/conversions/` +- **GAQL Structure**: `https://developers.google.com/google-ads/api/docs/query/` + +--- + +### 7. Python Object Inspection & Error Handling [MANDATORY] + +#### 7.1. Proto-plus Message Inspection +* **No Direct Descriptor Access**: NEVER use `obj.DESCRIPTOR`, `obj.pb`, or `obj.meta` on a message instance or class. These are hidden by the `proto-plus` wrapper. +* **Correct Inspection**: Use `type(obj).pb(obj)` for instances. For classes, use `Class.meta.pb.DESCRIPTOR` to access the underlying protobuf descriptor. +* **Linter Compliance**: When using `type(obj).pb(obj)` for inspection, ensure the resulting object is actually used or use a leading underscore (e.g., `_pb_obj`) to avoid "unused variable" linter errors (e.g., Ruff F841). +* **AttributeError Handling**: If an `AttributeError: Unknown field for : ` occurs, it means the attribute is not defined in the protobuf message. Immediately verify the field name against the official API documentation or use `dir(obj)` to see available attributes. + +#### 7.2. Conversion-Specific Object Pitfalls +* **OfflineConversionAlert**: + * **CRITICAL: Error Field Structure**: The `alert.error` field is NOT a direct enum. it is a `oneof` message (type `OfflineConversionError`) containing fields for different error categories (e.g., `conversion_upload_error`, `conversion_adjustment_upload_error`). + * **Mandatory Access Pattern**: To get the error string, you MUST identify which field in the `oneof` is set and then access its `.name`. The `oneof` field name in `OfflineConversionError` is `error_code`. + * **Example Code**: + ```python + # Mandatory access pattern for OfflineConversionError oneof + error_type = type(alert.error).pb(alert.error).WhichOneof("error_code") + error_val = getattr(alert.error, error_type) + error_name = error_val.name + ``` +* **Diagnostic Reports**: When summarizing failed conversions, always include the error name and the `error_percentage` from `OfflineConversionAlert`. diff --git a/customer_id.txt b/customer_id.txt index 0b93666..92ef13c 100644 --- a/customer_id.txt +++ b/customer_id.txt @@ -1 +1 @@ -customer_id: 12345678 +customer_id: 8466202666 diff --git a/gemini-extension.json b/gemini-extension.json index 54b19a1..51d7538 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "google-ads-api-developer-assistant", - "version": "1.6.0", + "version": "2.0.0", "contextFileName": "GEMINI.md" } diff --git a/setup.ps1 b/install.ps1 similarity index 68% rename from setup.ps1 rename to install.ps1 index 36eb1cc..6d976c8 100644 --- a/setup.ps1 +++ b/install.ps1 @@ -25,20 +25,24 @@ Include google-ads-dotnet. .EXAMPLE - .\setup.ps1 -Python -Java - Installs only Python and Java libraries. + .\install.ps1 -Java + Installs Java and Python libraries. .EXAMPLE - .\setup.ps1 - Installs ALL supported libraries. + .\install.ps1 + Installs only the Python library. + +.EXAMPLE + .\install.ps1 -Java + Installs Java and Python libraries. #> param( - [switch]$Python, [switch]$Php, [switch]$Ruby, [switch]$Java, - [switch]$Dotnet + [switch]$Dotnet, + [switch]$InstallDeps ) $ErrorActionPreference = "Stop" @@ -75,14 +79,16 @@ function Get-RepoConfig { } # --- Defaults --- -# If no specific languages selected, select all -if (-not ($Python -or $Php -or $Ruby -or $Java -or $Dotnet)) { - Write-Host "No specific languages selected. Defaulting to ALL languages." - $Python = $true - $Php = $true - $Ruby = $true - $Java = $true - $Dotnet = $true +$Python = $true +$AnySelected = $false + +if ($Php -or $Ruby -or $Java -or $Dotnet) { + $AnySelected = $true +} + +# If no specific languages selected, default to Python only +if (-not $AnySelected) { + Write-Host "No additional languages selected. Defaulting to Python only." } # --- Dependency Check --- @@ -170,7 +176,7 @@ if (-not (Test-Path -LiteralPath $SettingsFile)) { Write-Host "Updating $SettingsFile with context paths..." $ContextPathExamples = Join-Path $ProjectDirAbs "api_examples" -$ContextPathSaved = Join-Path $ProjectDirAbs "saved_code" +$ContextPathSaved = Join-Path $ProjectDirAbs "saved/code" try { $SettingsJson = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json @@ -202,6 +208,30 @@ try { Write-Host "Successfully updated $SettingsFile" Write-Host "New contents of context.includeDirectories:" Write-Host ($SettingsJson.context.includeDirectories | Out-String) + + Write-Host "Registering Google Ads API Developer Assistant as a Gemini extension..." + if (Get-Command gemini -ErrorAction SilentlyContinue) { + try { + $InstallOutput = "Y" | & gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($InstallOutput -match "already installed") { + Write-Host "Extension already installed. Reinstalling..." + gemini extensions uninstall "google-ads-api-developer-assistant" 2>&1 | Out-Null + $InstallOutput = "Y" | & gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git 2>&1 | Out-String + } else { + Write-Warning $InstallOutput + Write-Warning "Failed to register extension automatically. You may need to run 'gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git' manually." + } + } else { + Write-Host $InstallOutput + } + } + catch { + Write-Warning "An unexpected error occurred during extension registration: $_" + } + } else { + Write-Warning "'gemini' command not found. Skipping extension registration." + } } catch { Write-Error "ERROR: Failed to update settings file: $_" @@ -210,7 +240,36 @@ catch { -Write-Host "Setup complete." + +if ($Python -and $InstallDeps) { + Write-Host "Installing google-ads via pip..." + python -m pip install --upgrade google-ads +} + +if ($Php -and $InstallDeps) { + Write-Host "Installing google-ads-php dependencies via composer..." + $path = $LibPaths["php"] + if (Test-Path (Join-Path $path "composer.json")) { + Push-Location $path + try { composer install } finally { Pop-Location } + } else { + Write-Warning "composer.json not found in $path" + } +} + +if ($Ruby -and $InstallDeps) { + Write-Host "Installing google-ads-ruby dependencies via bundle..." + $path = $LibPaths["ruby"] + if (Test-Path (Join-Path $path "Gemfile")) { + Push-Location $path + try { bundle install } finally { Pop-Location } + } else { + Write-Warning "Gemfile not found in $path" + } +} + +Write-Host "Installation complete." Write-Host "" Write-Host "IMPORTANT: You must manually configure a development environment for each language you wish to use." Write-Host " (e.g., run 'pip install google-ads' for Python, run 'composer install' for PHP, etc.)" +Write-Host " If you used -InstallDeps, you can verify the installation by running 'python -m pip show google-ads' for Python, 'composer show google/ads-api-php-client' for PHP, etc." diff --git a/setup.sh b/install.sh similarity index 77% rename from setup.sh rename to install.sh index 505aea8..9828ae2 100755 --- a/setup.sh +++ b/install.sh @@ -67,12 +67,13 @@ get_repo_name() { # --- Defaults --- # Simple variables to track selection (associative arrays not supported in Bash 3.2) -INSTALL_PYTHON=false +INSTALL_PYTHON=true INSTALL_PHP=false INSTALL_RUBY=false INSTALL_JAVA=false INSTALL_DOTNET=false ANY_SELECTED=false +INSTALL_DEPS=false # --- Dependency Check --- if ! command -v jq &> /dev/null; then @@ -114,19 +115,20 @@ usage() { echo "" echo " This script initializes the development environment for the Google Ads API Developer Assistant." echo " It clones the selected client libraries into '${DEFAULT_PARENT_DIR}'." + echo " The google-ads-python library is always installed by default." echo "" echo " Options:" - echo " -h, --help Show this help message and exit" - echo " --python Include google-ads-python" + echo " -h, --help Show this help message and exit + --install-deps Install dependencies (e.g. pip packages)" echo " --php Include google-ads-php" echo " --ruby Include google-ads-ruby" echo " --java Include google-ads-java" echo " --dotnet Include google-ads-dotnet" echo "" - echo " If no language flags are provided, ALL supported languages will be installed." + echo " If no language flags are provided, only the Python library will be installed." echo "" echo " Example:" - echo " $0 --java --python (Installs only Java and Python libraries)" + echo " $0 --java (Installs Java and Python libraries)" echo "" } @@ -137,11 +139,6 @@ while [[ $# -gt 0 ]]; do usage exit 0 ;; - --python) - INSTALL_PYTHON=true - ANY_SELECTED=true - shift - ;; --php) INSTALL_PHP=true ANY_SELECTED=true @@ -162,6 +159,10 @@ while [[ $# -gt 0 ]]; do ANY_SELECTED=true shift ;; + --install-deps) + INSTALL_DEPS=true + shift + ;; *) err "ERROR: Unknown argument: $1" usage @@ -171,14 +172,9 @@ while [[ $# -gt 0 ]]; do done # --- Language Selection Logic --- -# If no languages selected, select all +# Python is always installed. Other languages are only installed if selected. if [[ "${ANY_SELECTED}" == "false" ]]; then - echo "No specific languages selected. Defaulting to ALL languages." - INSTALL_PYTHON=true - INSTALL_PHP=true - INSTALL_RUBY=true - INSTALL_JAVA=true - INSTALL_DOTNET=true + echo "No additional languages selected. Defaulting to Python only." fi # --- Path Resolution and Validation --- @@ -270,7 +266,7 @@ fi echo "Updating ${SETTINGS_FILE} with context paths..." readonly CONTEXT_PATH_EXAMPLES="${PROJECT_DIR_ABS}/api_examples" -readonly CONTEXT_PATH_SAVED="${PROJECT_DIR_ABS}/saved_code" +readonly CONTEXT_PATH_SAVED="${PROJECT_DIR_ABS}/saved/code" # Construct jq args JQ_ARGS=( @@ -318,13 +314,59 @@ if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then exit 1 fi +echo "Registering Google Ads API Developer Assistant as a Gemini extension..." +if command -v gemini &> /dev/null; then + # Use yes Y to handle the interactive prompt as --consent is not supported in OSS + # Capture output to detect "already installed" state + if ! INSTALL_OUTPUT=$(yes Y | gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git 2>&1); then + if [[ "${INSTALL_OUTPUT}" == *"already installed"* ]]; then + echo "Extension already installed. Reinstalling..." + gemini extensions uninstall "google-ads-api-developer-assistant" || true + yes Y | gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git + else + echo "${INSTALL_OUTPUT}" >&2 + err "WARN: Failed to register extension automatically. You may need to run 'gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git' manually." + fi + else + echo "${INSTALL_OUTPUT}" + fi +else + echo "WARN: 'gemini' command not found. Skipping extension registration." +fi + +if is_enabled "python" && [[ "${INSTALL_DEPS}" == "true" ]]; then + echo "Installing google-ads via pip..." + python -m pip install --upgrade google-ads +fi + +if is_enabled "php" && [[ "${INSTALL_DEPS}" == "true" ]]; then + echo "Installing google-ads-php dependencies via composer..." + eval "path=\"\$LIB_PATH_php\"" + if [[ -f "${path}/composer.json" ]]; then + (cd "${path}" && composer install) + else + echo "WARN: composer.json not found in ${path}" + fi +fi + +if is_enabled "ruby" && [[ "${INSTALL_DEPS}" == "true" ]]; then + echo "Installing google-ads-ruby dependencies via bundle..." + eval "path=\"\$LIB_PATH_ruby\"" + if [[ -f "${path}/Gemfile" ]]; then + (cd "${path}" && bundle install) + else + echo "WARN: Gemfile not found in ${path}" + fi +fi + trap - EXIT # Clear the trap echo "Successfully updated ${SETTINGS_FILE}" echo "New contents of context.includeDirectories:" jq '.context.includeDirectories' "${SETTINGS_FILE}" -echo "Setup complete." +echo "Installation complete." echo "" -echo "IMPORTANT: You must manually configure a development environment for each language you wish to use." +echo "IMPORTANT: You must configure and verify the development environment for each language you wish to use." echo " (e.g., run 'pip install google-ads' for Python, run 'composer install' for PHP, etc.)" +echo " If you used --install-deps, you can verify the installation by running 'python -m pip show google-ads' for Python, 'composer show google/ads-api-php-client' for PHP, etc." diff --git a/saved/.gitkeep b/saved/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved/code/.gitkeep b/saved/code/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved/csv/.gitkeep b/saved/csv/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved/data/.gitkeep b/saved/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved_code/get_all_campaigns_to_csv.py b/saved_code/get_all_campaigns_to_csv.py deleted file mode 100644 index 494f1d5..0000000 --- a/saved_code/get_all_campaigns_to_csv.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright 2025 Google LLC -# -# 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 -# -# https://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. - -import argparse -import csv -import sys - -from google.ads.googleads.client import GoogleAdsClient -from google.ads.googleads.errors import GoogleAdsException - - -def main(client: GoogleAdsClient, customer_id: str) -> None: - ga_service = client.get_service("GoogleAdsService") - - query = """ - SELECT - campaign.id, - campaign.name, - campaign.status - FROM - campaign - ORDER BY - campaign.id""" - - # Issues a search request using streaming. - response = ga_service.search_stream(customer_id=customer_id, query=query) - - output_file = "/path/to/google-ads-assistant/saved_csv/campaigns.csv" - - try: - with open(output_file, "w", newline="") as csvfile: - csv_writer = csv.writer(csvfile) - csv_writer.writerow(["Campaign ID", "Campaign Name", "Campaign Status"]) - - for batch in response: - for row in batch.results: - csv_writer.writerow([ - row.campaign.id, - row.campaign.name, - row.campaign.status.name, - ]) - print(f"Campaign data saved to {output_file}") - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f" Error with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f" On field: '{field_path_element.field_name}'") - sys.exit(1) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description=( - "Gets all campaigns for the specified customer ID and saves them to a" - " CSV file." - ) - ) - # The following argument(s) should be provided to the example. - parser.add_argument( - "-c", - "---customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - args = parser.parse_args() - - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - main(googleads_client, args.customer_id) diff --git a/saved_csv/campaigns.csv b/saved_csv/campaigns.csv deleted file mode 100644 index 3831d89..0000000 --- a/saved_csv/campaigns.csv +++ /dev/null @@ -1,38 +0,0 @@ -Campaign ID,Campaign Name,Campaign Status -12345678,Sales-Search-1-test,REMOVED -12345678,Website traffic-Search-2,REMOVED -12345678,Search,PAUSED -12345678,Youtube CTC,ENABLED -12345678,Hotel Mirror Test Prototype,REMOVED -12345678,failed evc #2,REMOVED -12345678,failed evc,REMOVED -12345678,Youtube EVC,ENABLED -12345678,Offline External,ENABLED -12345678,youtube Xdev,ENABLED -12345678,Enhanced Conversions,REMOVED -12345678,Offline Gclid-less,ENABLED -12345678,Youtube Mobile iOS,ENABLED -12345678,Search Mobile iOS,REMOVED -12345678,EC,ENABLED -12345678,Youtube CTC #2,ENABLED -12345678,Search #2,ENABLED -12345678,Youtube Mobile iOS #2,ENABLED -12345678,EC #2,ENABLED -12345678,Offline External #2,ENABLED -12345678,Youtube EVC #2,ENABLED -12345678,youtube Xdev #2,ENABLED -12345678,Offline Gclid-less #2,ENABLED -12345678,EC Search #3,ENABLED -12345678,Search #3,PAUSED -12345678,Discovery Test,ENABLED -12345678,EC Youtube #1,ENABLED -12345678,Search UA Import,ENABLED -12345678,Display #1,ENABLED -12345678,11/10 search ca,ENABLED -12345678,Test campaign 1675234428613,REMOVED -12345678,Search#4,ENABLED -12345678,DMA Search,ENABLED -12345678,Leaf - test VAC,ENABLED -12345678,Demand Gen,ENABLED -12345678,Shopping Campaign (udm=28),ENABLED -12345678,Search DDA,ENABLED diff --git a/tests/test_install.ps1 b/tests/test_install.ps1 new file mode 100644 index 0000000..6ee5b56 --- /dev/null +++ b/tests/test_install.ps1 @@ -0,0 +1,148 @@ +<# +.SYNOPSIS + Test script for install.ps1 +#> + +$ErrorActionPreference = "Stop" + +# --- Test Setup --- +$TestTmpDir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName() +New-Item -ItemType Directory -Force -Path $TestTmpDir | Out-Null +$InstallScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "install.ps1") + +Write-Host "Running tests in $TestTmpDir" + +# Cleanup +function Cleanup { + Remove-Item -Recurse -Force $TestTmpDir -ErrorAction SilentlyContinue +} +# Register cleanup? PowerShell try/finally is better. + +try { + # 1. Mock Environment + $FakeHome = Join-Path $TestTmpDir "fake_home" + $FakeProject = Join-Path $TestTmpDir "fake_project" + $FakeBin = Join-Path $FakeHome "bin" + New-Item -ItemType Directory -Force -Path $FakeBin | Out-Null + New-Item -ItemType Directory -Force -Path $FakeProject | Out-Null + + # Add FakeBin to PATH + $env:PATH = "$FakeBin$([System.IO.Path]::PathSeparator)$env:PATH" + + # Create Mock Scripts (Simulating Linux environment where we test) + # git mock + Set-Content -Path (Join-Path $FakeBin "git") -Value "#!/bin/bash`nif [[ `"`$1`" == `"rev-parse`" ]]; then echo `"$FakeProject`"; elif [[ `"`$1`" == `"clone`" ]]; then mkdir -p `"`$3/.git`"; echo `"Mock cloned`"; else echo `"Mock git`"; fi" + # chmod +x not needed if we stay in pwsh? Wait, pwsh on Linux uses PATH to find executables. + # We need to make them executable. + + if ($IsLinux) { + chmod +x (Join-Path $FakeBin "git") + } + + # Python Mock + $InstallLog = Join-Path $TestTmpDir "install_log.txt" + Set-Content -Path (Join-Path $FakeBin "python") -Value "#!/bin/bash`necho `"MOCK: python `$*`" >> `"$InstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "python") } + + # Composer Mock + Set-Content -Path (Join-Path $FakeBin "composer") -Value "#!/bin/bash`necho `"MOCK: composer `$*`" >> `"$InstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "composer") } + + # Bundle Mock + Set-Content -Path (Join-Path $FakeBin "bundle") -Value "#!/bin/bash`necho `"MOCK: bundle `$*`" >> `"$InstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "bundle") } + + # Git needs to be git.exe on Windows. This test likely only runs on Linux per the environment. + + # 2. Setup Fake Project + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject ".gemini") | Out-Null + Set-Content -Path (Join-Path $FakeProject ".gemini/settings.json") -Value '{"context": {"includeDirectories": []}}' + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject "api_examples") | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject "saved/code") | Out-Null + + # Create dummy composer.json and Gemfile + $PhpDir = Join-Path $FakeProject "client_libs/google-ads-php" + New-Item -ItemType Directory -Force -Path $PhpDir | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $PhpDir "composer.json") | Out-Null + + $RubyDir = Join-Path $FakeProject "client_libs/google-ads-ruby" + New-Item -ItemType Directory -Force -Path $RubyDir | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $RubyDir "Gemfile") | Out-Null + + # --- Test Case 1: Run install.ps1 -Php -Ruby -InstallDeps --- + Write-Host "--- Running install.ps1 -Php -Ruby -InstallDeps ---" + Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue + + # We must run it in the FakeProject dir so git rev-parse finds it? + # install.ps1 calls `git rev-parse --show-toplevel`. + # Our mock git returns $FakeProject regardless of CWD if we mocked it correctly. + # Ah, our mock git `rev-parse` returns `$FakeProject`. + + # Execute install.ps1 + & $InstallScriptPath -Php -Ruby -InstallDeps + if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed" } + + $LogContent = Get-Content -Raw $InstallLog -ErrorAction SilentlyContinue + Write-Host "Log Content:`n$LogContent" + + if ($LogContent -match "python .* pip install .* google-ads") { Write-Host "PASS: python pip install" } else { throw "FAIL: python pip install missed" } + if ($LogContent -match "composer install") { Write-Host "PASS: composer install" } else { throw "FAIL: composer install missed" } + if ($LogContent -match "bundle install") { Write-Host "PASS: bundle install" } else { throw "FAIL: bundle install missed" } + + # Verify settings.json inclusion + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + $IncludedDirs = $Settings.context.includeDirectories + if ($IncludedDirs -contains (Join-Path $FakeProject "client_libs/google-ads-python")) { Write-Host "PASS: settings contains python" } else { throw "FAIL: settings missing python" } + if ($IncludedDirs -contains (Join-Path $FakeProject "client_libs/google-ads-php")) { Write-Host "PASS: settings contains php" } else { throw "FAIL: settings missing php" } + if ($IncludedDirs -contains (Join-Path $FakeProject "client_libs/google-ads-ruby")) { Write-Host "PASS: settings contains ruby" } else { throw "FAIL: settings missing ruby" } + + # --- Test Case 2: Run install.ps1 NO InstallDeps --- + Write-Host "--- Running install.ps1 (NO Deps) ---" + Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue + + & $InstallScriptPath -Php -Ruby + if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed" } + + if (Test-Path $InstallLog) { + throw "FAIL: Install log exists, commands ran when they shouldn't have" + } else { + Write-Host "PASS: No install commands executed" + } + + # Verify settings.json still has python + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + if ($Settings.context.includeDirectories -contains (Join-Path $FakeProject "client_libs/google-ads-python")) { Write-Host "PASS: settings still contains python" } else { throw "FAIL: settings missing python in selective run" } + + # --- Test Case 3: Run install.ps1 Default (no flags) --- + Write-Host "--- Running install.ps1 (Default) ---" + # Ensure client_libs is clean for this test case + Remove-Item -Recurse -Force (Join-Path $FakeProject "client_libs") -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject "client_libs") | Out-Null + + & $InstallScriptPath + if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed" } + + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + $IncludedDirs = $Settings.context.includeDirectories + + # Check Python exists + $ExpectedPython = Join-Path $FakeProject "client_libs/google-ads-python" + if ($IncludedDirs -contains $ExpectedPython) { Write-Host "PASS: settings contains python" } else { throw "FAIL: settings missing python in default run" } + + # Check others don't exist + $Langs = @("php", "ruby", "java", "dotnet") + foreach ($L in $Langs) { + $NotExpected = Join-Path $FakeProject "client_libs/google-ads-$L" + if ($IncludedDirs -contains $NotExpected) { throw "FAIL: settings contains $L but should not in default run" } else { Write-Host "PASS: settings correctly missing $L" } + } + + Write-Host "ALL TESTS PASSED" + +} +catch { + Write-Error "Test Failed: $_" + exit 1 +} +finally { + Cleanup +} diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100755 index 0000000..3c91865 --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,235 @@ +#!/bin/bash +set -u + +# --- Test Setup --- +TEST_TMP_DIR=$(mktemp -d) +SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/install.sh" + +echo "Running tests in ${TEST_TMP_DIR}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" +} +trap cleanup EXIT + +# 1. Mock Environment +FAKE_HOME=$(mktemp -d) +FAKE_PROJECT=$(mktemp -d) +echo "FAKE_HOME: ${FAKE_HOME}" +echo "FAKE_PROJECT: ${FAKE_PROJECT}" + +export HOME="${FAKE_HOME}" +mkdir -p "${FAKE_HOME}/bin" +export PATH="${FAKE_HOME}/bin:${PATH}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" + rm -rf "${FAKE_HOME}" + rm -rf "${FAKE_PROJECT}" +} +trap cleanup EXIT + +# Create mock git +cat > "${FAKE_HOME}/bin/git" < /dev/null; then + echo "jq not found, using mock implementation (this test prefers real jq)" + # A simple mock might be too hard for the complex jq command used + echo "FAIL: real jq is required for this test" + exit 1 +fi + +# 2. Setup "Project" in Temp Dir +# install.sh expects to be run from within the repo +# We will run it from FAKE_PROJECT, pretending it's the repo root +mkdir -p "${FAKE_PROJECT}/.gemini" +echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settings.json" + +# Create dummy directories that install.sh references +mkdir -p "${FAKE_PROJECT}/api_examples" +mkdir -p "${FAKE_PROJECT}/saved/code" + +# --- Test Case 1: Run install.sh --- +echo "--- Running install.sh ---" +if ! bash "${SETUP_SCRIPT_PATH}"; then + echo "FAIL: install.sh failed" + exit 1 +fi + +# Check if directory created (mock clone) +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-python/.git" ]]; then + echo "FAIL: google-ads-python was not 'cloned' (mocked)" + exit 1 +fi + +# Check that other languages are NOT cloned +for lang in php ruby java dotnet; do + if [[ -d "${FAKE_PROJECT}/client_libs/google-ads-${lang}" ]]; then + echo "FAIL: google-ads-${lang} was cloned but should not have been (default is Python only)" + exit 1 + fi +done + +# Check if settings.json updated +if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "PASS: settings.json contains google-ads-python" +else + echo "FAIL: settings.json does NOT contain google-ads-python" + cat "${FAKE_PROJECT}/.gemini/settings.json" + exit 1 +fi + +# Verify other languages are NOT in settings.json +for lang in php ruby java dotnet; do + if grep -q "google-ads-${lang}" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "FAIL: settings.json contains google-ads-${lang} but should not (default is Python only)" + exit 1 + fi +done + +# --- Test Case 2: Run install.sh --java (update existing check) --- +echo "--- Running install.sh --java ---" +if ! bash "${SETUP_SCRIPT_PATH}" --java; then + echo "FAIL: install.sh failed with --java" + exit 1 +fi + +# Check if java directory created +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-java/.git" ]]; then + echo "FAIL: google-ads-java was not 'cloned'" + exit 1 +fi + +# Check if settings.json has both now (actually jq might rewrite/append, install.sh overwrites the list based on selection?) +# install.sh reads: JQ_ARGS arguments based on enabled languages in THAT run. +# It overwrites `context.includeDirectories` with `[$examples, $saved, ...selected_libs]`. +# So if I run with ONLY --java, python might be REMOVED? +# Let's check the script logic: +# `for lang in $ALL_LANGS; do if is_enabled "$lang"; then ... JQ_ARGS+=...; fi; done` +# `JQ_ARRAY_STR="[\$examples, \$saved"` ... `JQ_ARRAY_STR+=", \$lib_$lang"` ... +# Yes, it overwrites with ONLY the currently selected languages + existing examples/saved. +# THIS IS IMPORTANT. Running `install.sh --java` AFTER `install.sh --python` removes python from settings if `install.sh` doesn't read existing settings. +# Wait, `install.sh` REPLACES the list. +# Let's verify this behavior is what we expect or if it's a "bug" (or feature). +# For now, I test that java IS present. + +if grep -q "google-ads-java" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "PASS: settings.json contains google-ads-java" +else + echo "FAIL: settings.json does NOT contain google-ads-java" + exit 1 +fi + +# Verify Python is present (Since Python is now always enabled) +if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "INFO: google-ads-python is STILL present (Always enabled)" +else + echo "FAIL: google-ads-python is GONE (It should always be present)" + exit 1 +fi + +# Mock python +cat > "${FAKE_HOME}/bin/python" <> "${TEST_TMP_DIR}/install_log.txt" +else + echo "Mock python: \$*" +fi +EOF +chmod +x "${FAKE_HOME}/bin/python" + +# Mock composer +cat > "${FAKE_HOME}/bin/composer" <> "${TEST_TMP_DIR}/install_log.txt" +EOF +chmod +x "${FAKE_HOME}/bin/composer" + +# Mock bundle +cat > "${FAKE_HOME}/bin/bundle" <> "${TEST_TMP_DIR}/install_log.txt" +EOF +chmod +x "${FAKE_HOME}/bin/bundle" + +# Create dummy composer.json and Gemfile for detection +mkdir -p "${FAKE_PROJECT}/client_libs/google-ads-php" +touch "${FAKE_PROJECT}/client_libs/google-ads-php/composer.json" +mkdir -p "${FAKE_PROJECT}/client_libs/google-ads-ruby" +touch "${FAKE_PROJECT}/client_libs/google-ads-ruby/Gemfile" + + +# --- Test Case 3: Install Deps --- +echo "--- Running install.sh --php --ruby --install-deps ---" +# Clear log +rm -f "${TEST_TMP_DIR}/install_log.txt" + +if ! bash "${SETUP_SCRIPT_PATH}" --php --ruby --install-deps; then + echo "FAIL: install.sh failed with --install-deps" + exit 1 +fi + +LOG_CONTENT=$(cat "${TEST_TMP_DIR}/install_log.txt" 2>/dev/null || true) +echo "Install Log Content:" +echo "$LOG_CONTENT" + +if echo "$LOG_CONTENT" | grep -q "python -m pip install --upgrade google-ads"; then + echo "PASS: python pip install detected" +else + echo "FAIL: python pip install NOT detected" + exit 1 +fi + +if echo "$LOG_CONTENT" | grep -q "composer install"; then + echo "PASS: composer install detected" +else + echo "FAIL: composer install NOT detected" + exit 1 +fi + +if echo "$LOG_CONTENT" | grep -q "bundle install"; then + echo "PASS: bundle install detected" +else + echo "FAIL: bundle install NOT detected" + exit 1 +fi + +# --- Test Case 4: No Install Deps (Verify NO install) --- +echo "--- Running install.sh --php --ruby (NO deps) ---" +rm -f "${TEST_TMP_DIR}/install_log.txt" + +if ! bash "${SETUP_SCRIPT_PATH}" --php --ruby; then + echo "FAIL: install.sh failed without --install-deps" + exit 1 +fi + +if [[ -f "${TEST_TMP_DIR}/install_log.txt" ]]; then + echo "FAIL: install_log.txt should not exist (or be empty) but found content:" + cat "${TEST_TMP_DIR}/install_log.txt" + exit 1 +else + echo "PASS: No install commands executed" +fi + +echo "ALL TESTS PASSED" diff --git a/tests/test_setup.sh b/tests/test_setup.sh deleted file mode 100755 index 2decabb..0000000 --- a/tests/test_setup.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -set -u - -# --- Test Setup --- -TEST_TMP_DIR=$(mktemp -d) -SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/setup.sh" - -echo "Running tests in ${TEST_TMP_DIR}" - -# Cleanup function -cleanup() { - rm -rf "${TEST_TMP_DIR}" -} -trap cleanup EXIT - -# 1. Mock Environment -FAKE_HOME=$(mktemp -d) -FAKE_PROJECT=$(mktemp -d) -echo "FAKE_HOME: ${FAKE_HOME}" -echo "FAKE_PROJECT: ${FAKE_PROJECT}" - -export HOME="${FAKE_HOME}" -mkdir -p "${FAKE_HOME}/bin" -export PATH="${FAKE_HOME}/bin:${PATH}" - -# Cleanup function -cleanup() { - rm -rf "${TEST_TMP_DIR}" - rm -rf "${FAKE_HOME}" - rm -rf "${FAKE_PROJECT}" -} -trap cleanup EXIT - -# Create mock git -cat > "${FAKE_HOME}/bin/git" < /dev/null; then - echo "jq not found, using mock implementation (this test prefers real jq)" - # A simple mock might be too hard for the complex jq command used - echo "FAIL: real jq is required for this test" - exit 1 -fi - -# 2. Setup "Project" in Temp Dir -# setup.sh expects to be run from within the repo -# We will run it from FAKE_PROJECT, pretending it's the repo root -mkdir -p "${FAKE_PROJECT}/.gemini" -echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settings.json" - -# Create dummy directories that setup.sh references -mkdir -p "${FAKE_PROJECT}/api_examples" -mkdir -p "${FAKE_PROJECT}/saved_code" - -# --- Test Case 1: Run setup.sh with --python --- -echo "--- Running setup.sh --python ---" -if ! bash "${SETUP_SCRIPT_PATH}" --python; then - echo "FAIL: setup.sh failed with --python" - exit 1 -fi - -# Check if directory created (mock clone) -if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-python/.git" ]]; then - echo "FAIL: google-ads-python was not 'cloned' (mocked)" - exit 1 -fi - -# Check if settings.json updated -if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then - echo "PASS: settings.json contains google-ads-python" -else - echo "FAIL: settings.json does NOT contain google-ads-python" - cat "${FAKE_PROJECT}/.gemini/settings.json" - exit 1 -fi - -# --- Test Case 2: Run setup.sh --java (update existing check) --- -echo "--- Running setup.sh --java ---" -if ! bash "${SETUP_SCRIPT_PATH}" --java; then - echo "FAIL: setup.sh failed with --java" - exit 1 -fi - -# Check if java directory created -if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-java/.git" ]]; then - echo "FAIL: google-ads-java was not 'cloned'" - exit 1 -fi - -# Check if settings.json has both now (actually jq might rewrite/append, setup.sh overwrites the list based on selection?) -# setup.sh reads: JQ_ARGS arguments based on enabled languages in THAT run. -# It overwrites `context.includeDirectories` with `[$examples, $saved, ...selected_libs]`. -# So if I run with ONLY --java, python might be REMOVED? -# Let's check the script logic: -# `for lang in $ALL_LANGS; do if is_enabled "$lang"; then ... JQ_ARGS+=...; fi; done` -# `JQ_ARRAY_STR="[\$examples, \$saved"` ... `JQ_ARRAY_STR+=", \$lib_$lang"` ... -# Yes, it overwrites with ONLY the currently selected languages + existing examples/saved. -# THIS IS IMPORTANT. Running `setup.sh --java` AFTER `setup.sh --python` removes python from settings if `setup.sh` doesn't read existing settings. -# Wait, `setup.sh` REPLACES the list. -# Let's verify this behavior is what we expect or if it's a "bug" (or feature). -# For now, I test that java IS present. - -if grep -q "google-ads-java" "${FAKE_PROJECT}/.gemini/settings.json"; then - echo "PASS: settings.json contains google-ads-java" -else - echo "FAIL: settings.json does NOT contain google-ads-java" - exit 1 -fi - -# Verify Python is gone (based on current implementation analysis) -if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then - echo "INFO: google-ads-python is STILL present (Accumulative?)" -else - echo "INFO: google-ads-python is GONE (Expected per current logic if overwriting)" -fi - -echo "ALL TESTS PASSED" diff --git a/tests/test_uninstall.ps1 b/tests/test_uninstall.ps1 new file mode 100644 index 0000000..ac04a9e --- /dev/null +++ b/tests/test_uninstall.ps1 @@ -0,0 +1,83 @@ +<# +.SYNOPSIS + Test script for uninstall.ps1 +#> + +$ErrorActionPreference = "Stop" + +# --- Test Setup --- +$TestTmpDir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName() +New-Item -ItemType Directory -Force -Path $TestTmpDir | Out-Null +$UninstallScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "uninstall.ps1") + +Write-Host "Running tests in $TestTmpDir" + +# Cleanup +function Cleanup { + Remove-Item -Recurse -Force $TestTmpDir -ErrorAction SilentlyContinue +} + +try { + # 1. Mock Environment + $FakeHome = Join-Path $TestTmpDir "fake_home" + $MockParentDir = Join-Path $TestTmpDir "mock_parent" + $FakeProject = Join-Path $MockParentDir "google-ads-api-developer-assistant" + $FakeBin = Join-Path $FakeHome "bin" + + New-Item -ItemType Directory -Force -Path $FakeBin | Out-Null + New-Item -ItemType Directory -Force -Path $FakeProject | Out-Null + + # Add FakeBin to PATH + $env:PATH = "$FakeBin$([System.IO.Path]::PathSeparator)$env:PATH" + + # Create Mock Scripts + # git mock + Set-Content -Path (Join-Path $FakeBin "git") -Value "#!/bin/bash`nif [[ `"`$1`" == `"rev-parse`" ]]; then echo `"$FakeProject`"; else echo `"Mock git`"; fi" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "git") } + + # gemini mock + $UninstallLog = Join-Path $TestTmpDir "uninstall_log.txt" + Set-Content -Path (Join-Path $FakeBin "gemini") -Value "#!/bin/bash`necho `"MOCK: gemini `$*`" >> `"$UninstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "gemini") } + + # 2. Setup Fake Project + Set-Content -Path (Join-Path $FakeProject "some_file.txt") -Value "test" + + # --- Test Case 1: Run uninstall.ps1 with 'n' --- + Write-Host "--- Running uninstall.ps1 with 'n' (Cancellation) ---" + # We use a temporary input file to simulate Read-Host input + # Actually, we can use a string array and pipe it + $Result = "n" | pwsh -File $UninstallScriptPath + + if (Test-Path $FakeProject) { + Write-Host "PASS: Cancellation respected" + } else { + throw "FAIL: project directory was deleted on cancellation" + } + + # --- Test Case 2: Run uninstall.ps1 with 'Y' --- + Write-Host "--- Running uninstall.ps1 with 'Y' (Success) ---" + $Result = "Y" | pwsh -File $UninstallScriptPath + + if (Test-Path $FakeProject) { + throw "FAIL: project directory still exists" + } else { + Write-Host "PASS: Directory removed" + } + + if (Get-Content $UninstallLog | Select-String "extensions uninstall google-ads-api-developer-assistant") { + Write-Host "PASS: gemini extensions uninstall called" + } else { + throw "FAIL: gemini extensions uninstall NOT called" + } + + Write-Host "ALL POWERSHELL UNINSTALL TESTS PASSED" + +} +catch { + Write-Error "Test Failed: $_" + exit 1 +} +finally { + Cleanup +} diff --git a/tests/test_uninstall.sh b/tests/test_uninstall.sh new file mode 100644 index 0000000..6d7f5aa --- /dev/null +++ b/tests/test_uninstall.sh @@ -0,0 +1,94 @@ +#!/bin/bash +set -u + +# --- Test Setup --- +TEST_TMP_DIR=$(mktemp -d) +UNINSTALL_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/uninstall.sh" + +echo "Running tests in ${TEST_TMP_DIR}" + +# 1. Mock Environment +FAKE_HOME=$(mktemp -d) +# We create a fake project directory inside another temp dir to simulate deletion +MOCK_PARENT_DIR=$(mktemp -d) +FAKE_PROJECT="${MOCK_PARENT_DIR}/google-ads-api-developer-assistant" +mkdir -p "${FAKE_PROJECT}" + +echo "FAKE_HOME: ${FAKE_HOME}" +echo "FAKE_PROJECT: ${FAKE_PROJECT}" + +export HOME="${FAKE_HOME}" +mkdir -p "${FAKE_HOME}/bin" +export PATH="${FAKE_HOME}/bin:${PATH}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" + rm -rf "${FAKE_HOME}" + rm -rf "${MOCK_PARENT_DIR}" +} +trap cleanup EXIT + +# Create mock git +cat > "${FAKE_HOME}/bin/git" < "${FAKE_HOME}/bin/gemini" <> "${TEST_TMP_DIR}/uninstall_log.txt" +EOF +chmod +x "${FAKE_HOME}/bin/gemini" + +# 2. Setup "Project" in Mock Dir +cd "${FAKE_PROJECT}" +touch "some_file.txt" +mkdir "some_dir" + +# --- Test Case 1: Run uninstall.sh with 'n' --- +echo "--- Running uninstall.sh with 'n' (Cancellation) ---" +if ! echo "n" | bash "${UNINSTALL_SCRIPT_PATH}"; then + echo "FAIL: uninstall.sh failed on cancellation check" + exit 1 +fi + +if [[ ! -d "${FAKE_PROJECT}" ]]; then + echo "FAIL: project directory was deleted on cancellation" + exit 1 +fi +echo "PASS: Cancellation respected" + +# --- Test Case 2: Run uninstall.sh with 'Y' --- +echo "--- Running uninstall.sh with 'Y' (Success) ---" +# We need to run it such that it can delete the directory it's "in" +# The script calls 'cd ${parent_dir}' before 'rm -rf' +if ! echo "Y" | bash "${UNINSTALL_SCRIPT_PATH}"; then + echo "FAIL: uninstall.sh failed" + exit 1 +fi + +# Check if directory deleted +if [[ -d "${FAKE_PROJECT}" ]]; then + echo "FAIL: project directory still exists" + exit 1 +fi +echo "PASS: Directory removed" + +# Check if gemini uninstall was called +if grep -q "gemini extensions uninstall google-ads-api-developer-assistant" "${TEST_TMP_DIR}/uninstall_log.txt"; then + echo "PASS: gemini extensions uninstall called" +else + echo "FAIL: gemini extensions uninstall NOT called" + cat "${TEST_TMP_DIR}/uninstall_log.txt" + exit 1 +fi + +echo "ALL BASH UNINSTALL TESTS PASSED" diff --git a/tests/test_update.sh b/tests/test_update.sh new file mode 100644 index 0000000..3273cb1 --- /dev/null +++ b/tests/test_update.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. + +# Description: +# Integration tests for update.sh. + +set -eu + +# --- Environment Setup --- +# Create a temporary directory for tests +TEST_DIR=$(mktemp -d "/tmp/test_update_sh_XXXXXX") +trap 'rm -rf "${TEST_DIR}"' EXIT + +echo "Running tests in ${TEST_DIR}" + +FAKE_HOME="${TEST_DIR}/fake_home" +FAKE_PROJECT="${TEST_DIR}/fake_project" +mkdir -p "${FAKE_HOME}/bin" +mkdir -p "${FAKE_PROJECT}/.gemini" + +# Resolve real script path before mocking git +REAL_UPDATE_SCRIPT="$(git rev-parse --show-toplevel)/update.sh" + +# Mock git +cat > "${FAKE_HOME}/bin/git" < "${FAKE_HOME}/bin/jq" < "${FAKE_PROJECT}/.gemini/settings.json" +mkdir -p "${FAKE_PROJECT}/client_libs/google-ads-python/.git" + +# Copy the real update.sh for testing +UPDATE_SCRIPT_PATH="${FAKE_PROJECT}/update.sh" +cp "${REAL_UPDATE_SCRIPT}" "${UPDATE_SCRIPT_PATH}" +chmod +x "${UPDATE_SCRIPT_PATH}" + +# --- Test Case 1: Run update.sh (no flags) --- +echo "--- Test Case 1: Default Update ---" +(cd "${FAKE_PROJECT}" && bash update.sh) + +# Verify python was "updated" +# (Mock pull output would be in stdout, but the script continues if it works) + +# --- Test Case 2: Run update.sh --php (Add new library) --- +echo "--- Test Case 2: Add PHP library ---" +(cd "${FAKE_PROJECT}" && bash update.sh --php) + +# Check if php cloned +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-php/.git" ]]; then + echo "FAIL: google-ads-php was not cloned" + exit 1 +fi + +# Check if settings.json updated +if /usr/bin/jq -r '.context.includeDirectories[]' "${FAKE_PROJECT}/.gemini/settings.json" | grep -q "google-ads-php"; then + echo "PASS: settings.json updated with php path" +else + echo "FAIL: settings.json missing php path" + cat "${FAKE_PROJECT}/.gemini/settings.json" + exit 1 +fi + +# --- Test Case 3: Run update.sh --php (Already exists) --- +echo "--- Test Case 3: Update existing PHP library ---" +# We just run it again, it should not clone but pull (mock handled) +(cd "${FAKE_PROJECT}" && bash update.sh --php) +echo "PASS: update.sh --php ran successfully with existing lib" + +echo "ALL TESTS PASSED" diff --git a/tests/test_update_logic.sh b/tests/test_update_logic.sh deleted file mode 100755 index 82cf1f7..0000000 --- a/tests/test_update_logic.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/bash -set -u - -# --- Test Update Logic --- -TEST_TMP_DIR=$(mktemp -d) -UPDATE_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/update.sh" - -echo "Running tests in ${TEST_TMP_DIR}" - -# Cleanup function -cleanup() { - rm -rf "${TEST_TMP_DIR}" -} -trap cleanup EXIT - -# 1. Mock Environment -FAKE_HOME=$(mktemp -d) -FAKE_PROJECT=$(mktemp -d) -echo "FAKE_HOME: ${FAKE_HOME}" -echo "FAKE_PROJECT: ${FAKE_PROJECT}" - -export HOME="${FAKE_HOME}" -mkdir -p "${FAKE_HOME}/bin" -export PATH="${FAKE_HOME}/bin:${PATH}" - -# Cleanup function (updated) -cleanup() { - rm -rf "${TEST_TMP_DIR}" - rm -rf "${FAKE_HOME}" - rm -rf "${FAKE_PROJECT}" -} -trap cleanup EXIT - -# Create mock git -cat > "${FAKE_HOME}/bin/git" < ".gemini/settings.json" - fi - # We don't touch customer_id.txt in repo usually, or maybe we do? - # If repo has customer_id.txt, it might overwrite. - if [[ -f "customer_id.txt" ]]; then - echo "REPO_CUSTOMER_ID" > "customer_id.txt" - fi -elif [[ "\$1" == "ls-files" ]]; then - exit 0 # everything matches for now -elif [[ "\$1" == "checkout" ]]; then - echo "Mock checkout \$2" - # Actually restore the file to "HEAD" state? - # logic: if git ls-files ...; then git checkout ...; fi - # We can just ignore checkout for this test as we want to test the MERGE/RESTORE logic primarily. -else - echo "Mock git: command \$* ignored" -fi -EOF -chmod +x "${FAKE_HOME}/bin/git" - -# Create mock jq if not present -if ! command -v jq &> /dev/null; then - echo "FAIL: real jq is required for this test" - exit 1 -fi - -# 2. Setup "Project" in Temp Dir -mkdir -p "${FAKE_PROJECT}/.gemini" -SETTINGS_JSON="${FAKE_PROJECT}/.gemini/settings.json" -CUSTOMER_ID_FILE="${FAKE_PROJECT}/customer_id.txt" - -# Initial "User" State -echo '{"user_setting": true, "common_setting": "user_value", "context": {"includeDirectories": []}}' > "${SETTINGS_JSON}" -echo "USER_CUSTOMER_ID" > "${CUSTOMER_ID_FILE}" - -echo "Initial settings:" -cat "${SETTINGS_JSON}" -echo "Initial customer_id:" -cat "${CUSTOMER_ID_FILE}" - -# 3. Run update.sh from within FAKE_PROJECT (update.sh expects to be in repo) -cd "${FAKE_PROJECT}" -echo "--- Running update.sh ---" -if ! bash "${UPDATE_SCRIPT_PATH}"; then - echo "FAIL: update.sh failed" - exit 1 -fi - -# 4. Verify Results -echo "Final settings:" -cat "${SETTINGS_JSON}" -echo "Final customer_id:" -cat "${CUSTOMER_ID_FILE}" - -# Verify Settings -USER_VAL=$(jq -r .user_setting "${SETTINGS_JSON}") -REPO_VAL=$(jq -r .repo_setting "${SETTINGS_JSON}") -COMMON_VAL=$(jq -r .common_setting "${SETTINGS_JSON}") - -if [[ "$USER_VAL" == "true" ]] && [[ "$REPO_VAL" == "true" ]] && [[ "$COMMON_VAL" == "user_value" ]]; then - echo "PASS: Settings merged correctly" -else - echo "FAIL: Settings merge incorrect" - exit 1 -fi - -# Verify Customer ID -CID_VAL=$(cat "${CUSTOMER_ID_FILE}") -if [[ "$CID_VAL" == "USER_CUSTOMER_ID" ]]; then - echo "PASS: Customer ID preserved" -else - echo "FAIL: Customer ID NOT preserved (Got: $CID_VAL)" - exit 1 -fi - -echo "ALL TESTS PASSED" diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..133c4d4 --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Uninstalls the Google Ads API Developer Assistant extension and removes the project directory. +#> + +$ErrorActionPreference = "Stop" + +# Determine project root +try { + $ProjectDirAbs = git rev-parse --show-toplevel 2>$null + if (-not $ProjectDirAbs) { throw "Not in a git repo" } + $ProjectDirAbs = (Get-Item -LiteralPath $ProjectDirAbs).FullName +} +catch { + Write-Error "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +} + +Write-Host "This will uninstall the Google Ads API Developer Assistant extension" +Write-Host "and DELETE the entire directory: $ProjectDirAbs" +$Confirm = Read-Host "Are you sure you want to proceed? (Y/n)" + +if ($Confirm -notmatch "^[Yy]$") { + Write-Host "Uninstallation cancelled." + exit 0 +} + +if (Get-Command gemini -ErrorAction SilentlyContinue) { + Write-Host "Uninstalling Gemini extension..." + try { + & gemini extensions uninstall "google-ads-api-developer-assistant" 2>&1 | Out-Null + } + catch { + Write-Warning "Extension was not registered or failed to uninstall. Continuing..." + } +} +else { + Write-Warning "'gemini' command not found. Skipping extension uninstallation." +} + +Write-Host "Removing project directory: $ProjectDirAbs..." +# Move out of the directory to allow deletion +Set-Location (Split-Path $ProjectDirAbs) +Remove-Item -Recurse -Force -LiteralPath $ProjectDirAbs + +Write-Host "Uninstallation complete." diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..3350339 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Copyright 2025 Google LLC +# +# 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 +# +# https://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. + +# Description: +# This script uninstalls the Google Ads API Developer Assistant extension +# and removes the local project directory. + +set -eu + +# Determine project root +if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then + echo "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +fi + +echo "This will uninstall the Google Ads API Developer Assistant extension" +echo "and DELETE the entire directory: ${PROJECT_DIR_ABS}" +read -p "Are you sure you want to proceed? (Y/n): " confirm + +if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then + echo "Uninstallation cancelled." + exit 0 +fi + +if command -v gemini &> /dev/null; then + echo "Uninstalling Gemini extension..." + gemini extensions uninstall "google-ads-api-developer-assistant" || echo "WARN: Extension was not registered or failed to uninstall. Continuing..." +else + echo "WARN: 'gemini' command not found. Skipping extension uninstallation." +fi + +echo "Removing project directory: ${PROJECT_DIR_ABS}..." +# Use a temporary script to remove the directory because the current script is inside it +# Actually on Linux we can usually delete the script while it's running, but to be safe: +parent_dir=$(dirname "${PROJECT_DIR_ABS}") +project_name=$(basename "${PROJECT_DIR_ABS}") + +cd "${parent_dir}" +rm -rf "${project_name}" + +echo "Uninstallation complete." diff --git a/update.ps1 b/update.ps1 index 8178a4d..3cb1abc 100644 --- a/update.ps1 +++ b/update.ps1 @@ -12,6 +12,36 @@ .\update.ps1 #> +param( + [switch]$Python, + [switch]$Php, + [switch]$Ruby, + [switch]$Java, + [switch]$Dotnet +) + +function Get-RepoUrl { + param($Lang) + switch ($Lang) { + "python" { return "https://github.com/googleads/google-ads-python.git" } + "php" { return "https://github.com/googleads/google-ads-php.git" } + "ruby" { return "https://github.com/googleads/google-ads-ruby.git" } + "java" { return "https://github.com/googleads/google-ads-java.git" } + "dotnet" { return "https://github.com/googleads/google-ads-dotnet.git" } + } +} + +function Get-RepoName { + param($Lang) + switch ($Lang) { + "python" { return "google-ads-python" } + "php" { return "google-ads-php" } + "ruby" { return "google-ads-ruby" } + "java" { return "google-ads-java" } + "dotnet" { return "google-ads-dotnet" } + } +} + $ErrorActionPreference = "Stop" # --- Dependency Check --- @@ -161,12 +191,57 @@ finally { } +# --- Handle Specific Library Additions --- +$SpecifiedLangs = @() +if ($Python) { $SpecifiedLangs += "python" } +if ($Php) { $SpecifiedLangs += "php" } +if ($Ruby) { $SpecifiedLangs += "ruby" } +if ($Java) { $SpecifiedLangs += "java" } +if ($Dotnet) { $SpecifiedLangs += "dotnet" } + +if ($SpecifiedLangs.Count -gt 0) { + $DefaultParentDir = Join-Path $ProjectDirAbs "client_libs" + + foreach ($Lang in $SpecifiedLangs) { + $RepoUrl = Get-RepoUrl $Lang + $RepoName = Get-RepoName $Lang + $LibPath = Join-Path $DefaultParentDir $RepoName + + if (-not (Test-Path -LiteralPath $LibPath)) { + Write-Host "Library $RepoName not found. Cloning into $LibPath..." + New-Item -ItemType Directory -Force -Path $DefaultParentDir | Out-Null + git clone $RepoUrl $LibPath + if ($LASTEXITCODE -ne 0) { throw "Failed to clone $RepoUrl" } + + # Add to settings.json if not present + if (Test-Path -LiteralPath $SettingsFile) { + # Ensure we have the most up to date settings after possible git pull + $SettingsJson = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json + $AbsPath = (Get-Item -LiteralPath $LibPath).FullName + + if ($null -eq $SettingsJson.context) { + $SettingsJson | Add-Member -MemberType NoteProperty -Name "context" -Value @{ includeDirectories = @() } + } + if ($null -eq $SettingsJson.context.includeDirectories) { + $SettingsJson.context | Add-Member -MemberType NoteProperty -Name "includeDirectories" -Value @() + } + + if (-not ($SettingsJson.context.includeDirectories -contains $AbsPath)) { + Write-Host "Registering $AbsPath in $SettingsFile..." + $SettingsJson.context.includeDirectories += $AbsPath + $SettingsJson | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsFile -Encoding UTF8 + } + } + } + } +} + # --- Locate and Update Client Libraries --- $SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" if (-not (Test-Path -LiteralPath $SettingsFile)) { Write-Error "ERROR: Settings file not found: $SettingsFile" - Write-Error "Please run setup.ps1 first." + Write-Error "Please run install.ps1 first." exit 1 } diff --git a/update.sh b/update.sh index 0a07236..fefc20a 100755 --- a/update.sh +++ b/update.sh @@ -41,14 +41,94 @@ usage() { echo "" echo " Options:" echo " -h, --help Show this help message and exit" + echo " --python Ensure google-ads-python is present and updated" + echo " --php Ensure google-ads-php is present and updated" + echo " --ruby Ensure google-ads-ruby is present and updated" + echo " --java Ensure google-ads-java is present and updated" + echo " --dotnet Ensure google-ads-dotnet is present and updated" + echo "" + echo " If flags are provided, the script will ensure those libraries are installed" + echo " (cloned) and registered in .gemini/settings.json if they weren't already." echo "" } +# --- Defaults --- +INSTALL_PYTHON=false +INSTALL_PHP=false +INSTALL_RUBY=false +INSTALL_JAVA=false +INSTALL_DOTNET=false +ANY_SELECTED=false + # --- Argument Parsing --- -if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then - usage - exit 0 -fi +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --python) + INSTALL_PYTHON=true + ANY_SELECTED=true + shift + ;; + --php) + INSTALL_PHP=true + ANY_SELECTED=true + shift + ;; + --ruby) + INSTALL_RUBY=true + ANY_SELECTED=true + shift + ;; + --java) + INSTALL_JAVA=true + ANY_SELECTED=true + shift + ;; + --dotnet) + INSTALL_DOTNET=true + ANY_SELECTED=true + shift + ;; + *) + # Ignore unknown options or handle them + shift + ;; + esac +done + +# Helper functions for repo info (Matching setup.sh) +get_repo_url() { + case "$1" in + python) echo "https://github.com/googleads/google-ads-python.git" ;; + php) echo "https://github.com/googleads/google-ads-php.git" ;; + ruby) echo "https://github.com/googleads/google-ads-ruby.git" ;; + java) echo "https://github.com/googleads/google-ads-java.git" ;; + dotnet) echo "https://github.com/googleads/google-ads-dotnet.git" ;; + esac +} + +get_repo_name() { + case "$1" in + python) echo "google-ads-python" ;; + php) echo "google-ads-php" ;; + ruby) echo "google-ads-ruby" ;; + java) echo "google-ads-java" ;; + dotnet) echo "google-ads-dotnet" ;; + esac +} + +is_enabled() { + case "$1" in + python) [[ "${INSTALL_PYTHON}" == "true" ]] ;; + php) [[ "${INSTALL_PHP}" == "true" ]] ;; + ruby) [[ "${INSTALL_RUBY}" == "true" ]] ;; + java) [[ "${INSTALL_JAVA}" == "true" ]] ;; + dotnet) [[ "${INSTALL_DOTNET}" == "true" ]] ;; + esac +} # --- Dependency Check --- if ! command -v jq &> /dev/null; then @@ -151,12 +231,50 @@ fi echo "Successfully updated google-ads-api-developer-assistant." +# --- Handle Specific Library Additions --- +readonly ALL_LANGS="python php ruby java dotnet" +readonly DEFAULT_PARENT_DIR="${PROJECT_DIR_ABS}/client_libs" + +for lang in $ALL_LANGS; do + if is_enabled "$lang"; then + repo_url=$(get_repo_url "$lang") + repo_name=$(get_repo_name "$lang") + lib_path="${DEFAULT_PARENT_DIR}/${repo_name}" + + if [[ ! -d "${lib_path}" ]]; then + echo "Library ${repo_name} not found. Cloning into ${lib_path}..." + mkdir -p "${DEFAULT_PARENT_DIR}" + if ! git clone "${repo_url}" "${lib_path}"; then + err "ERROR: Failed to clone ${repo_url}" + exit 1 + fi + + # Add to settings.json if not present + if [[ -f "${SETTINGS_JSON}" ]]; then + # Ensure path is absolute for settings.json + ABS_PATH=$(realpath "${lib_path}" 2>/dev/null || echo "${lib_path}") + echo "Registering ${ABS_PATH} in ${SETTINGS_JSON}..." + if ! jq --arg new_path "${ABS_PATH}" ' + if (.context.includeDirectories | any(. == $new_path)) then + . + else + .context.includeDirectories += [$new_path] + end' "${SETTINGS_JSON}" > "${SETTINGS_JSON}.tmp"; then + err "ERROR: Failed to update ${SETTINGS_JSON}" + exit 1 + fi + mv "${SETTINGS_JSON}.tmp" "${SETTINGS_JSON}" + fi + fi + fi +done + # --- Locate and Update Client Libraries --- readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" if [[ ! -f "${SETTINGS_FILE}" ]]; then err "ERROR: Settings file not found: ${SETTINGS_FILE}" - err "Please run setup.sh first." + err "Please run install.sh first." exit 1 fi From 96bbf32b9d83824df2d5d41ea21a2a3c347e3707 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 26 Feb 2026 16:53:50 +0000 Subject: [PATCH 48/61] anonymize customer id --- customer_id.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/customer_id.txt b/customer_id.txt index 92ef13c..93acd6e 100644 --- a/customer_id.txt +++ b/customer_id.txt @@ -1 +1 @@ -customer_id: 8466202666 +customer_id: 12345678 From 0774bed66b90f42195af42db4fdab0b4ea40d121 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 26 Feb 2026 11:55:39 -0500 Subject: [PATCH 49/61] Fixed example test --- api_examples/tests/test_add_campaign_with_date_times.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api_examples/tests/test_add_campaign_with_date_times.py b/api_examples/tests/test_add_campaign_with_date_times.py index 5aa983f..ac62f97 100644 --- a/api_examples/tests/test_add_campaign_with_date_times.py +++ b/api_examples/tests/test_add_campaign_with_date_times.py @@ -18,11 +18,10 @@ import sys import os -# Add the parent directory to sys.path to import the example script -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# Add the project root to sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) # Import the main function from the example script -# We need to import it as a module to mock it properly from api_examples import add_campaign_with_date_times class TestAddCampaignWithDateTimes(unittest.TestCase): From a3803e15abff0565fc9f18f5d33d69584d71ae8a Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 26 Feb 2026 12:02:37 -0500 Subject: [PATCH 50/61] Update ChangeLog --- ChangeLog | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ChangeLog b/ChangeLog index 5c27090..2bff13e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,7 +1,3 @@ -<<<<<<< HEAD -* 1.5.1 -- Added support for v23. -======= * 2.0.0 - Hierachical context file for conversions troubleshooting. - Added Conversion Troubleshooting & Diagnostics functionality (api_examples/collect_conversions_troubleshooting_data.py). From 1b25b1d9f79d4db4649aa1d9a5965b6e651804c5 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 26 Feb 2026 12:04:30 -0500 Subject: [PATCH 51/61] Delete .bak files --- .gemini/settings.json.bak | 29 ---------------------- .gemini/settings.json.bak.2 | 48 ------------------------------------- 2 files changed, 77 deletions(-) delete mode 100644 .gemini/settings.json.bak delete mode 100644 .gemini/settings.json.bak.2 diff --git a/.gemini/settings.json.bak b/.gemini/settings.json.bak deleted file mode 100644 index e8eaaaa..0000000 --- a/.gemini/settings.json.bak +++ /dev/null @@ -1,29 +0,0 @@ -{ - "context": { - "fileFiltering": { - "enableRecursiveFileSearch": false - } - }, - "ui": { - "theme": "Default Light" - }, - "general": { - "preferredEditor": "vim" - }, - "useSmartEdit": true, - "tools": { - "allowed": [ - "read_file", - "read_many_files", - "list_directory", - "search_file_content", - "glob", - "web_fetch", - "google_web_search", - "save_memory", - "read_document" - ], - "enableHooks": true - }, - "enableHooks": true -} diff --git a/.gemini/settings.json.bak.2 b/.gemini/settings.json.bak.2 deleted file mode 100644 index c04b78a..0000000 --- a/.gemini/settings.json.bak.2 +++ /dev/null @@ -1,48 +0,0 @@ -{ - "ui": { - "accessibility": { - "disableLoadingPhrases": true, - "enableLoadingPhrases": false - } - }, - "context": { - "includeDirectories": [ - "/path/google-ads-api-developer-assistant/api_examples", - "/path/google-ads-api-developer-assistant/saved_code", - "/path/google-ads-api-developer-assistant/client_libs/google-ads-python" - ] - }, - "tools": { - "enableHooks": true - }, - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [ - { - "name": "session-start-configure", - "type": "command", - "command": "python3 .gemini/hooks/SessionStart/custom_config_python.py", - "description": "Configure Google Ads API client to use interceptors", - "timeout": 30000 - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "exit", - "hooks": [ - { - "name": "session-end-cleanup", - "type": "command", - "command": "python3 .gemini/hooks/SessionEnd/cleanup_config.py", - "description": "Cleanup /config", - "timeout": 30000 - } - ] - } - ] - } -} \ No newline at end of file From e3e7c8403044868d9fafc50763bd2ff2b08be61f Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 27 Feb 2026 17:37:19 -0500 Subject: [PATCH 52/61] Added test for hook files --- ChangeLog | 7 +- tests/test_cleanup_config.py | 61 +++++++++++++++++ tests/test_custom_config.py | 97 ++++++++++++++++++++++++++++ tests/test_service_account_config.py | 68 +++++++++++++++++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 tests/test_cleanup_config.py create mode 100644 tests/test_custom_config.py create mode 100644 tests/test_service_account_config.py diff --git a/ChangeLog b/ChangeLog index 2bff13e..dc2f91a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,9 @@ +* 2.0.1 +- Added unit tests for custom_config.py hook script. +- Added unit tests for cleanup_config.py hook script. +- Verified project tests pass by isolating from client_libs collection errors. +- Fixed linting errors in new test files. + * 2.0.0 - Hierachical context file for conversions troubleshooting. - Added Conversion Troubleshooting & Diagnostics functionality (api_examples/collect_conversions_troubleshooting_data.py). @@ -18,7 +24,6 @@ - Changed name of setup files to install and provided an uninstall procedure. - Added additional rules for GAQL edge cases to GEMINI.md. - Added command conversions_support_data. ->>>>>>> v1.6.0 * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/tests/test_cleanup_config.py b/tests/test_cleanup_config.py new file mode 100644 index 0000000..b55285f --- /dev/null +++ b/tests/test_cleanup_config.py @@ -0,0 +1,61 @@ +import sys +import os +import unittest +from unittest.mock import patch + +# Add the project root to sys.path +script_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.abspath(os.path.join(script_dir, "..")) +hooks_dir = os.path.join(project_root, ".gemini/hooks") +sys.path.append(hooks_dir) + +import cleanup_config # noqa: E402 + +class TestCleanupConfig(unittest.TestCase): + + @patch("os.path.exists") + @patch("os.listdir") + @patch("os.path.isfile") + @patch("os.path.isdir") + @patch("os.unlink") + @patch("shutil.rmtree") + def test_cleanup_success(self, mock_rmtree, mock_unlink, mock_isdir, mock_isfile, mock_listdir, mock_exists): + # Setup mocks + mock_exists.return_value = True + mock_listdir.return_value = ["file1.txt", "dir1", ".gitkeep"] + + # Define side effects for isfile and isdir + def is_file_side_effect(path): + return "file1.txt" in path + def is_dir_side_effect(path): + return "dir1" in path + + mock_isfile.side_effect = is_file_side_effect + mock_isdir.side_effect = is_dir_side_effect + + cleanup_config.cleanup() + + # Verify calls + mock_unlink.assert_called_once() + self.assertIn("file1.txt", mock_unlink.call_args[0][0]) + + mock_rmtree.assert_called_once() + self.assertIn("dir1", mock_rmtree.call_args[0][0]) + + # Verify .gitkeep was NOT touched + for call in mock_unlink.call_args_list: + self.assertNotIn(".gitkeep", call[0][0]) + + @patch("os.path.exists") + def test_cleanup_no_config_dir(self, mock_exists): + mock_exists.return_value = False + with patch("sys.stderr") as mock_stderr: + cleanup_config.cleanup() + mock_stderr.write.assert_called() + # Should not call listdir if it doesn't exist + with patch("os.listdir") as mock_listdir: + cleanup_config.cleanup() + mock_listdir.assert_not_called() + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_custom_config.py b/tests/test_custom_config.py new file mode 100644 index 0000000..6ded6bf --- /dev/null +++ b/tests/test_custom_config.py @@ -0,0 +1,97 @@ +import sys +import os +import unittest +from unittest.mock import patch, MagicMock, mock_open + +# Add the project root to sys.path so we can import the hook scripts +script_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.abspath(os.path.join(script_dir, "..")) +hooks_dir = os.path.join(project_root, ".gemini/hooks") +sys.path.append(hooks_dir) + +import custom_config # noqa: E402 + +class TestCustomConfig(unittest.TestCase): + + def test_get_version_success(self): + with patch("subprocess.run") as mocked_run: + mocked_run.return_value = MagicMock(stdout="2.1.0\n", check=True) + version = custom_config.get_version("dummy_script.py") + self.assertEqual(version, "2.1.0") + mocked_run.assert_called_once() + + def test_get_version_failure(self): + with patch("subprocess.run") as mocked_run: + mocked_run.side_effect = Exception("failed") + version = custom_config.get_version("dummy_script.py") + self.assertEqual(version, "2.0.0") # Fallback + + def test_parse_ruby_config(self): + content = """ + c.developer_token = 'token123' + c.client_id = "id456" + c.client_secret = 'secret789' + """ + with patch("builtins.open", mock_open(read_data=content)): + data = custom_config.parse_ruby_config("dummy.rb") + self.assertEqual(data["developer_token"], "token123") + self.assertEqual(data["client_id"], "id456") + self.assertEqual(data["client_secret"], "secret789") + + def test_parse_ini_config(self): + content = "[DEFAULT]\ndeveloper_token = token123\nclient_id = 'id456'\n" + with patch("builtins.open", mock_open(read_data=content)): + data = custom_config.parse_ini_config("dummy.ini") + self.assertEqual(data["developer_token"], "token123") + self.assertEqual(data["client_id"], "id456") + + def test_parse_properties_config(self): + content = "api.googleads.developerToken=token123\napi.googleads.clientId=id456\n" + with patch("builtins.open", mock_open(read_data=content)): + data = custom_config.parse_properties_config("dummy.properties") + self.assertEqual(data["developer_token"], "token123") + self.assertEqual(data["client_id"], "id456") + + def test_write_yaml_config_oauth2(self): + data = { + "developer_token": "token123", + "client_id": "id456", + "client_secret": "secret789", + "refresh_token": "refresh000" + } + with patch("builtins.open", mock_open()) as mocked_file: + success = custom_config.write_yaml_config(data, "dummy.yaml", "2.1.0") + self.assertTrue(success) + handle = mocked_file() + handle.write.assert_any_call("developer_token: token123\n") + handle.write.assert_any_call("client_id: id456\n") + handle.write.assert_any_call("gaada: \"2.1.0\"\n") + + def test_write_yaml_config_service_account(self): + data = { + "developer_token": "token123", + "json_key_file_path": "/path/to/key.json", + "impersonated_email": "user@example.com" + } + with patch("builtins.open", mock_open()) as mocked_file: + success = custom_config.write_yaml_config(data, "dummy.yaml", "2.1.0") + self.assertTrue(success) + handle = mocked_file() + handle.write.assert_any_call("json_key_file_path: /path/to/key.json\n") + handle.write.assert_any_call("impersonated_email: user@example.com\n") + # Verify client_id is NOT written + for call in handle.write.call_args_list: + self.assertNotIn("client_id:", call[0][0]) + + def test_configure_language(self): + with patch("os.path.exists", return_value=True), \ + patch("shutil.copy2") as mocked_copy, \ + patch("builtins.open", mock_open()) as mocked_file: + success = custom_config.configure_language("Python", "home.yaml", "target.yaml", "2.1.0", is_python=True) + self.assertTrue(success) + mocked_copy.assert_called_once_with("home.yaml", "target.yaml") + handle = mocked_file() + handle.write.assert_called_with('\ngaada: "2.1.0"\n') + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service_account_config.py b/tests/test_service_account_config.py new file mode 100644 index 0000000..3da2779 --- /dev/null +++ b/tests/test_service_account_config.py @@ -0,0 +1,68 @@ +import sys +import os +import unittest +from unittest.mock import patch, MagicMock + +# Add .gemini/hooks to sys.path +script_dir = os.path.dirname(os.path.abspath(__file__)) +hooks_dir = os.path.join(script_dir, "../.gemini/hooks") +sys.path.append(hooks_dir) + +import custom_config + +class TestCustomConfig(unittest.TestCase): + + def test_parse_ruby_config_service_account(self): + content = """ + GoogleAds::Config.new do |c| + c.developer_token = 'TEST_TOKEN' + c.json_key_file_path = '/path/to/key.json' + c.impersonated_email = 'user@example.com' + end + """ + with patch("builtins.open", unittest.mock.mock_open(read_data=content)): + data = custom_config.parse_ruby_config("dummy.rb") + self.assertEqual(data["json_key_file_path"], "/path/to/key.json") + self.assertEqual(data["impersonated_email"], "user@example.com") + + def test_parse_ini_config_service_account(self): + content = """ +[GOOGLE_ADS] +developer_token = "TEST_TOKEN" +json_key_file_path = "/path/to/key.json" +impersonated_email = "user@example.com" + """ + with patch("builtins.open", unittest.mock.mock_open(read_data=content)): + data = custom_config.parse_ini_config("dummy.ini") + self.assertEqual(data["json_key_file_path"], "/path/to/key.json") + self.assertEqual(data["impersonated_email"], "user@example.com") + + def test_parse_properties_config_service_account(self): + content = """ +api.googleads.developerToken=TEST_TOKEN +api.googleads.oAuth2SecretsJsonPath=/path/to/key.json +api.googleads.oAuth2PrnEmail=user@example.com + """ + with patch("builtins.open", unittest.mock.mock_open(read_data=content)): + data = custom_config.parse_properties_config("dummy.properties") + self.assertEqual(data["json_key_file_path"], "/path/to/key.json") + self.assertEqual(data["impersonated_email"], "user@example.com") + + def test_write_yaml_config_service_account(self): + data = { + "developer_token": "TEST_TOKEN", + "json_key_file_path": "/path/to/key.json", + "impersonated_email": "user@example.com" + } + with patch("builtins.open", unittest.mock.mock_open()) as mocked_file: + custom_config.write_yaml_config(data, "dummy.yaml", "2.0.0") + mocked_file.assert_called_once_with("dummy.yaml", "w") + handle = mocked_file() + # Verify json_key_file_path is written + handle.write.assert_any_call("json_key_file_path: /path/to/key.json\n") + # Verify client_id is NOT written + for call in handle.write.call_args_list: + self.assertNotIn("client_id:", call[0][0]) + +if __name__ == "__main__": + unittest.main() From 278bc052a22e1604ec14324d41f3728f3de667b8 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 5 Mar 2026 12:49:52 -0500 Subject: [PATCH 53/61] Release of v2.1.0 --- .gemini/commands/explain.toml | 11 +++++++++-- ChangeLog | 5 +++++ GEMINI.md | 8 ++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.gemini/commands/explain.toml b/.gemini/commands/explain.toml index 7c854e3..8a946e3 100644 --- a/.gemini/commands/explain.toml +++ b/.gemini/commands/explain.toml @@ -1,8 +1,15 @@ description = "Explains the code or text provided in plain English." prompt = """ -You are a helpful coding tutor. -Please explain the following code or text in simple, easy-to-understand terms: +You are a senior technical educator who specializes in holistic system design. +When explaining the following code or concept, ensure your explanation encompasses its complete functional scope within the product ecosystem, rather than focusing on a single sub-feature or use case. +Follow these guidelines: +1. **The Big Picture:** Explain the fundamental problem this concept solves for the entire system. +2. **Comprehensive Analogies:** Use real-world analogies that illustrate the full breadth of its capabilities. +3. **Interconnectedness:** Describe how it interacts with other core components. +4. **Simple Language:** Keep it accessible but technically accurate. + +Concept to explain: {{args}} """ diff --git a/ChangeLog b/ChangeLog index dc2f91a..17b8110 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,8 @@ +* 2.1.0 +- Updated Workflow: API Versioning & Pre-Task Validation +- Added anti-pattern to Configuration Protocol +- Modified explain command to return structured explanations. + * 2.0.1 - Added unit tests for custom_config.py hook script. - Added unit tests for cleanup_config.py hook script. diff --git a/GEMINI.md b/GEMINI.md index 6755852..7a98a9f 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -24,8 +24,15 @@ - **NO PERSISTENCE:** Never save the confirmed API version to `save_memory`. - **READ-ONLY:** Only execute `search`, `search_stream`, or `get` methods. - **SURYGICAL UPDATES:** When modifying files, use the `replace` tool with minimal context to avoid unintended regressions. +- **PROTOCOL ADHERENCE:** Strictly prohibited from executing un-linted Python code or un-validated GAQL queries. #### 1.3. Workflow: API Versioning & Pre-Task Validation +... +#### 1.4. Technical Gatekeeping (Protocol Enforcement) +- **NO BYPASS:** Bypassing the GAQL Validation (3.1) or Python Linting (3.2) protocols is a **System Failure**. +- **EXPLICIT LOGGING:** Before calling `run_shell_command` for Python or any API search tool, you MUST explicitly state which protocol step you are currently executing (e.g., "Protocol 3.2: Executing Ruff linting on /tmp/script.py"). +- **PRE-FLIGHT GATE:** For every Python script, the `ruff` check is a blocking operation. If `ruff` returns an error, you MUST fix it and re-lint before the script is even considered for the `saved/code/` directory. +- **GAQL INTEGRITY:** Any GAQL query presented in chat or sent to the API MUST be preceded by a "Validation Block" confirming it has passed the 4-step sequence in Section 3.1. 1. **Search (Exact):** `google_web_search` with query `google ads api release notes`. 2. **Fetch (Source):** Extract content from `developers.google.com/google-ads/api/docs/release-notes`. 3. **Identify:** Find the latest MAJOR stable version (e.g., `v23`). @@ -56,6 +63,7 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali #### 2.2. Configuration Protocol - **Discovery:** Check `config/` for language-specific files (`google-ads.yaml`, `google_ads_config.rb`, etc.). - **Execution:** Always set `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to the absolute path in `config/` when running `python3`. +- **Anti-Pattern [CRITICAL]:** NEVER point to configuration files inside `client_libs/`. These are unconfigured templates. Using them will trigger a `ValueError` due to placeholders like `INSERT_USE_PROTO_PLUS_FLAG_HERE`. - **Generation:** Do NOT include a hardcoded path in `load_from_storage()`. Use environment variables or default search paths. #### 2.3. File Persistence From bf458f07df027c4ae36f062f99069f96431c24f5 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 5 Mar 2026 21:09:03 +0000 Subject: [PATCH 54/61] Release v2.1.1 --- ChangeLog | 3 +++ api_examples/add_campaign_with_date_times.py | 15 +++++++++++---- api_examples/ai_max_reports.py | 2 +- api_examples/capture_gclids.py | 2 +- .../collect_conversions_troubleshooting_data.py | 2 +- api_examples/conversion_reports.py | 2 +- api_examples/create_campaign_experiment.py | 2 +- api_examples/disapproved_ads_reports.py | 3 ++- api_examples/gaql_validator.py | 4 ++-- api_examples/get_campaign_bid_simulations.py | 2 +- api_examples/get_campaign_shared_sets.py | 2 +- api_examples/get_change_history.py | 2 +- api_examples/get_conversion_upload_summary.py | 2 +- api_examples/get_geo_targets.py | 2 +- api_examples/list_accessible_users.py | 2 +- api_examples/list_pmax_campaigns.py | 2 +- .../parallel_report_downloader_optimized.py | 2 +- .../remove_automatically_created_assets.py | 2 +- api_examples/target_campaign_with_user_list.py | 2 +- 19 files changed, 33 insertions(+), 22 deletions(-) diff --git a/ChangeLog b/ChangeLog index 17b8110..4d9b68a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,6 @@ +* 2.1.1 +- api_examples: api version is a required argument with no default value. + * 2.1.0 - Updated Workflow: API Versioning & Pre-Task Validation - Added anti-pattern to Configuration Protocol diff --git a/api_examples/add_campaign_with_date_times.py b/api_examples/add_campaign_with_date_times.py index ceb797b..fbfe79a 100644 --- a/api_examples/add_campaign_with_date_times.py +++ b/api_examples/add_campaign_with_date_times.py @@ -124,10 +124,6 @@ def _handle_google_ads_exception(exception): if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - parser = argparse.ArgumentParser( description="Creates a campaign with start and end date times." ) @@ -139,6 +135,17 @@ def _handle_google_ads_exception(exception): required=True, help="The Google Ads customer ID.", ) + parser.add_argument( + "-v", + "--api_version", + type=str, + required=True, + help="The Google Ads API version.", + ) args = parser.parse_args() + # GoogleAdsClient will read the google-ads.yaml configuration file in the + # home directory if none is specified. + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(googleads_client, args.customer_id) diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index f729075..1225170 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -65,7 +65,7 @@ def main(client: GoogleAdsClient, customer_id: str, report_type: str) -> None: help="The type of AI Max report to generate.", ) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/capture_gclids.py b/api_examples/capture_gclids.py index 8b4d37a..d1d0fa0 100644 --- a/api_examples/capture_gclids.py +++ b/api_examples/capture_gclids.py @@ -83,7 +83,7 @@ def main( help="The GCLID for the ad click.", ) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py index f0e9fae..5cccf40 100644 --- a/api_examples/collect_conversions_troubleshooting_data.py +++ b/api_examples/collect_conversions_troubleshooting_data.py @@ -112,7 +112,7 @@ def main(client: GoogleAdsClient, customer_id: str): if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("-v", "--api_version", type=str, default="v23", help="The Google Ads API version.") + parser.add_argument("-v", "--api_version", type=str, required=True, help="The Google Ads API version.") args = parser.parse_args() googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) main(googleads_client, args.customer_id) diff --git a/api_examples/conversion_reports.py b/api_examples/conversion_reports.py index a3d76eb..318b648 100644 --- a/api_examples/conversion_reports.py +++ b/api_examples/conversion_reports.py @@ -212,7 +212,7 @@ def get_conversion_performance_report( parser.add_argument("--date_range_preset", default="LAST_30_DAYS") parser.add_argument("--metrics", nargs="+", default=["conversions"]) parser.add_argument("--filters", nargs="*", default=[]) - parser.add_argument("-v", "--api_version", type=str, default="v23", help="The Google Ads API version.") + parser.add_argument("-v", "--api_version", type=str, required=True, help="The Google Ads API version.") args = parser.parse_args() googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/create_campaign_experiment.py b/api_examples/create_campaign_experiment.py index 6fa35e1..a49d587 100644 --- a/api_examples/create_campaign_experiment.py +++ b/api_examples/create_campaign_experiment.py @@ -221,7 +221,7 @@ def modify_treatment_campaign(client, customer_id, draft_campaign_resource_name) help="The ID of the base campaign to use for the experiment.", ) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() diff --git a/api_examples/disapproved_ads_reports.py b/api_examples/disapproved_ads_reports.py index aa29559..5cd04bc 100644 --- a/api_examples/disapproved_ads_reports.py +++ b/api_examples/disapproved_ads_reports.py @@ -37,8 +37,9 @@ def main(client: GoogleAdsClient, customer_id: str, output_file: str) -> None: parser.add_argument("-c", "--customer_id", required=True) parser.add_argument("-o", "--output", default="saved_csv/disapproved_ads.csv") parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() + client = GoogleAdsClient.load_from_storage(version=args.api_version) main(client, args.customer_id, args.output) diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py index acd3e9c..09b8d39 100644 --- a/api_examples/gaql_validator.py +++ b/api_examples/gaql_validator.py @@ -53,8 +53,8 @@ def main( ) parser.add_argument( "--api_version", - default="v23", - help="API Version (e.g., v23). Defaults to v23.", + required=True, + help="API Version (e.g., v23).", ) args = parser.parse_args() diff --git a/api_examples/get_campaign_bid_simulations.py b/api_examples/get_campaign_bid_simulations.py index c7ce01f..b9c0581 100644 --- a/api_examples/get_campaign_bid_simulations.py +++ b/api_examples/get_campaign_bid_simulations.py @@ -36,7 +36,7 @@ def main(client: GoogleAdsClient, customer_id: str, campaign_id: str) -> None: parser.add_argument("-c", "--customer_id", required=True) parser.add_argument("-i", "--campaign_id", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/get_campaign_shared_sets.py b/api_examples/get_campaign_shared_sets.py index 6e15d23..82216dd 100644 --- a/api_examples/get_campaign_shared_sets.py +++ b/api_examples/get_campaign_shared_sets.py @@ -26,7 +26,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: parser = argparse.ArgumentParser() parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/get_change_history.py b/api_examples/get_change_history.py index a0d6397..2127b95 100644 --- a/api_examples/get_change_history.py +++ b/api_examples/get_change_history.py @@ -41,7 +41,7 @@ def main(client: GoogleAdsClient, customer_id: str, start: str, end: str, resour parser.add_argument("--start_date") parser.add_argument("--resource_type", help="Filter by type (e.g. CAMPAIGN, AD_GROUP)") parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() diff --git a/api_examples/get_conversion_upload_summary.py b/api_examples/get_conversion_upload_summary.py index 3957c45..0e2cd96 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -33,7 +33,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: parser = argparse.ArgumentParser() parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/get_geo_targets.py b/api_examples/get_geo_targets.py index e9801fc..31f7f2b 100644 --- a/api_examples/get_geo_targets.py +++ b/api_examples/get_geo_targets.py @@ -43,7 +43,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: parser = argparse.ArgumentParser() parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/list_accessible_users.py b/api_examples/list_accessible_users.py index f0c380a..eade0f7 100644 --- a/api_examples/list_accessible_users.py +++ b/api_examples/list_accessible_users.py @@ -24,7 +24,7 @@ def main(client: GoogleAdsClient) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser(description="Lists accessible customers.") parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() diff --git a/api_examples/list_pmax_campaigns.py b/api_examples/list_pmax_campaigns.py index 6708224..5c429b0 100644 --- a/api_examples/list_pmax_campaigns.py +++ b/api_examples/list_pmax_campaigns.py @@ -38,7 +38,7 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: parser = argparse.ArgumentParser() parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/parallel_report_downloader_optimized.py b/api_examples/parallel_report_downloader_optimized.py index c1a4a0e..9181776 100644 --- a/api_examples/parallel_report_downloader_optimized.py +++ b/api_examples/parallel_report_downloader_optimized.py @@ -83,7 +83,7 @@ def main( parser.add_argument("-l", "--login_id") parser.add_argument("-w", "--workers", type=int, default=5) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() main(args.customer_ids, args.login_id, args.api_version, args.workers) diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index 6f64150..b021bb1 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -25,7 +25,7 @@ def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, asset_rn: parser.add_argument("-a", "--asset_rn", required=True) parser.add_argument("-f", "--field_type", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) diff --git a/api_examples/target_campaign_with_user_list.py b/api_examples/target_campaign_with_user_list.py index 9e9d593..f610313 100644 --- a/api_examples/target_campaign_with_user_list.py +++ b/api_examples/target_campaign_with_user_list.py @@ -26,7 +26,7 @@ def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, user_list_ parser.add_argument("-C", "--campaign_id", required=True) parser.add_argument("-u", "--user_list_id", required=True) parser.add_argument( - "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + "-v", "--api_version", type=str, required=True, help="The Google Ads API version." ) args = parser.parse_args() client = GoogleAdsClient.load_from_storage(version=args.api_version) From 846c6fe1e7dbc3c19b0004386fb79255bf5698ab Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Mar 2026 19:53:53 +0000 Subject: [PATCH 55/61] Update extension version --- gemini-extension.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gemini-extension.json b/gemini-extension.json index 51d7538..bb6c353 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "google-ads-api-developer-assistant", - "version": "2.0.0", + "version": "2.1.1", "contextFileName": "GEMINI.md" } From 1ca0ce3e9b12cf62156015927469186cec390b96 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 12 Mar 2026 22:47:13 -0400 Subject: [PATCH 56/61] Added before tool hook --- .gemini/hooks/export_gaada.py | 38 +++++++++++++++++++++++++++++++++++ .gemini/settings.json | 24 +++++++++++++++------- 2 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 .gemini/hooks/export_gaada.py diff --git a/.gemini/hooks/export_gaada.py b/.gemini/hooks/export_gaada.py new file mode 100644 index 0000000..bdefeb5 --- /dev/null +++ b/.gemini/hooks/export_gaada.py @@ -0,0 +1,38 @@ +import os +import sys +import subprocess +import json + + +def get_version(ext_version_script): + """Retrieves the extension version.""" + try: + result = subprocess.run( + [sys.executable, ext_version_script], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except Exception as e: + print(f"Error getting extension version: {e}", file=sys.stderr) + return "666" # Fallback + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(script_dir, "../..")) + ext_version_script = os.path.join( + project_root, ".gemini/skills/ext_version/scripts/get_extension_version.py" + ) + + version = get_version(ext_version_script) + + output = {"environment": {"gaada": version}} + + # Output JSON to stdout so the CLI can consume the exported environment variables + print(json.dumps(output)) + + +if __name__ == "__main__": + main() diff --git a/.gemini/settings.json b/.gemini/settings.json index a3835a7..4e702ae 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,17 +1,15 @@ { "ui": { "accessibility": { - "disableLoadingPhrases": true, "enableLoadingPhrases": false - } + }, + "loadingPhrases": "off" }, "context": { "includeDirectories": [ - "/path/to/project_dir/google-ads-api-developer-assistant/api_examples", - "/path/to/project_dir/google-ads-api-developer-assistant/saved/code", - "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-python", - "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-php", - "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-ruby" + "/usr/local/google/home/rwh/google-ads-api-developer-assistant/api_examples", + "/usr/local/google/home/rwh/google-ads-api-developer-assistant/saved/code", + "/usr/local/google/home/rwh/google-ads-api-developer-assistant/client_libs/google-ads-python" ] }, "tools": { @@ -30,6 +28,18 @@ ] } ], + "BeforeTool": [ + { + "matcher": "*", + "hooks": [ + { + "name": "export_gaada", + "type": "command", + "command": "python3 .gemini/hooks/export_gaada.py" + } + ] + } + ], "SessionEnd": [ { "matcher": "exit", From 48d4127132523e6565e79e70ab690f4838bcbf2d Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 12 Mar 2026 22:55:57 -0400 Subject: [PATCH 57/61] Correct before tool logic --- .gemini/hooks/export_gaada.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.gemini/hooks/export_gaada.py b/.gemini/hooks/export_gaada.py index bdefeb5..f9cddfc 100644 --- a/.gemini/hooks/export_gaada.py +++ b/.gemini/hooks/export_gaada.py @@ -28,6 +28,19 @@ def main(): version = get_version(ext_version_script) + new_command = f'export gaada="{version}"' + + # Return the modified command to the CLI + response = { + "decision": "allow", + "tool_input": { + "command": new_command + } + } + + # 5. Output the final JSON to stdout + print(json.dumps(response)) + output = {"environment": {"gaada": version}} # Output JSON to stdout so the CLI can consume the exported environment variables From edffc23b89d403748e21b7a715ac7e7fd2301889 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 13 Mar 2026 14:42:38 +0000 Subject: [PATCH 58/61] Corrected custom config --- GEMINI.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 7a98a9f..3951b88 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,10 +1,9 @@ # Google Ads API Developer Assistant Configuration ## Metadata -- **Version:** 2.1 +- **Version:** 2.1.1 - **Status:** Optimized for Machine Comprehension - **Runtime:** Python 3.x, Bash -- **Workspace Root:** `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant` --- @@ -23,7 +22,8 @@ - **NO SECRETS:** Never print, log, or save developer tokens, OAuth secrets, or PII. - **NO PERSISTENCE:** Never save the confirmed API version to `save_memory`. - **READ-ONLY:** Only execute `search`, `search_stream`, or `get` methods. -- **SURYGICAL UPDATES:** When modifying files, use the `replace` tool with minimal context to avoid unintended regressions. +- **SURGICAL UPDATES:** When modifying files, use the `replace` tool with minimal context to avoid unintended regressions. +- **SOURCE OF TRUTH:** Never rely solely on high-level documentation summaries or search snippets for API capabilities. Always use `grep_search` and `read_file` to verify the literal `.proto` definitions or Python client library docstrings before concluding an API feature's behavior or requirements. - **PROTOCOL ADHERENCE:** Strictly prohibited from executing un-linted Python code or un-validated GAQL queries. #### 1.3. Workflow: API Versioning & Pre-Task Validation @@ -54,17 +54,16 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali ### 2. File & Data Management [LOGISTICS] #### 2.1. Project Structure -- **Root:** `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant` +- **Root:** Current context directory (`./`) - **Config:** `config/` (Target files for CLI execution). -- **Scripts (Library):** `api_examples/` (READ-ONLY. Never modify). +- **Scripts (Library):** `api_examples/` (Modifiable by user request). - **Output (Code):** `saved/code/` (All generated/modified scripts). - **Output (Data):** `saved/csv/`, `saved/data/` (All report outputs). #### 2.2. Configuration Protocol - **Discovery:** Check `config/` for language-specific files (`google-ads.yaml`, `google_ads_config.rb`, etc.). -- **Execution:** Always set `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to the absolute path in `config/` when running `python3`. - **Anti-Pattern [CRITICAL]:** NEVER point to configuration files inside `client_libs/`. These are unconfigured templates. Using them will trigger a `ValueError` due to placeholders like `INSERT_USE_PROTO_PLUS_FLAG_HERE`. -- **Generation:** Do NOT include a hardcoded path in `load_from_storage()`. Use environment variables or default search paths. +- **Generation:** Always use `load_from_storage()` to initialize the client. Do NOT use `load_from_env()`. Ensure `GOOGLE_ADS_CONFIGURATION_FILE_PATH` is set in the environment before execution. #### 2.3. File Persistence - **Write:** Use `write_file` for new scripts. @@ -128,9 +127,16 @@ except GoogleAdsException as ex: #### 4.3. Python Object Inspection (CRITICAL) NEVER guess the structure of an API object. - **Discovery:** Execute a one-liner to print `type()`, `dir()`, and `str()`. +- **NO DUMMY CREDENTIALS:** When writing quick inline scripts for object inspection, NEVER initialize `GoogleAdsClient` using `load_from_dict` with placeholder credentials (e.g., `{'developer_token': '1'}`). This triggers an immediate OAuth `RefreshError`. +- **Inspection Initialization:** You MUST initialize the client using `GoogleAdsClient.load_from_storage()` and ensure the environment variables are correctly passed to the shell command (e.g., `GOOGLE_ADS_USE_PROTO_PLUS=True python3 -c "..."`), OR directly import the protobuf types without initializing a client. - **Protobuf:** Verify `.pb` existence before using `message.pb.DESCRIPTOR`. - **Nested Types:** Use `Class.meta.pb.DESCRIPTOR` for class-level inspection. +#### 4.4. Performance Max URL Expansion +- **Asset Group URL Filtering:** When asked to filter or restrict URL expansion for specific Asset Groups without using Page Feeds, ALWAYS use the `AssetGroupListingGroupFilter` resource with a `listing_source` of `WEBPAGE`. +- **Implementation:** Create a subdivision tree containing a `UNIT_INCLUDED` node with a `Webpage` condition using the `url_contains` operator. +- **Anti-Pattern:** Do not falsely state that "URL contains" rules for Asset Groups are impossible without feeds. Do not exclusively recommend Campaign-level exclusions or separate campaigns when Asset Group-level webpage partitioning is the requested goal. + --- ### 5. Troubleshooting [DIAGNOSTICS] From 980c09bef23c1ef40923c34fa718c21f7c81ee65 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 13 Mar 2026 10:46:36 -0400 Subject: [PATCH 59/61] Correct included dir --- .gemini/settings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 4e702ae..8d5ab33 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -7,9 +7,9 @@ }, "context": { "includeDirectories": [ - "/usr/local/google/home/rwh/google-ads-api-developer-assistant/api_examples", - "/usr/local/google/home/rwh/google-ads-api-developer-assistant/saved/code", - "/usr/local/google/home/rwh/google-ads-api-developer-assistant/client_libs/google-ads-python" + "/path/to/your/project/google-ads-api-developer-assistant/api_examples", + "/path/to/your/project/google-ads-api-developer-assistant/saved/code", + "/path/to/your/project/google-ads-api-developer-assistant/client_libs/google-ads-python" ] }, "tools": { From 50de24dd165c3716aa7a2ed898a8813e27b5bf33 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 13 Mar 2026 10:52:18 -0400 Subject: [PATCH 60/61] Remove BeforeTool artifiacts --- .gemini/hooks/export_gaada.py | 51 ----------------------------------- .gemini/settings.json | 12 --------- 2 files changed, 63 deletions(-) delete mode 100644 .gemini/hooks/export_gaada.py diff --git a/.gemini/hooks/export_gaada.py b/.gemini/hooks/export_gaada.py deleted file mode 100644 index f9cddfc..0000000 --- a/.gemini/hooks/export_gaada.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import sys -import subprocess -import json - - -def get_version(ext_version_script): - """Retrieves the extension version.""" - try: - result = subprocess.run( - [sys.executable, ext_version_script], - capture_output=True, - text=True, - check=True, - ) - return result.stdout.strip() - except Exception as e: - print(f"Error getting extension version: {e}", file=sys.stderr) - return "666" # Fallback - - -def main(): - script_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(script_dir, "../..")) - ext_version_script = os.path.join( - project_root, ".gemini/skills/ext_version/scripts/get_extension_version.py" - ) - - version = get_version(ext_version_script) - - new_command = f'export gaada="{version}"' - - # Return the modified command to the CLI - response = { - "decision": "allow", - "tool_input": { - "command": new_command - } - } - - # 5. Output the final JSON to stdout - print(json.dumps(response)) - - output = {"environment": {"gaada": version}} - - # Output JSON to stdout so the CLI can consume the exported environment variables - print(json.dumps(output)) - - -if __name__ == "__main__": - main() diff --git a/.gemini/settings.json b/.gemini/settings.json index 8d5ab33..ef0494a 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -28,18 +28,6 @@ ] } ], - "BeforeTool": [ - { - "matcher": "*", - "hooks": [ - { - "name": "export_gaada", - "type": "command", - "command": "python3 .gemini/hooks/export_gaada.py" - } - ] - } - ], "SessionEnd": [ { "matcher": "exit", From 4d16b03835cd4481b865794df6a6804be5fca2fd Mon Sep 17 00:00:00 2001 From: Fares Azani Date: Mon, 1 Jun 2026 03:12:57 -0700 Subject: [PATCH 61/61] Improve Gemini command listing output --- README.md | 9 +++++++ list_commands.py | 25 ++++++++++------- tests/test_list_commands.py | 54 +++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 tests/test_list_commands.py diff --git a/README.md b/README.md index 4c67f1d..6a0af02 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,15 @@ To ensure you are using the latest versions, run `update.sh` (Linux/macOS) or `update.ps1` (Windows) when a new version of the API is published or a new version of a client library is released. +### Running Tests + +Run the Python test suite with pytest plugin autoload disabled so unrelated +globally installed pytest plugins do not affect this project: + +```bash +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest -q +``` + ## Uninstallation If you wish to remove the extension and the project directory, you can use the uninstallation scripts: diff --git a/list_commands.py b/list_commands.py index a1b5477..33c6472 100644 --- a/list_commands.py +++ b/list_commands.py @@ -2,9 +2,9 @@ import tomllib import sys -def main(): - commands_dir = pathlib.Path(".gemini/commands") - + +def load_commands(commands_dir): + """Returns command names and descriptions from Gemini command TOML files.""" if not commands_dir.exists(): print(f"Directory not found: {commands_dir.absolute()}") sys.exit(1) @@ -13,30 +13,35 @@ def main(): if not files: print("No .toml files found in .gemini/commands") - return + return [] - # Collect all commands and descriptions commands = [] for file_path in files: try: with file_path.open("rb") as f: data = tomllib.load(f) description = data.get("description", "No description found") - commands.append((file_path.stem, description)) + commands.append((f"/{file_path.stem}", description)) except Exception as e: print(f"Error reading {file_path.name}: {e}", file=sys.stderr) + return commands + + +def print_commands(commands): if not commands: return - # Calculate max length for alignment max_len = max(len(cmd[0]) for cmd in commands) - - # Print aligned output - # We add a few spaces gap between command and description gap = 3 for name, description in commands: print(f"{name:<{max_len + gap}}{description}") + +def main(): + commands_dir = pathlib.Path(".gemini/commands") + print_commands(load_commands(commands_dir)) + + if __name__ == "__main__": main() diff --git a/tests/test_list_commands.py b/tests/test_list_commands.py new file mode 100644 index 0000000..66530ea --- /dev/null +++ b/tests/test_list_commands.py @@ -0,0 +1,54 @@ +import io +import pathlib +import tempfile +import unittest +from contextlib import redirect_stdout + +import list_commands + + +class TestListCommands(unittest.TestCase): + + def test_load_commands_adds_slash_prefix_and_description(self): + with tempfile.TemporaryDirectory() as tmp: + commands_dir = pathlib.Path(tmp) + (commands_dir / "explain.toml").write_text( + 'description = "Explain a Google Ads concept"\n', + encoding="utf-8", + ) + + commands = list_commands.load_commands(commands_dir) + + self.assertEqual(commands, [("/explain", "Explain a Google Ads concept")]) + + def test_load_commands_uses_default_description(self): + with tempfile.TemporaryDirectory() as tmp: + commands_dir = pathlib.Path(tmp) + (commands_dir / "step_by_step.toml").write_text("", encoding="utf-8") + + commands = list_commands.load_commands(commands_dir) + + self.assertEqual(commands, [("/step_by_step", "No description found")]) + + def test_print_commands_aligns_descriptions(self): + output = io.StringIO() + + with redirect_stdout(output): + list_commands.print_commands([ + ("/a", "short"), + ("/longer", "long"), + ]) + + self.assertEqual(output.getvalue(), "/a short\n/longer long\n") + + def test_missing_directory_exits(self): + missing_dir = pathlib.Path("/path/that/does/not/exist") + + with self.assertRaises(SystemExit) as context: + list_commands.load_commands(missing_dir) + + self.assertEqual(context.exception.code, 1) + + +if __name__ == "__main__": + unittest.main()