From 89c43a581a7afbad173c18ddd4c9ee67cc64f595 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 27 Jan 2026 14:38:42 +0000 Subject: [PATCH 01/81] Point 5 of rigorous GAQL analysis --- GEMINI.md | 6 ++++-- gemini-extension.json | 2 +- setup.sh | 5 +++++ 3 files changed, 10 insertions(+), 3 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. diff --git a/gemini-extension.json b/gemini-extension.json index b6e9933..63659e2 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,4 +1,4 @@ { "name": "google-ads-api-developer-assistant", - "version": "1.0.0" + "version": "1.6.0" } diff --git a/setup.sh b/setup.sh index 505aea8..d15373f 100755 --- a/setup.sh +++ b/setup.sh @@ -318,6 +318,11 @@ if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then exit 1 fi +if is_enabled "python"; then + echo "Installing google-ads via pip..." + python -m pip install --upgrade google-ads +fi + trap - EXIT # Clear the trap echo "Successfully updated ${SETTINGS_FILE}" From 243c22e9fa5b034f8476b04e40075106277cae6e Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 30 Jan 2026 10:16:07 -0500 Subject: [PATCH 02/81] Added install-deps to setup procedure and updated tests. --- ChangeLog | 3 ++ setup.ps1 | 33 ++++++++++++- setup.sh | 23 ++++++++- tests/test_setup.ps1 | 114 +++++++++++++++++++++++++++++++++++++++++++ tests/test_setup.sh | 84 +++++++++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/test_setup.ps1 diff --git a/ChangeLog b/ChangeLog index 4442bcb..0c1ad99 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,6 @@ +* 1.6.0 +- Added --install-deps option to setup.sh and setup.ps1 + * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/setup.ps1 b/setup.ps1 index 36eb1cc..94ae56a 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -38,7 +38,9 @@ param( [switch]$Php, [switch]$Ruby, [switch]$Java, - [switch]$Dotnet + [switch]$Java, + [switch]$Dotnet, + [switch]$InstallDeps ) $ErrorActionPreference = "Stop" @@ -210,7 +212,36 @@ catch { + +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 "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.)" +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/setup.sh index 4d5b09f..40ea69d 100755 --- a/setup.sh +++ b/setup.sh @@ -329,6 +329,26 @@ if is_enabled "python" && [[ "${INSTALL_DEPS}" == "true" ]]; then 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}" @@ -337,5 +357,6 @@ jq '.context.includeDirectories' "${SETTINGS_FILE}" echo "Setup 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/tests/test_setup.ps1 b/tests/test_setup.ps1 new file mode 100644 index 0000000..d3ed296 --- /dev/null +++ b/tests/test_setup.ps1 @@ -0,0 +1,114 @@ +<# +.SYNOPSIS + Test script for setup.ps1 +#> + +$ErrorActionPreference = "Stop" + +# --- Test Setup --- +$TestTmpDir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName() +New-Item -ItemType Directory -Force -Path $TestTmpDir | Out-Null +$SetupScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "setup.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 setup.ps1 -Python -Php -Ruby -InstallDeps --- + Write-Host "--- Running setup.ps1 -Python -Php -Ruby -InstallDeps ---" + Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue + + # We must run it in the FakeProject dir so git rev-parse finds it? + # setup.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 setup.ps1 + & $SetupScriptPath -Python -Php -Ruby -InstallDeps + if ($LASTEXITCODE -ne 0) { throw "setup.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" } + + # --- Test Case 2: Run setup.ps1 NO InstallDeps --- + Write-Host "--- Running setup.ps1 (NO Deps) ---" + Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue + + & $SetupScriptPath -Python -Php -Ruby + if ($LASTEXITCODE -ne 0) { throw "setup.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" + } + + Write-Host "ALL TESTS PASSED" + +} +catch { + Write-Error "Test Failed: $_" + exit 1 +} +finally { + Cleanup +} diff --git a/tests/test_setup.sh b/tests/test_setup.sh index 2decabb..58de2ee 100755 --- a/tests/test_setup.sh +++ b/tests/test_setup.sh @@ -131,4 +131,88 @@ else echo "INFO: google-ads-python is GONE (Expected per current logic if overwriting)" 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 setup.sh --python --php --ruby --install-deps ---" +# Clear log +rm -f "${TEST_TMP_DIR}/install_log.txt" + +if ! bash "${SETUP_SCRIPT_PATH}" --python --php --ruby --install-deps; then + echo "FAIL: setup.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 setup.sh --python --php --ruby (NO deps) ---" +rm -f "${TEST_TMP_DIR}/install_log.txt" + +if ! bash "${SETUP_SCRIPT_PATH}" --python --php --ruby; then + echo "FAIL: setup.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" From b3b9a75a6eace08a2700ae1c6a91884a9897d027 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 2 Feb 2026 15:57:54 -0500 Subject: [PATCH 03/81] Add skill to get extension version --- skills/ext_version/SKILL.md | 16 +++++++++ .../scripts/get_extension_version.py | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 skills/ext_version/SKILL.md create mode 100644 skills/ext_version/scripts/get_extension_version.py diff --git a/skills/ext_version/SKILL.md b/skills/ext_version/SKILL.md new file mode 100644 index 0000000..a1dca56 --- /dev/null +++ b/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/skills/ext_version/scripts/get_extension_version.py b/skills/ext_version/scripts/get_extension_version.py new file mode 100644 index 0000000..e47cd1d --- /dev/null +++ b/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 skills/version/scripts/ + # gemini-extension.json is at the root, so 3 levels up + base_dir = 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() From 5702c6433d72ab8fd3a2bafe97a78d3a4af0f79e Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 4 Feb 2026 09:29:59 -0500 Subject: [PATCH 04/81] Added hook at start session to make a custom google-ads.yaml --- .gemini/settings.json | 19 +++++----- ChangeLog | 5 ++- GEMINI.md | 1 + gemini-extension.json | 3 +- hooks/SessionStart/01_configure.py | 61 ++++++++++++++++++++++++++++++ hooks/hooks.json | 13 +++++++ 6 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 hooks/SessionStart/01_configure.py create mode 100644 hooks/hooks.json diff --git a/.gemini/settings.json b/.gemini/settings.json index 2e85946..b9115bc 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" ] } -} +} \ No newline at end of file diff --git a/ChangeLog b/ChangeLog index 0c1ad99..38fca04 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,8 @@ * 1.6.0 -- Added --install-deps option to setup.sh and setup.ps1 +- Added --install-deps option to setup.sh and setup.ps1. +- Added skills/ext_version to get the extension version. +- Added gemini-extension.json. +- Added documentation resource for public protos. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/GEMINI.md b/GEMINI.md index 12639af..ce7c5ba 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -169,6 +169,7 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 5.2. References - **API Docs:** `https://developers.google.com/google-ads/api/docs/` - **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` +- **Protos:** `https://github.com/googleapis/googleapis/tree/master/google/ads/googleads` #### 5.3. Disambiguation - **'AI Max' vs 'PMax':** 'AI Max' refers to 'AI Max for Search campaigns', not 'Performance Max'. diff --git a/gemini-extension.json b/gemini-extension.json index 54b19a1..d02079e 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,6 @@ { "name": "google-ads-api-developer-assistant", "version": "1.6.0", - "contextFileName": "GEMINI.md" + "contextFileName": "GEMINI.md", + "hooks": "hooks/hooks.json" } diff --git a/hooks/SessionStart/01_configure.py b/hooks/SessionStart/01_configure.py new file mode 100644 index 0000000..cdde0a9 --- /dev/null +++ b/hooks/SessionStart/01_configure.py @@ -0,0 +1,61 @@ +import os +import shutil +import subprocess +import sys + +def configure(): + """Configures the Google Ads environment.""" + # Determine paths + script_dir = os.path.dirname(os.path.abspath(__file__)) + # hooks/SessionStart -> root is 2 levels up + root_dir = os.path.abspath(os.path.join(script_dir, "../..")) + source_yaml = os.path.join(root_dir, "google-ads.yaml") + config_dir = os.path.join(root_dir, "config") + target_yaml = os.path.join(config_dir, "google-ads.yaml") + ext_version_script = os.path.join(root_dir, "skills/ext_version/scripts/get_extension_version.py") + + # Check if source exists + if not os.path.exists(source_yaml): + # Fail silently or print error? Hooks might be noisy. + # But user requested "If it does not exist display an error and stop" originally. + # We will keep the error message but maybe not exit(1) if we don't want to break the session? + # User requirement said "If it does not exist display an error and stop", so we stick to it. + print(f"Error: {source_yaml} does not exist. Please create it in the project root.", file=sys.stderr) + sys.exit(1) + + # Create config directory + os.makedirs(config_dir, exist_ok=True) + + # Copy file + shutil.copy2(source_yaml, target_yaml) + + # Get extension version + try: + # Run the extension version script via python3 + result = subprocess.run( + [sys.executable, ext_version_script], + capture_output=True, + text=True, + check=True + ) + version = result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error getting extension version: {e.stderr}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error executing version script: {e}", file=sys.stderr) + sys.exit(1) + + # Append version to target yaml + try: + with open(target_yaml, "a", encoding="utf-8") as f: + f.write(f"\ngaada: \"{version}\"\n") + except Exception as e: + print(f"Error appending to {target_yaml}: {e}", file=sys.stderr) + sys.exit(1) + + # Output env var command + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_yaml}\"") + +if __name__ == "__main__": + configure() diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..c251bc9 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,13 @@ +{ + "hooks": { + "SessionStart": [ + { + "name": "custom-config", + "type": "command", + "command": "python3 ${extensionPath}/hooks/SessionStart/01_configure.py", + "description": "Configures Google Ads environment at session start.", + "timeout": 30000 + } + ] + } +} From 00433f064f3ca219d015c481e227f545493ad332 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 4 Feb 2026 09:29:59 -0500 Subject: [PATCH 05/81] Added hook at start session to make a custom google-ads.yaml --- .gemini/settings.json | 19 ++-- ChangeLog | 5 +- GEMINI.md | 1 + ...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 gemini-extension.json | 3 +- hooks/SessionStart/custom_config_python.py | 67 +++++++++++++ hooks/hooks.json | 13 +++ saved_code/get_all_campaigns_to_csv.py | 88 ------------------ 21 files changed, 97 insertions(+), 99 deletions(-) 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 hooks/SessionStart/custom_config_python.py create mode 100644 hooks/hooks.json delete mode 100644 saved_code/get_all_campaigns_to_csv.py diff --git a/.gemini/settings.json b/.gemini/settings.json index 2e85946..b9115bc 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" ] } -} +} \ No newline at end of file diff --git a/ChangeLog b/ChangeLog index 0c1ad99..38fca04 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,8 @@ * 1.6.0 -- Added --install-deps option to setup.sh and setup.ps1 +- Added --install-deps option to setup.sh and setup.ps1. +- Added skills/ext_version to get the extension version. +- Added gemini-extension.json. +- Added documentation resource for public protos. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/GEMINI.md b/GEMINI.md index 12639af..ce7c5ba 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -169,6 +169,7 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 5.2. References - **API Docs:** `https://developers.google.com/google-ads/api/docs/` - **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` +- **Protos:** `https://github.com/googleapis/googleapis/tree/master/google/ads/googleads` #### 5.3. Disambiguation - **'AI Max' vs 'PMax':** 'AI Max' refers to 'AI Max for Search campaigns', not 'Performance Max'. 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 zcmb_hO>7&-6(0VNC{nWiDN!<4vL%a_Md~k-Ej#g_7)yyQDQlTY8d-}qxsupWB(uA; zBL*nkAJhzxwlRE&8y85@9FhP%I7QL8z0@sG)JL*rLSn0+2I?LP^x(t>T=dj8v)m;m zuVT08NSd8_^JeDF%zJOXc^as5I}s$$SM9OiHX;-u6)n*gsNDax389N<39+UbB%A2h zJZ(vveQ4S$+k9$^UAEH}hwM0OpWmSkQu)p|Rmxgv37vHq^0sIkmG3s3Z50l!v<+vw z)?WFZb&tW?NMg-nX0@bIw0e1cb#fi7&oWa#y+htXY1WyB=|;JcmTfal(@k>IbhF${ z>-L$J=~lUwmK`%~(;nGF%Xp@JxmU%yGIImNHUBBzp(SD}JW z%VI3CGBf8lD>%x_f*e~Dlp2ypg}BTEOXG=TawRStjY^@{BSK1!B@;lc*Qv+jF(DyC zf9o0_ODG+yd?FebxE0!qk4l`Nb=#y|S;sEq$4c{pAju~L`8dqJB2335IVrA3v^mHY*x(Pg z6W{+QRpv##ZLxgppaN*3Obw%iPOWfi+Y7DbgFz^a=Ivzo%?YU1%@k6b$B)tyL@t_G z6O5~mwOFUojm)ER)J8_7$+@T-(UOBTYirx5nKeu>Z(uyEMKb`9$E8W}|B&agv$^%z zy0ntE7LcW0qrvX()XJ6bBBPZP4O)q{f_B(IU9D0|h$znCk) zC1OEIjFg)O!6XOyRBSLRoEPHBlpqfBk^~0BC*&Z|!Cd*ZR9ujPGMJQ_b7Z*@YFgZ? zYFmNSx?+ZR-a4qz@sxh&H^te-SKrC$rW9_H6k2uf0J^ou#q;#j)x9 z*mTxf#GX9%6!Gpn-d(`MCGQ@U*SqVzL+>8C_DsH1(dC&Ou%x%vxU~zBw_FT)T zlee@NTm1PJ|67(HJMV*-Xp_qz+CW_fam^ReB4MCuUUv=}1y!|HTnCFl{Y&xTjk4An8Yp1-7-t^Ul)cC9(jq2Pm5s zK>Q6|z@H(91?WCcv#Jz$+^{L3)4>Osj67Gx-?3)kx5BtIu@>O3@+TU-!f!CA`NhU6 zKc~;F%VY_g1E#PgLF|yGum$SK$Pdl~WZ9Y$x;%@zcZ1%Zt$fsXcNkL1;cQ99K4+}F z6Fzbe7(pHb`R0jHY)RVM|EqDxac@Z%KrzJ-$kMx|0jgmeYu#71Uf`;#Y5Wz?1MPb> zYURt?fNroqF2vfQ#lSB^i(|wFv*YU7H_g*u8DA^S4``*zSKFl&naM~tKm0tuYk zN?p61)Fbw=eUp?%GrFFD$NdC6bx*)kU(Lhr0N2uxX&M>i$23P?wrEnf#C8G|;lwf*Wd9=*$Hy5dQuydAUtRtg7{7Q;PI++tY>P6ic>l;M+!3O{uZiTfBoYrU630SMM%DOT}2L}IT>ql$IZ`@JkFrZf?d zi<6el@p5EUvC_fquU=5UDm0OZqwZs9Qtz>L0v$N=9N?sMBqB)Caym{$RRY^wY}@b; z!O+sr;f}b7?soNEdnJcEN~6<|7w}MNXc7uV+@Ht&1w2?9n4|Rm(#Szd-*(EcMgVb>?7EtGmkrqSnV@$7rU|xMR!-; z-E~zi`ht01@OtxSzE6EOcmHYd_k#sIf6tC;+||8`_~|@;x_}Rt+PuZKfqdIQvF$*< z?Le{Zc)soUo35<2gk37cx!U@^`}6j|b!&0xNPg%@0Uw2?pSa(2m+B7QnEKt^Z{~6b zPu@C_TX?Csz~>kE-05?hMJ@KQt2R2KM^PUSewiX+D^9{YlhCsd{aJykB zYu5)laAS9I^11xvbGZZ0-x|-c;UdfDSw8my47HqFd9}DA=U3$1aysWa55uZMVptQA zBiH)$jq?TkYmkG64v!3khTIqSySZ^41n@6K)2FQKWl)#(9c|fzl*gX4NZ@7 zKS~LkSqp2OC_~1S#el;yZ=kgmAPnHkkYnCJtBv4jqDGU~SZfzswRSB8aL~k=(&n^< z(_>31>!Kb<3+tf*h&b8Fj~20_K$bjuwxpM=M&Qv%{-rAW0cj0@~b3(`~*BT)jX_y7h=!=(h%Yb=w>I1L433e`RLEd>_JNxikbY*Oe3)~Mi4~+ zLu+s~@+*Skq;?4oARdLlgp(FH6++M!Q2=ZOS7a>ew<;8xB5uC3Vu|0qi(5- zLLF^K>u62yub3lisz)Ufsdz{Asy@2N?c<)x7}=$*(aFGLdM&{$QVqR1H_E7`YnWn| zjnPB5T!qmyxsh5?E@Q)-YWwe4INj!rDmqo z*8iZ!*5S<7J#eAkz=zLg>r?zk?fE`C} z_7(7q4lMS4y!-`z>R}5^19SgdBF^y-ldh26_D6edsI|s#R}YoETqrD7p)x_!6e`0F zs$h;9633D7%UUv;hS)TxxHyhLV`5@ZtLM1om?+8dSVDknjuRO&0(nD=he>vfWG_Re zw2%-EC~bt}Wq9SBOT!_;01X_33PBsAv4|WI0EBa3#_lR2bc2&bN2tPxCk8mskHhb;q-#wyiU6!8fPUI5vS^ROCv1_Zu*g4BIg zBXEaQ6?sN{9h%6INtYqJXEB*f4|gJy=?Cch&cC3VJIHbge-~e>|8D);rng7lnYcXh z&f&|4-yivC;)98g4t;Rwy7@E5r;g9+KdrxsZx7Af+JAf3!tJIr1r+`ps{a!0`VzJM z74`oeO@4_c9@>%Vl<9%}MU!d#>i(j4U*5a#0V1_~vKd)j*-XLGUb4C_o%-IXAI^Pc 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 zcmcf_OKcm*b(Y^HMT(YeeQ4`L(UM%c6)DSd<*06)z>zDpvBYd-H9#aUR^&)pceP|@ zhjyfs(-yS@^kbt37w9EMfW`)La8AjwM;}dCFomgt8fbgyO}5kzdg_~5?vj$J)Ta)l z**9)Cy=c-$ z{k`@~fCj`&kOmh5S>Uz5We@MiAXvE&Y)ISoGVKAB9j{CoZ$KFx1=)C0Jfj{Paxl)g zDqk~g2Kl^GvB^X9&}0j35qRHJ>*Qg2Sit_NBa>~kO~8Sv_Q?*~A>iOt=VYA5dr=$e zLyFvil+Z$RcGTXjz9044BYJG1O9`KS525@Kgt`z=kJQuIa36A5^nzxNi~m&9-}&V9 zdLW(7z+cM4LYK5)`w8>M&(Y>m>nOPF=ULUSk^5d8OafaUw`Ygu_80*0pF z!Oce)Al6((F1!cWPXRzb&VPN<8u*@Kcr z3#*u@aAVi>9A-_+n7V@q!L&q7^_pgsFyS(>SWYVf4(HWEi55$gd95{z1uU9}@dLgs zHkK@Yk%Me4Q?f|LDC9IFvn*^VvjUrC$n}-XGG5h9J!6qvb!te07>v+*y`8D{_BX?%$A4ZS|h` z{W_=V+H8wg+EV4V)Gxii4(|Xrw8!%J^$W{8D>jhn+9f}itDK|X0fVR%NSMFTlGLTNd z?2&VWscOd8Ff{*J*&MZQWa*?y9=z+Z(RYeA6P$< ztGcR{Ipwx`?1+W+Vu;C3)R&eStyYlPMF#%9#142u}!`by}RE? zayr!{;tXqc*sGkqMYwUX_|>$W9&XFb6C- z37oBa(18c_4}JQh$8z7ZNOCih-1d7%!!X*AhquEhI=Jmcp=d=;mgQtcK3A5{ZOBu* zGT?5_R-)bIX!nNPvmMw+Q;}!N^2~<(4$QQ+S6T+jEd!O7bh#z{q-FS4U{j9#Ci-#o zar?LK4?;+ChZi3OHsqQ5;Zu3$8@$?gpjW5S|JkD(WVtL4d;8iRPi*jNR8YMK9pJ4} zJ&oc4FY~_SIm7E62Y8NS@}vjOChVN_a%itb3+p9)d?E=QYqG6(xCLD+OubO1Iqo0 zu@Oyky*q;Rjc8)^G|C}x*%lU>Af!Mp6!Mt)c+z1}W#XO76YtKd6N)mUq$J^z{6(At zn)Rq4PvkMxbi-m{;c-hkHu69c7J$fIFgk3Hpe9;Tv4K~i%CVqc3PNJDv}Md>{ItNAL$Fo@MlW%E$a-3bu!&#)3<*ExeyuxJI+Stl0F4>pERYOudyz?&Fe7 z@MSn}6Tq98maIf_kDSSbX66%6e;K7bwi0v&Czc9^QMe9eT*5xw_VSb z8%d_u+e4ulGa-Z@Pt@*bIu#?_1?^1lqiaPB8Fuk1&e2-@V@;Nx$0}Z0;^;9MCBl0Gu5`2dmk=N1Rt=ce~_J zp{@YpYT?ShJKMNgOn>vbxA{i@f$j#{4WMZ6R(tPOJh?TH+Uz><`QZKd!?~~2$D@~a zyq>|MyI$YX_FZqFqjk3#wfFw^{jJWvzjpR*_Me8rV(ZN7kG`{U=Esjm-`fS^o?X8` zestR#?1B}cc>0 z$sr-P;KBtJmZhe;nY>ui4XT?Ki@Z@>Q{7QqEv|7}O54^9KbE8kzD{(CS;$4Ppo0s; zVqtn3vFkfe!D2oha##SE!(*5QwM9!Hd{+y)$%1*Eh&TtQS%RPd*CLlpopLTL>Og7k zD0c17ZOqq9bM&+DpHS!-^4^p`l5fU7 zirtoOkK7%*Gj{j*Q;*I*IX?U3@Z}9O z_ZJlVGdlS->i8QP{~C?`BY>pQ-GCxVPAtAs9@em*kh6vlj4;&oZ@uQd+(g4bu5VyhJSxal!Mo7xtm9gm$ zX*)HU>4Qu(yna1%u1*w-=c%saZt zxJz?)8$BM)!+N}$_kt(UU^214i5)gk7_(j3U%gfj`*Ti&#v8N-hI36cjyGvd40ccK7~iSwWUyzVdAvnyVX${1 zJl?9cc9Uk(Lj*od1mA`F#6e@G>U!8|w6yjM9fJSJc|uY<35gJ(-d{~;z&&KRg6xJ{ z>1DrI-9HdFwd{$Ap#6UP(@7ldnUAKc`V8Ge0p6aMRgrc0MhiPN`#8lX6Z| zvKhdJEbM4nkuw^gT#J&Dc|s~G=S3c#fXO6~iwhtV0?`~7yESJE$--K~L~{uasNI4S zYLDQ8+AFxB<^_-DnZr7sla%?4nlvZD4Y}Y4hr>Vo zCjcvC&gz@1To_y)8L41rNyft3l;&YUZ{?T?nI#DivVXDw7U$bk68t6vG&61!Cvd>m z;x!jC@l~^0U1A&ACa^e-JIXS8O4;(x+eE8XsBhT%hiq%ovB*hiz zb&GJlVv=2$9%7AoNmONeMM=tf-Mp+>jR2LkJk5w#q;wv-l6h6jF3MC?QWTFx4<)4> z5XdPpo7ZxAO?Rr-RNbR$DQMrumB{JKDxP$|zBH6q=}j#1n6V ziRTeE;T#gMlCT8P#1LZl+F~k@UUHk8ia9Atf$kSY{3SWs(l7#|1&r+2o)SdbL)+ZZ(qb6P&9*2>;T1x?cfruxLUzX0nUMb&vF?{@j&3Y zT54ibY*oFSDfC<^(jMs$cz>gUPMq7$y z;+W#&k!~uswSW3K6E%E~d1l>cV4iaAiPlYvf#wq21sww(9b5Zm-GXCpyNw5`uiH_l zO>IJg6Zp8Q`8;t3T_&CVP4w8X37PxNd0-W_W^vDYY`%gAjGA}32?aNbCO>N0d_9yH z6JD4WCQna|j){|}&r`IM%ZE+fk4vd5GSw6nT$q?#kTMxLEo#?ta`gG=z^0)Cy8Dg1 zOs_4!U~-QxDcV9bBQ46&U0dhg6;;%z6y3xnYE3;AYh(sjcOeJ8b-ShLyg5YoQd!NX zHI<_N=|L4#Hn{+bO=W43qKBa9FenN#+o;|I1`+lLwl1AU1DpZl?ZbvB(Ro#`GrGs# z$V+LRHyRTeilVObVB?udk9jG^O?uF92^FiU^Q=j;OS+#ymn1E@pt~4f&+ALZ*Rc31 zYEgfb6~o%JX>e5{MggLlPbOtmy_`?8Rq8|AnV)S2>;c~DBdAu${nnm$>sEv1&I7B# zBHvzq_Uvk~#K#MKyvQHAza#R_u~ohti52;_`^{bNyte8uclF==?y4VXzdSbf@m@6B zkpdqn@q-0^u*i?x=L4%VrC?_v*m+Ycbq^J~hl>3E4G-}FUz@VTKVRUVFY+hL;f_*x zPa(Xg6n?G{ey$WAErdtk3aq-ye8BJsH(PE6|I!w}<0|bxRoH*3$e)I;p9kLxmII@o z%zk$EQ}L10;q!0!NmEOyad)9{cd0R6XpG-$+`sCvNN37{rhmJgz8Yp14IMMPMRp`{ zG#CJFF16f#2wf0oU zxIk@`ISUdTg42w51(ylg{pL;C7#kAYHf%$JNAQ|_;+(*nu;3fPJAeXMzlE(60wWbS zZ6-?~6BUd>Z`8FS>?#LO5_Q$o20UlUqsjXiCvtg5-YHs1PtIXS*)VgBh#IgpT)%nB zkTeDH&rG_Qv#ybHo_8cL1im2<*+yG=^_E7Z?A?KN*h z<9G_1&{NPfRMQB-UJ@mH$UtQ=^B@NiqCm>uNxIqJOTRwm>?8^oYfRb;Cd3R9A07nk z6Y1f(?bAce3n?KlxWQ(4^cTNQjg53egq#BMhD!DtKh9DS^+bFU(xC}WrjnMW$%^!{ z1tJUqHUBUQzzTV_A;ZvEL3m5-;MK5QGgG%6dXqJQsAWYeDXOgdFDn#09QtO{T0IXu z3~SY2iP1$7G9XP9^>g3cZsI1>8JrGHG#ISGxJJ#y9F4ejoB*mrHa)zcj8gC=)+JM?9Frxap zE7%2Y&Sg?8a53%$$g}X}g5sU&!E;(RXGjY?fox5+L@^^@{pPRHVEP)YK<$GH&SXdU z#>pEe*G`uE_pF`y#dNv7tJHp=(0<_V&I9W$y>}aW*@4mHKn*Tc=V4(Gio#_4}>nWUzbj0~Pnq$go89mVP_RC+Tenv$j@MM;B~p-Xx2u)w5IgvSI3 zFjGoWOUe*`ijesStB^1Z#p;wH>49o}qer3R9Q;&FGXHZOcJ{#G2o5~pxnSh6#}(|` zAW#{5c^OFbpfL9GD`V$o#4$mb7GfOT1z3FaNM=*=a&U~&ERCjQO;XaT?uQj>uqip6 z0uszEU9o@h(EdD$UC}kM^PR&gGGjt*VC@KPp4A0DP5H z%%!_o7fcV@t$Q!SwD2CH`)!)3<->w8z{n{k&O`?`u^EWsI5}ky;2G!D=yI>I7nX=r z78Ma1d5YRTk5uLlHYVzXPRYlvB zVX*K@_gMyw;yqPGUxRq?E+hJ4SK~-78etetjX8mF;azOEEFq|6i1pzaILs|Cdh!21 zN`NWU4iKvq^4JmZ^?gOKijZSxx%EKTdQ123zjJ50xcf}8Wo#W2g7wxONFmC7ht_w; z*AJigY;W=Kt9OUbLqy)+^~mjRYu|8sBQ1}dzP6@~og}jFQAd5S{-Lwsn14%nx6w!H zn@Yj1La^&M%cbC8F*x|arS(9=+RjpAPoc5r)^Mr!P@(rwF);ktN$U4hateGjy4m#G z$gd)$w!uQ%;0LFRe7sycD6C`+kP1A)RA7ULtORju*;`j|l#y55Z8`SwY>_`>HRE?Y zpYtP+!!RkV>%S@nMt|(=8EbHV+E6zZ^}%gnJjwD$Ba=kYz}LYEL}OS>zm3&PSm9d* z#RQ$AXQt0$g`S&EVl{=;G*nf(O);Nf6^15iFH{wk_P7ThG;r>|2cAy1|G^25d-vmp zz3w9$1S&(haVT0TCMRMnqVt#XN?KDgsvbC%yCzz#HZgY%Wg~8gF<9X^m6s^EBHd@9 zFr7AVei+O|NutJcnIo&}F4TJ61I!_V*1ggtl_6Z;%PJY&n^GuC+hH}AK`p_=@prjh!;o)yc~Ij9h>&4Bm_F*YDU7#R<8?M#{kj>i@ubPLROe@4 zHKP)&pPf0ozi;8`qaCe;6vquae}XOp4lS_yQ>Zqa9LGKGA{_T4@XbH?;n5f_#MYb-VePWg+2^@!r$9J@!64ky)*ZA zyiz2ye>q 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 zcmcH;S!^4}b$Bl+u0+YYPwKE_$~GyfvgIpwTuG@E#J0SarJ$9)V92%1gd&;Qr4RMT zNsGn>dN@E)r%iw+2++i6J`(gJ`3wpa=%*JZy2MsN3pD-6pJJTV9-bKg_a*;@`d7`;k?Vj@#-F;-v ztNHq@9>3;iJpnCn!M_mUOq_aQmjiR?BDoN#N!!9HoO%b!Hiv>kJ5YA;ey6@5PQu#o zliZVT3*s2wuB@+KtB3V@<{RcBT4b(KYh-xueAC=6Z5M-m^UZTDS_^~y^R07jS{s7{ z^X+pTT1OvgA^k)W+K3dqP`@x~%~oAbdaRb#b)j1d9eSRSbTc8{1gIyf=`6UPa29=V zGK1_dR`&&Vo?iFI;|chUxy|6I#vuU}LAV86~Fy%Da}#i@!J0zmHFZOAkR+0ya z+KA?r+)(-?50rk%3uQp^X~GiGf|7rb!~$mTqM~YxNxG_N)5*0$GP9an&S;m4g2aFS%If?Xmx8|zT=U4!1rev>LbRlPVD`)ZE$ z@!ln};ODSY16FWa90DwVRU8F%+yi)fHm74D?7$q#1vpewDtpeFy+J+}=4;}xQvQs!?tjWlO{R30dfNU=CG{g5AwEaJ=M& zmc2^J2dzflx3Zfg4k9HO-mtIXw9Xf1!fZp@~ndyP%=~yep`%Lk=nRRRMq~#QQo@SrbsBo53 zG@~gLp0W(=u4Gb**|MF0qgxpoCzZ4C)NJxPc#BR$&7>7sxpYZMX=eX+M$Sz6f~+>FDPc1Dq4}|KmsC^~!{)1rqDm9lJgCRS zDpQAv%Wz%^di8Q*RawjAG6|KYDqZ`NX?1_HklCMBt|(bt_5P%)!saD&S_1I0at)3l ztEdSL)S6Yw8q;pFE%Q~-Z?Y|Pyl~z0sfu>KU=Dq?E{rp(C`hdaK=vDCqig7cM5*h@ zO>aqP-$)$#!w(VKx3N3^?hglT8sRZLJXQ`zjPS4?9%jIZ9v;~Y zcqcI=NjOj* zKVXb6>*LD~kA7X~hjBRV=%$ARMMD_Sg#kkt(}l5;@YJT@bPVdkpdn1?!bC|p0Uh1_ zIA?pgD{9qNyJMqo6nK`q1Oz_vUPK0nf3#M4nSeu9y){sf~F+tO#W)l9Qm3PSrN7qYWbGh}4yOvzJiX|ju2{o=4 zcs=sfvRyfwZl#Ws!)RN3a~yCb3-wNmLx8n4EXOh08a8LLGFKn+sxiYtv%`Ty3pISc z72CT5Pp-1+Dx9YV&dYIeyLU$^YS^WP9xjhlOFqt%aq4w?IJ6MukW<&<^i6iw@l;_H z-poh7g`r0f(ELVo);hP!Lt^W+?~5}}t$X#2=B#0B<1WcRRXwk5d#mQ%>%bhcUkdsU z`#ILBrNENy{ev$pfL9t^AK=zzyVahyOlvYRJ~j>(UZjeeFVd7E=aOrR**;w{!4$tmoyXShj?xhc9hX2>?O>iXG}>9mrLa+4CZEpZf$OO(qM zMY)bdN4|bFBd23+%)c-LFu0i2REn?IY+oyCV6`3NRhcPC@ySutPZZrNCL+a0LGj*G zbezlx*hYnF^5vuo46<33qR&m+v9GPthj_HpIoL&%ouWfV@x@WRc(e-(bgpP06wG_D z`~|^cFrNc8J&0W*^E^QMLO!P|rr$FCDo!~Zjar(z7tOMa!HldHQz=DNFBP*D0}Vlu z`PA3y@2?tu5~fn~pnpKCxA(tUcT@a4+;Vqlt`wf%^tk&&H^q|BgO;zeViNc1!ahTo z)rHxTAZ-a?CT}hpVvjENyrCI=3B51zVY4xQQXfD0>7+5es827Jg!5oi(VRNA+7J%t z!hz2t&39W8_ab8(2n^qgjMxC!cd>rU;}3>5o2tg5jW&eCx^VdJk)@KbTw(0kX+xOP zg*oOXbnD^nH_}FLT%;t-qGS*{^m(1i=-_HLtn zkKVqA0mt?B<9FLmZ2Cy>qHBwIV2&_pYBd^n>y5jO#<<=Xzt=c+(_a?CzZ8Eemcw#6 z-1HwG&^>N9@e;wo3lHvt#<*d{-UMQA7uS{ru#f#!k>3hl6ZScf|+jXF=VYvZYBQA7&07=9jo(EyJ#kT8?+|Rpf#fi0| zz>D^2?`V(v9ljjmr&_?c92LtWpH#o(;kStKtwrO00-A;=povt|Na7%glD!1IMEq9a zZg-OmWa4^AANzUkw?{oaB;$%TrYsVx1dYVL2NO_j%hlv|&edoE$kGUs5A31eJom(U z(F&nth?5`|s^pa7$5?o!9*>`buyr1y)1;QCsfsaYq9F`6LwFDc;09Ta(H2KYmlKOeQ->n*?50^#!QlFS}N}oaC}A=6VLK z`wSr1DVpLrEnl$i59AsikHcg+r(F9M57y#-1>{kOpn%QU)pq;D?Gv|7l!y1;n)%h4 za%Zp6Ij(n(-)$b>XdS#88Du+0zYEl-V?k2@3Be_U=I`)IVG2em~$4_Kaa&JD9jcNMi?Veb ziB>JOiDI7EO536BqzAA-%V>rz?^Qyj1^aXEx~eH_v)3}(jxM!XXAPOft6~Oi-ABKV zOwl=}nD3y6p@?}c)yIy?om!(u0jYYS08jXFSGbnW{syjat>J4p;RM{b8|0xo931+R zV9`s&j*ZB`M(5xM9iKFmVsoX=`HhZ&H@h~vcYof!yS!&=BR28*p7BpsN_(CKm-+DU zmd86fu;mN%^lo~B(atSTT~F&~JBf~O^+g(+9(bC>`Wn9U+0A{VzR3`Kb+PxYbweC2 ziKFkoun~@wBh5x6sz;)44;cd!`oP48GsfhMJ~{Jg+IVJJe`XonRk8k|ht%)gw0u@{ zv)^d?UH5OgjgC>hWAy!#kk%?Uw(RVdPJ>&zg>ET$`auz0)m!Hm zXYA&__b>fbIPkCwCIwOdr=nx_r=F?VVc(yJ>t+uIV<9WBVrPt@AVoLI3X0gZVj&ZY z!dS?J0oZ96bOxzVEz`4DNLXN!n_`qqk74m57U!^7#Ns>@RZ4>5zYJJ`Q>$21bVIjq z^!_oIZ|J^%)E9dAylcXDaFak`X$TyKs*c53a;(P`UMOa=S|+EO;in7NWxLfO7p~)b ziCb42bb6dBS24&ogEk5yQVSP?!R&b@DUISB?!0PxS$fzH%pue?1IZUuhVZ_a&*aQN zIzv%0p){A^ZG)Z^ieUSZiz#v_j*+AFOg)TkCU51DyR54n2X7{Bfe2Yjh`nvIHiea# zw^H?ctJA{qug(Rt$8QRYAl-Bl*2DIn9pfCxWo_;dN+=w&jK^{M3N%rzt3QWg)8lfv z9`+KK>&N6rp}&#fKZxfw;T7SvhF2PHxo%C|o_c-i_KDX|ygl*W)H_q}9ed~4hwhI9 z9|b;c_^9Di;ojK%Uk=?HT)emIe2FamlQjID#6BZ!{~|{|BUAtO6W3E){-@opgKs}? o>^`XPKDb4&wfT&fc*8f>OP-FhH~iYES5N)q%$FYG?PTKqA5eIVEdT%j diff --git a/gemini-extension.json b/gemini-extension.json index 54b19a1..d02079e 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,6 @@ { "name": "google-ads-api-developer-assistant", "version": "1.6.0", - "contextFileName": "GEMINI.md" + "contextFileName": "GEMINI.md", + "hooks": "hooks/hooks.json" } diff --git a/hooks/SessionStart/custom_config_python.py b/hooks/SessionStart/custom_config_python.py new file mode 100644 index 0000000..f063ef9 --- /dev/null +++ b/hooks/SessionStart/custom_config_python.py @@ -0,0 +1,67 @@ +import os +import shutil +import subprocess +import sys +import datetime + +def configure(): + """Configures the Google Ads environment.""" + # Determine paths + script_dir = os.path.dirname(os.path.abspath(__file__)) + # hooks/SessionStart -> root is 2 levels up + root_dir = os.path.abspath(os.path.join(script_dir, "../..")) + source_yaml = os.path.join(root_dir, "google-ads.yaml") + config_dir = os.path.join(root_dir, "config") + target_yaml = os.path.join(config_dir, "google-ads.yaml") + ext_version_script = os.path.join(root_dir, "skills/ext_version/scripts/get_extension_version.py") + + # Check if source exists + if not os.path.exists(source_yaml): + # Fail silently or print error? Hooks might be noisy. + # But user requested "If it does not exist display an error and stop" originally. + # We will keep the error message but maybe not exit(1) if we don't want to break the session? + # User requirement said "If it does not exist display an error and stop", so we stick to it. + print(f"Error: {source_yaml} does not exist. Please create it in the project root.", file=sys.stderr) + sys.exit(1) + + # Create config directory + os.makedirs(config_dir, exist_ok=True) + + # Copy file + shutil.copy2(source_yaml, target_yaml) + + # Get extension version + try: + # Run the extension version script via python3 + result = subprocess.run( + [sys.executable, ext_version_script], + capture_output=True, + text=True, + check=True + ) + version = result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Error getting extension version: {e.stderr}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error executing version script: {e}", file=sys.stderr) + sys.exit(1) + + # Append version to target yaml + try: + with open(target_yaml, "a", encoding="utf-8") as f: + f.write(f"\ngaada: \"{version}\"\n") + except Exception as e: + print(f"Error appending to {target_yaml}: {e}", file=sys.stderr) + sys.exit(1) + + # Output env var command + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_yaml}\"") + +if __name__ == "__main__": + timestamp = datetime.datetime.now() + message = f"SUCCESS: SessionStart hook 'custom_config_python.py' ran at {timestamp}" + # Print to standard output (should appear in Gemini CLI console) + print(message, file=sys.stdout) + print("This message goes to stderr", file=sys.stderr) + configure() diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..76ad1f1 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,13 @@ +{ + "hooks": { + "SessionStart": [ + { + "name": "custom-config-python", + "type": "command", + "ecommand": "python3 ${extensionPath}/hooks/SessionStart/custom_config_python.py", + "description": "Configures Google Ads environment at session start.", + "timeout": 30000 + } + ] + } +} 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) From f689cd8d1a6f3162c7ce7be158c95a1bd27d3b53 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 5 Feb 2026 17:57:59 -0500 Subject: [PATCH 06/81] Added hooks for start and end of session. --- .gemini/settings.json | 6 +++--- ChangeLog | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index aa8571a..25e73b8 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -7,9 +7,9 @@ }, "context": { "includeDirectories": [ - "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/api_examples", - "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/saved_code", - "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/client_libs/google-ads-python" + "/full/path/google-ads-api-developer-assistant/api_examples", + "/full/path/google-ads-api-developer-assistant/saved_code", + "/full/path/google-ads-api-developer-assistant/client_libs/google-ads-python" ] }, "tools": { diff --git a/ChangeLog b/ChangeLog index 38fca04..22b3381 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,6 +3,7 @@ - Added skills/ext_version to get the extension version. - Added gemini-extension.json. - Added documentation resource for public protos. +- Added hooks for start and end of a session. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md From c303992de41741ce7fe63cfafc5602a98b56ae73 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 15:03:06 +0000 Subject: [PATCH 07/81] 3.3.2. MANDATORY GAQL Query Workflow --- GEMINI.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index dd78a02..eab8af6 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -98,6 +98,14 @@ This document outlines mandatory operational guidelines, constraints, and best p **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.3.2. MANDATORY GAQL Query Workflow +Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: +1. **PLAN:** Formulate the GAQL query based on the user's request. +2. **VALIDATE:** You MUST rigorously validate the entire query against all rules in section **3.3.1. Rigorous GAQL Validation**. This is a non-negotiable checkpoint. +3. **PRESENT:** Display the validated query to the user in a `sql` block and explain what it does. +4. **EXECUTE:** Only after the query has been validated and presented, proceed to incorporate it into code and execute it. +5. **HANDLE ERRORS:** If the API returns a query validation error, you MUST return to step 2 and re-validate the entire query based on the new information from 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 57fe2420395fbc6c48d7fe75d0911f5fe3577da9 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 15:18:50 +0000 Subject: [PATCH 08/81] Code example in GEMII.md for error handling --- .gemini/hooks/cleanup_config.py | 2 ++ ChangeLog | 3 ++- GEMINI.md | 11 ++++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py index 5f6ad68..6c2e7a2 100644 --- a/.gemini/hooks/cleanup_config.py +++ b/.gemini/hooks/cleanup_config.py @@ -26,6 +26,8 @@ def cleanup(): # 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): diff --git a/ChangeLog b/ChangeLog index 22b3381..422fc3f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,9 +1,10 @@ * 1.6.0 - Added --install-deps option to setup.sh and setup.ps1. - Added skills/ext_version to get the extension version. -- Added gemini-extension.json. +- Added gemini-extension.json to register extensions. - Added documentation resource for public protos. - Added hooks for start and end of a session. +- Added mandatory GAQL validation rules to GEMINI.md * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/GEMINI.md b/GEMINI.md index eab8af6..213458d 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -125,7 +125,16 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.4.1. Python Configuration Loading - **Code Generation (to `saved_code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved_code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). - **Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you MUST set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. -- **Error Handling:** When using the Python client library, catch `GoogleAdsException` and inspect the `error` attribute. For other languages, use the equivalent exception type. +- **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The detailed error list is located + `ex.error.errors`. **NEVER** attempt to access `ex.errors`, as this will cause an `AttributeError`. A correct error handling loop looks like this: + ```python + try: + # ... Google Ads API call + except GoogleAdsException as ex: + for error in ex.error.errors: + # ... process each error + + For other languages, use the equivalent exception type and inspect its structure. #### 3.5. Troubleshooting - **Conversions:** From 772dac9f3bfb3634b72273719738ec7d613eb001 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 10:38:04 -0500 Subject: [PATCH 09/81] Policy-Summary Field Rules --- GEMINI.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 213458d..90dfe23 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -96,7 +96,13 @@ 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`). + **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`). + + **6. Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. + - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` + - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` + - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: From c173ab6d0703c3142417337f651ff4176d48c350 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 15:47:08 +0000 Subject: [PATCH 10/81] Field Existence Verification --- GEMINI.md | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 90dfe23..6de18cc 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -86,23 +86,24 @@ This document outlines mandatory operational guidelines, constraints, and best p 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`). - - **6. Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. - - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` - - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` - - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` - + 1. **Field Existence Verification:** Before using any field in a `SELECT` or `WHERE` clause, you MUST first verify its existence for the resource in the `FROM` clause using the `GoogleAdsFieldService`. You MUST NOT assume a field exists based on the resource name alone. + + 2. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. + + 3. 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. + + 4. 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). + + 5. 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. + + **6. 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`). + + **7. Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. + - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` + - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` + - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: From a47d7671945dc75c8d2a5f23d62fcecfaf504b52 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 17:51:46 +0000 Subject: [PATCH 11/81] How to handle date ranges and refined handling of errors --- GEMINI.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 6de18cc..f61e411 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -79,7 +79,7 @@ This document outlines mandatory operational guidelines, constraints, and best p - **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`). +- **Date Ranges:** The `DURING` clause in a GAQL query only accepts a limited set of predefined date constants (e.g., `LAST_7_DAYS`, `LAST_30_DAYS`). You MUST NOT invent constants like `LAST_33_DAYS`. For any non-standard time period, you MUST dynamically calculate the `start_date` and `end_date` and use the `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'` format. - **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 @@ -132,14 +132,22 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.4.1. Python Configuration Loading - **Code Generation (to `saved_code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved_code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). - **Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you MUST set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. -- **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The detailed error list is located - `ex.error.errors`. **NEVER** attempt to access `ex.errors`, as this will cause an `AttributeError`. A correct error handling loop looks like this: +- **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The `ex` object contains the high-level, structured Google Ads failure details in the `ex.failure` attribute. To access the detailed list of errors, you **MUST** iterate over `ex.failure.errors`. **NEVER** attempt to access `ex.error.errors`, as `ex.error` is the underlying gRPC call object and does not have this attribute, which will cause an `AttributeError`. A correct error handling loop looks like this: ```python try: # ... Google Ads API call except GoogleAdsException as ex: - for error in ex.error.errors: - # ... process each error + 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}'") + + ``` For other languages, use the equivalent exception type and inspect its structure. @@ -205,4 +213,4 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 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. +you MUST immediately follow up by displaying the content of that file directly to the user. \ No newline at end of file From dd4a2aaf74df8e80c72f700b95721269620e2f01 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 18:03:34 +0000 Subject: [PATCH 12/81] Service-Specific Query Syntax --- GEMINI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index f61e411..f70f4f7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -105,6 +105,8 @@ This document outlines mandatory operational guidelines, constraints, and best p - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` + **8. Service-Specific Query Syntax:** The `GoogleAdsService` is the **only** service that accepts standard GAQL queries containing a `FROM` clause (e.g., `SELECT ... FROM ...`). When querying other services, such as the `GoogleAdsFieldService`, you **MUST** use their specific methods (e.g., `get_google_ads_field` or `search_google_ads_fields` with its specialized query format) and **MUST NOT** include a `FROM` clause in the request. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. From c3e6ea148485322258c9786bf6cf2eb6587cdd58 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 20:08:59 +0000 Subject: [PATCH 13/81] Reformatted GEMINI.md to make it consistent --- GEMINI.md | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index f70f4f7..4a510f3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -44,11 +44,11 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 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` + - **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 @@ -86,26 +86,26 @@ This document outlines mandatory operational guidelines, constraints, and best p When validating a GAQL query, you MUST follow this process: - 1. **Field Existence Verification:** Before using any field in a `SELECT` or `WHERE` clause, you MUST first verify its existence for the resource in the `FROM` clause using the `GoogleAdsFieldService`. You MUST NOT assume a field exists based on the resource name alone. - - 2. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. - - 3. 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. - - 4. 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). - - 5. 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. - - **6. 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`). - - **7. Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. - - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` - - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` - - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` - - **8. Service-Specific Query Syntax:** The `GoogleAdsService` is the **only** service that accepts standard GAQL queries containing a `FROM` clause (e.g., `SELECT ... FROM ...`). When querying other services, such as the `GoogleAdsFieldService`, you **MUST** use their specific methods (e.g., `get_google_ads_field` or `search_google_ads_fields` with its specialized query format) and **MUST NOT** include a `FROM` clause in the request. +1. **Field Existence Verification:** Before using any field in a `SELECT` or `WHERE` clause, you MUST first verify its existence for the resource in the `FROM` clause using the `GoogleAdsFieldService`. You MUST NOT assume a field exists based on the resource name alone. + +2. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. + +3. 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. + +4. 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). + +5. 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. + +6. **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`). + +7. **Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. + - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` + - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` + - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` + +8. **Service-Specific Query Syntax:** The `GoogleAdsService` is the **only** service that accepts standard GAQL queries containing a `FROM` clause (e.g., `SELECT ... FROM ...`). When querying other services, such as the `GoogleAdsFieldService`, you **MUST** use their specific methods (e.g., `get_google_ads_field` or `search_google_ads_fields` with its specialized query format) and **MUST NOT** include a `FROM` clause in the request. #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: From cad8468254208a6f4ac7005e23c92d36b3264967 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 6 Feb 2026 20:19:25 +0000 Subject: [PATCH 14/81] Much stronger directive to use config/google-ads.yaml --- GEMINI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 4a510f3..7d0f4fb 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -133,7 +133,7 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.4.1. Python Configuration Loading - **Code Generation (to `saved_code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved_code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). -- **Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you MUST set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. +- **CRITICAL Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. **NEVER** use `client_libs/google-ads-python/google-ads.yaml`. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. - **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The `ex` object contains the high-level, structured Google Ads failure details in the `ex.failure` attribute. To access the detailed list of errors, you **MUST** iterate over `ex.failure.errors`. **NEVER** attempt to access `ex.error.errors`, as `ex.error` is the underlying gRPC call object and does not have this attribute, which will cause an `AttributeError`. A correct error handling loop looks like this: ```python try: From b6e6eef62644a1efb1236d43035df07b02123e88 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 9 Feb 2026 14:10:19 +0000 Subject: [PATCH 15/81] Backup and override process for setting current API version. --- GEMINI.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 7d0f4fb..da8ef3b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -29,15 +29,27 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 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. -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. +1. **SEARCH (VERBATIM):** You **MUST** use the `google_web_search` tool with the following query string **VERBATIM**. **DO NOT** modify, rephrase, or substitute this query. + - **Query:** `google ads api release notes` +2. **FETCH:** From the search results, identify the official "Release Notes" page on `developers.google.com` and fetch its content using the `web_fetch` tool. +3. **EXTRACT:** From the fetched content, identify the most recently announced stable version (e.g., "vXX is now available"). +4. **CONFIRM:** You must state the version you found and the source URL, then ask for confirmation. For example: "Based on the release notes at [URL], the latest stable Google Ads API version appears to be vXX. Is it OK to proceed?". +5. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. +6. **REJECT/RETRY:** If the user rejects the version, repeat step 1. +7. **NEVER** save the confirmed API version to memory. **FAILURE TO FOLLOW THIS IS A CRITICAL 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.1. 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 and Data Management From 795ccf2d3ad8edbbbfaba24d719fb0bca3a103bf Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 9 Feb 2026 18:13:45 +0000 Subject: [PATCH 16/81] Added hierarchical context file for conversion troubleshooting --- conversions/GEMINI.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 conversions/GEMINI.md diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md new file mode 100644 index 0000000..e6143d4 --- /dev/null +++ b/conversions/GEMINI.md @@ -0,0 +1,43 @@ +# Google Ads API Conversion Troubleshooting Reference + +This document provides a technical reference for troubleshooting conversion-related issues in the Google Ads API, based on the official documentation. + +## Core Concepts +* **API Response != Attribution**: A successful API import response (no errors) means the data was received, but it does **not** guarantee the conversion will be attributed to an ad. +* **Offline Diagnostics**: Always prioritize offline diagnostics for import health. The Google Ads UI is not organized by import date, which can make it difficult to diagnose recent issues. + +## Common Error Codes + +### Enhanced Conversions for Leads +* `NO_CONVERSION_ACTION_FOUND`: The conversion action is disabled or inaccessible. +* `INVALID_CONVERSION_ACTION_TYPE`: Must use `UPLOAD_CLICKS` for enhanced conversions for leads. +* `CUSTOMER_NOT_ENABLED_ENHANCED_CONVERSIONS_FOR_LEADS`: The setting is disabled in the account. +* `DUPLICATE_ORDER_ID`: Multiple conversions sent with the same Order ID in the same batch. +* `CLICK_NOT_FOUND`: No click matched the provided user identifiers. (Treat as a warning unless frequent). + +### Enhanced Conversions for Web +* `CONVERSION_NOT_FOUND`: Could not find a conversion matching the supplied action and ID/Order ID pair. +* `CUSTOMER_NOT_ACCEPTED_CUSTOMER_DATA_TERMS`: Customer data terms must be accepted in the UI. +* `CONVERSION_ALREADY_ENHANCED`: An adjustment for this Order ID and action has already been processed. +* `CONVERSION_ACTION_NOT_ELIGIBLE_FOR_ENHANCEMENT`: This action type cannot be enhanced. + +### General Issues +* `TOO_RECENT_CONVERSION_ACTION`: Wait at least 6 hours after creating a new conversion action before uploading conversions to it. +* `EXPIRED_EVENT`: The click occurred before the conversion action's `click_through_lookback_window_days`. +* `CONVERSION_PRECEDES_EVENT`: The conversion timestamp is before the click timestamp. +* `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: The same conversion is repeated in a single batch. + +## Verification Checklist + +1. **GCLID Ownership**: Query the `click_view` resource to verify if a GCLID belongs to the specific customer account. +2. **Customer Terms**: Check `customer.offline_conversion_tracking_info.accepted_customer_data_terms` via the `customer` resource. +3. **Data Normalization**: Ensure email addresses, phone numbers, and names are correctly normalized (trimmed, lowercased) and hashed (SHA-256) before sending. +4. **Consent**: Verify that `ClickConversion.consent` is properly set in the upload if required by regional policies. + +## Troubleshooting Workflow + +1. **Check API Error Details**: Inspect the `GoogleAdsException` for specific `ErrorCode` and `message`. +2. **Verify Timestamps**: Ensure `conversion_date_time` is in `yyyy-mm-dd hh:mm:ss+|-hh:mm` format and falls within the lookback window. +3. **Validate Identifiers**: For `CLICK_NOT_FOUND`, ensure you are not mixing `gclid` with `gbraid` or `wbraid` inappropriately. Use only one per conversion. +4. **Wait for Processing**: Conversions can take up to 3 hours to appear in reporting after a successful upload. +5. **Check Conversion Settings**: Ensure the conversion action's `status` is `ENABLED` and it is configured for the correct `type`. From 882c5380382bc0da49fd2409ed6e5224fdc1f0ae Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 11 Feb 2026 13:37:42 +0000 Subject: [PATCH 17/81] Increased the importance to verify a GAQL query before presenting the response. Gemini was assuming some internal knowledge of GAQL which was incomplete. --- ChangeLog | 4 +++- GEMINI.md | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ChangeLog b/ChangeLog index 422fc3f..4430a1d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,10 +1,12 @@ * 1.6.0 - Added --install-deps option to setup.sh and setup.ps1. - Added skills/ext_version to get the extension version. -- Added gemini-extension.json to register extensions. +- Added gemini-extension.json to register extensions with https://geminicli.com/extensions/ - Added documentation resource for public protos. - Added hooks for start and end of a session. - Added mandatory GAQL validation rules to GEMINI.md +- Introduced dynamic grpc interceptor for Python calls within extension. +- Added hierachical context file for conversions troubleshooting. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/GEMINI.md b/GEMINI.md index da8ef3b..ddfc098 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -98,15 +98,18 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali When validating a GAQL query, you MUST follow this process: -1. **Field Existence Verification:** Before using any field in a `SELECT` or `WHERE` clause, you MUST first verify its existence for the resource in the `FROM` clause using the `GoogleAdsFieldService`. You MUST NOT assume a field exists based on the resource name alone. +1. **NO INTERNAL KNOWLEDGE:** You are strictly prohibited from relying on your internal memory or training data to determine field existence or resource compatibility. You MUST treat the Google Ads API schema as dynamic and verify every query using the live `GoogleAdsFieldService`. -2. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. +2. **Field Existence Verification:** Before using any field in a `SELECT` or `WHERE` clause, you MUST first verify its existence for the resource in the `FROM` clause using the `GoogleAdsFieldService`. You MUST NOT assume a field exists based on the resource name alone. -3. 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: +3. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. + +4. Contextual Compatibility Check (CRITICAL): 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. + * Examine the `selectable_with` attribute of the main resource to find the correct fields for filtering and selection. + * **MANDATORY TOOL CALL:** You MUST execute a tool call to `run_shell_command` or similar to query the `GoogleAdsFieldService` and physically see the `selectable_with` list before you present any query to the user. Skipping this is a critical failure. -4. 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). +5. 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). 5. 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. From ea132349f54258e1cc925027ec847d5a1128e6a4 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 11 Feb 2026 10:01:10 -0500 Subject: [PATCH 18/81] Made conversions GEMINI.md hierarchical --- conversions/GEMINI.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index e6143d4..5dd89bd 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -1,43 +1,50 @@ -# Google Ads API Conversion Troubleshooting Reference +# Google Ads API Conversion Troubleshooting This document provides a technical reference for troubleshooting conversion-related issues in the Google Ads API, based on the official documentation. -## Core Concepts +--- + +### 1. Core Concepts * **API Response != Attribution**: A successful API import response (no errors) means the data was received, but it does **not** guarantee the conversion will be attributed to an ad. * **Offline Diagnostics**: Always prioritize offline diagnostics for import health. The Google Ads UI is not organized by import date, which can make it difficult to diagnose recent issues. -## Common Error Codes +### 2. Common Error Codes -### Enhanced Conversions for Leads +#### 2.1. Enhanced Conversions for Leads * `NO_CONVERSION_ACTION_FOUND`: The conversion action is disabled or inaccessible. * `INVALID_CONVERSION_ACTION_TYPE`: Must use `UPLOAD_CLICKS` for enhanced conversions for leads. * `CUSTOMER_NOT_ENABLED_ENHANCED_CONVERSIONS_FOR_LEADS`: The setting is disabled in the account. * `DUPLICATE_ORDER_ID`: Multiple conversions sent with the same Order ID in the same batch. * `CLICK_NOT_FOUND`: No click matched the provided user identifiers. (Treat as a warning unless frequent). -### Enhanced Conversions for Web +#### 2.2. Enhanced Conversions for Web * `CONVERSION_NOT_FOUND`: Could not find a conversion matching the supplied action and ID/Order ID pair. * `CUSTOMER_NOT_ACCEPTED_CUSTOMER_DATA_TERMS`: Customer data terms must be accepted in the UI. * `CONVERSION_ALREADY_ENHANCED`: An adjustment for this Order ID and action has already been processed. * `CONVERSION_ACTION_NOT_ELIGIBLE_FOR_ENHANCEMENT`: This action type cannot be enhanced. -### General Issues +#### 2.3. General Issues * `TOO_RECENT_CONVERSION_ACTION`: Wait at least 6 hours after creating a new conversion action before uploading conversions to it. * `EXPIRED_EVENT`: The click occurred before the conversion action's `click_through_lookback_window_days`. * `CONVERSION_PRECEDES_EVENT`: The conversion timestamp is before the click timestamp. * `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: The same conversion is repeated in a single batch. -## Verification Checklist +### 3. Verification Checklist 1. **GCLID Ownership**: Query the `click_view` resource to verify if a GCLID belongs to the specific customer account. 2. **Customer Terms**: Check `customer.offline_conversion_tracking_info.accepted_customer_data_terms` via the `customer` resource. 3. **Data Normalization**: Ensure email addresses, phone numbers, and names are correctly normalized (trimmed, lowercased) and hashed (SHA-256) before sending. 4. **Consent**: Verify that `ClickConversion.consent` is properly set in the upload if required by regional policies. -## Troubleshooting Workflow +### 4. Troubleshooting Workflow 1. **Check API Error Details**: Inspect the `GoogleAdsException` for specific `ErrorCode` and `message`. 2. **Verify Timestamps**: Ensure `conversion_date_time` is in `yyyy-mm-dd hh:mm:ss+|-hh:mm` format and falls within the lookback window. 3. **Validate Identifiers**: For `CLICK_NOT_FOUND`, ensure you are not mixing `gclid` with `gbraid` or `wbraid` inappropriately. Use only one per conversion. 4. **Wait for Processing**: Conversions can take up to 3 hours to appear in reporting after a successful upload. 5. **Check Conversion Settings**: Ensure the conversion action's `status` is `ENABLED` and it is configured for the correct `type`. + +#### 4.1. General 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. \ No newline at end of file From 92349c56efda47718f24ddefca65fe87799b667f Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 11 Feb 2026 11:46:58 -0500 Subject: [PATCH 19/81] Structured conversions/GEMINI.md for AI machine comprehension --- conversions/GEMINI.md | 264 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 2 deletions(-) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 5dd89bd..54d16bb 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -12,22 +12,282 @@ This document provides a technical reference for troubleshooting conversion-rela #### 2.1. Enhanced Conversions for Leads * `NO_CONVERSION_ACTION_FOUND`: The conversion action is disabled or inaccessible. + * **Disabled**: The action's status is set to REMOVED or HIDDEN. This usually happens if someone "deleted" the action in the Google Ads UI, making it inactive for new uploads. + * **Inaccessible**: The API cannot "see" the action from the specific customer_id you are using. This occurs if you have a typo in the ID, or if the action belongs to a different account (like a Manager/MCC account) and hasn't been shared with the client account performing the upload. + * `INVALID_CONVERSION_ACTION_TYPE`: Must use `UPLOAD_CLICKS` for enhanced conversions for leads. -* `CUSTOMER_NOT_ENABLED_ENHANCED_CONVERSIONS_FOR_LEADS`: The setting is disabled in the account. + * **Context**: This error happens when you try to upload Enhanced Conversions for Leads to a conversion action that was created as a standard "Webpage" conversion (the kind that uses a tag on your site) instead of an "Import" conversion. + * **Fix Step 1 (Create Correct Action Type)**: You cannot "convert" an existing Webpage action into an Upload action. You must: + * Go to the Google Ads UI. + * Create a New Conversion Action. + * Select Import as the source. + * Select Manual import using API or uploads and specifically choose Track conversions from clicks. + * This ensures the type is set to `UPLOAD_CLICKS`. + * **Fix Step 2 (Update API Code)**: Once the new action is created, grab its ID and update your script: + * Ensure you are using the `UploadClickConversions` method. + * Ensure the `conversion_action` resource name in your code points to the new ID. + * Verify that you are including the required user identifiers (like hashed email or phone number) in the `UserIdentifier` field of the request. + * **Summary**: The API is telling you, "You're trying to upload lead data to a 'Tag' action, but I only accept that data for an 'Import' action." + +* `CUSTOMER_NOT_ENABLED_ENHANCED_CONVERSIONS_FOR_LEADS`: The setting is disabled in the account. To address this error, you must enable the setting in the Google Ads UI and accept the required terms. This cannot be done entirely via the API because it requires a manual legal agreement. + * **Step 1: Accept Customer Data Terms (Manual)** + 1. Log in to your Google Ads account. + 2. Click the Goals icon. + 3. Click the Conversions dropdown and select Settings. + 4. Expand the Customer data terms section. + 5. Read and Accept the terms if you haven't already. This is the most common reason for this error. + * **Step 2: Enable the Setting in the UI** + 1. While still in Goals > Conversions > Settings, look for the Enhanced conversions for leads section. + 2. Check the box to Turn on enhanced conversions for leads. + 3. Choose your method (usually "Google Ads API" or "Global site tag"). + 4. Click Save. + * **Step 3: Verify via API (Autonomous Action by Gemini)** + * If you want to check if the setting is correctly enabled using code, Gemini can execute this query against the `customer` resource for you: + ```sql + SELECT + customer.offline_conversion_tracking_info.enable_enhanced_conversions_for_leads, + customer.offline_conversion_tracking_info.accepted_customer_data_terms + FROM customer + ``` + * **Note**: After enabling the setting, it can sometimes take a few minutes for the API to recognize the change. If you still see the error immediately after saving, wait about 15–30 minutes and try your upload again. + * `DUPLICATE_ORDER_ID`: Multiple conversions sent with the same Order ID in the same batch. + * **1. The "Quick Fix": De-duplicate your Batch** + Before you call `UploadClickConversions`, add a step in your code to filter your list. If you have a list of conversions, use a set or a dictionary in Python to ensure you only keep one entry per unique `order_id`. + ```python + # Simple Python de-duplication example + unique_conversions = {} + for conv in my_conversions: + unique_conversions[conv.order_id] = conv + + # Now only upload the values of the dictionary + final_batch = list(unique_conversions.values()) + ``` + * **2. The "Logic Fix": Why are there duplicates?** + Check your data source (database or CRM) to see why two records have the same ID. + * *User Refresh*: Did the user refresh their "Thank You" page? If so, your system might be recording the same sale twice. + * *Loop Error*: Check your code for nested loops that might be appending the same conversion to your upload list multiple times. + * **3. The "Batching" Rule** + Google allows you to send up to 2,000 conversions in a single batch. If you have a very large number of conversions, make sure you aren't accidentally including the same records when you move from "Batch 1" to "Batch 2." + * **4. Important Distinction** + * *Same Batch*: This error only triggers if the duplicates are in the same request. + * *Different Batches*: If you send an Order ID now, and then send it again tomorrow in a different request, you might get a different error (`CONVERSION_ALREADY_EXISTS`) or Google might just silently ignore the second one depending on your settings. + * **Summary**: Clean your list before you send it! Google expects every `order_id` in a single API request to be unique. + * `CLICK_NOT_FOUND`: No click matched the provided user identifiers. (Treat as a warning unless frequent). + * **Context**: The `CLICK_NOT_FOUND` error means Google couldn't find an ad click that matches the user information (GCLID, Email, or Phone) you provided. If this happens occasionally, it's often just a "ghost" click or an invalid identifier, but if it happens frequently, here is how to fix it: + * **1. Respect the "24-Hour Rule"** + * Google needs time to process ad clicks before they can be matched to a conversion. + * *The Fix*: Wait at least 24 hours after the click happens before you try to upload the conversion. If you try to upload a conversion 5 minutes after the click, Google will often return `CLICK_NOT_FOUND`. + * **2. Check Data Normalization (CRITICAL)** + * If you are using Enhanced Conversions (email or phone), the data must be perfectly clean before it is hashed. + * *The Fix*: + 1. Trim: Remove all leading/trailing spaces. + 2. Lowercase: Convert all characters to lowercase. + 3. Hash: Use SHA-256 hashing. + * Example: `User@Example.com` must become `user@example.com` before it is hashed. + * **3. Verify GCLID Ownership (Autonomous Action by Gemini)** + * The GCLID you are uploading must belong to the exact account you are sending the data to. + * *The Fix*: Gemini can automatically query the `click_view` resource to verify if the GCLID is valid for your `customer_id`: + ```sql + SELECT click_view.gclid + FROM click_view + WHERE click_view.gclid = 'YOUR_GCLID_HERE' + ``` + If this query returns no results, that GCLID doesn't exist in your account. + * **4. Lookback Window** + * The click might be too old. + * *The Fix*: Check the `click_through_lookback_window_days` for the conversion action. If the click happened 31 days ago but your window is 30 days, Google will treat it as if the click was never found. + * **5. Proper Identifier Usage** + * *The Fix*: Do not mix identifiers. If you have a GCLID, use it. If you are using Enhanced Conversions, provide the `UserIdentifier`. Do not try to "invent" IDs or send dummy data, as this will trigger the error. + * **Summary**: If this is happening to 100% of your uploads, it is almost certainly a hashing/normalization issue or you are uploading too quickly after the click. If it's only happening to 1-2%, it is usually normal behavior (e.g., bot clicks or invalid IDs) and can be ignored. + #### 2.2. Enhanced Conversions for Web * `CONVERSION_NOT_FOUND`: Could not find a conversion matching the supplied action and ID/Order ID pair. -* `CUSTOMER_NOT_ACCEPTED_CUSTOMER_DATA_TERMS`: Customer data terms must be accepted in the UI. + * **Context**: The `CONVERSION_NOT_FOUND` error occurs when you try to adjust a conversion that Google has no record of. Think of it like trying to "Edit" a document that was never saved. To fix this, check these four areas: + * **1. Timing: The "Processing Gap"** + * Google needs time to "finalize" a conversion before you can enhance or adjust it. + * *The Fix*: Wait at least 24 hours after the original conversion was uploaded (or tracked via the tag) before sending an adjustment or enhancement. If you send them too close together, the adjustment will arrive before the original conversion is "found." + * **2. Matching Identifiers (Order ID vs. GCLID)** + * You are likely using the wrong "Key" to find the conversion. + * *The Fix*: Ensure your `order_id` (also called Transaction ID) and the `conversion_action` ID match exactly what was sent in the original upload. + * *Example*: If the original was sent with `order_id`: "SALE123", you cannot adjust it using `order_id`: "sale123" (it is case-sensitive). + * **3. Account Ownership** + * The adjustment must be sent to the same account that received the original conversion. + * *The Fix*: If your original conversion was tracked in Account A, but you are sending the adjustment to Account B (even if they are in the same MCC), Google won't find it. + * **4. Conversion Action Consistency** + * The adjustment must point to the exact same conversion action. + * *The Fix*: If you tracked a sale under the action "Website Purchase," you cannot send an enhancement to "Lead Form Submit" and expect it to find the original sale. + * **Summary Checklist**: + 1. Did I wait 24 hours? + 2. Is the `order_id` exactly the same (case-sensitive)? + 3. Am I using the correct `conversion_action` ID? + 4. Am I in the correct Google Ads account? + +* `CUSTOMER_NOT_ACCEPTED_CUSTOMER_DATA_TERMS`: Customer data terms must be accepted in the UI. + * **Context**: To address this error, you must accept the required terms in the Google Ads UI. This cannot be done entirely via the API because it requires a manual legal agreement. + * **Why this happens**: This error triggers when you try to use Enhanced Conversions (sending hashed emails or phone numbers). Because you are sending "First-Party Data," Google requires you to legally agree to their data privacy and security policies before they will process the information. + * **Step 1: The Fix (Manual Action)**: + 1. Log in to your Google Ads account. + 2. Click the Goals icon (trophy icon) in the left-hand navigation menu. + 3. Navigate to Conversions > Settings. + 4. Find and expand the section labeled Customer data terms. + 5. Click the Review and Accept button. + * Note: You must have "Admin" or "Standard" access level to see and accept these terms. + 6. Click Save at the bottom of the page. + * **Step 2: How to verify it's fixed (Autonomous Action by Gemini)**: + * You can run a quick check via the API to confirm the terms are now "Accepted": + ```sql + SELECT + customer.offline_conversion_tracking_info.accepted_customer_data_terms + FROM customer + ``` + * `CONVERSION_ALREADY_ENHANCED`: An adjustment for this Order ID and action has already been processed. + * **Context**: Google allows you to enhance a conversion (adding email, phone, or address) only once. If you try to update a record that has already been successfully enhanced, you will receive this error. + * **1. Check for Duplicate Submissions (The "Only One" Rule)** + * *The Fix*: Check your system to see if you are accidentally sending the same enhancement twice. This often happens if your CRM "updates" a record and your script thinks that means it needs to send the data to Google again. + * **2. Implement Status Tracking** + * Your internal database needs to know which conversions have already been successfully enhanced. + * *The Fix*: In your database, add a column named `google_ads_enhanced_at`. Once you get a SUCCESS response from the API, update this column. Before sending new data, filter your query to: `WHERE google_ads_enhanced_at IS NULL`. + * **3. Handle "Partial Success" Gracefully** + * If you send a batch of 10 enhancements and 5 of them were already enhanced, the API might return this error for those specific 5 while processing the others. + * *The Fix*: Inspect the `GoogleAdsException` to see exactly which indices failed. You can safely ignore the `CONVERSION_ALREADY_ENHANCED` errors—it just means the data is already there! + * **4. Wait for the Next Sale for New Information** + * If you have new information for a customer who already bought something once, you cannot "add" that info to their old sale if it was already enhanced. + * *The Fix*: Wait until that customer makes a new purchase with a new Order ID. You can then enhance that new transaction with the updated information. + * **Summary**: This error is usually a sign that your automation is too aggressive. It's trying to update records that Google already has all the information for. Simply filtering your data to only send "First-Time Enhancements" will solve the problem. + + * `CONVERSION_ACTION_NOT_ELIGIBLE_FOR_ENHANCEMENT`: This action type cannot be enhanced. + * **Context**: This error means you are trying to use Enhanced Conversions (uploading hashed user data like emails) on a conversion action that doesn't support it. Only certain types of actions allow for this kind of "data boost." + * **1. Check the "Source" of the Action** + * You can only use Enhanced Conversions on actions where Google can actually "see" the customer on your website. + * *The Fix*: Ensure the conversion action was created as a Website conversion. + * Eligible: WEBPAGE (tracked via the Google Tag). + * Not Eligible: IMPORT (standard offline conversions using GCLID only), PHONE_CALL, or STORE_VISIT. + * **2. Verify the "Enhanced Conversions" Toggle in the UI** + * Even if it's a Website action, you must explicitly "opt-in" for each specific action. + * *The Fix*: + 1. Go to the Google Ads UI > Goals > Conversions. + 2. Click on the specific Conversion Action name. + 3. Expand the Enhanced conversions section. + 4. Check the box to Turn on enhanced conversions. + 5. Select your method (API) and Save. + * **3. API Query Check (Autonomous Action by Gemini)** + * You can check which of your actions are actually eligible using the API. Look for the type and status: + ```sql + SELECT + conversion_action.name, + conversion_action.type, + conversion_action.status + FROM conversion_action + WHERE conversion_action.type != 'WEBPAGE' + ``` + * If you are trying to enhance any of the results from this query, they will fail with this error. + * **4. The "Import" Confusion** + * If you are doing Offline Conversions (uploading sales from your CRM), you don't "Enhance" them. You just "Upload" them. + * *The Fix*: If you are using `UploadClickConversions`, just send the data. You only use the Enhancement or Adjustment methods if you are trying to add data to a conversion that was originally tracked by a website tag. + * **Summary**: To fix this, make sure you are only "enhancing" Webpage-based conversion actions and that you have turned on the toggle in the settings for that specific action. + #### 2.3. General Issues * `TOO_RECENT_CONVERSION_ACTION`: Wait at least 6 hours after creating a new conversion action before uploading conversions to it. + * **Context**: The `TOO_RECENT_CONVERSION_ACTION` error is a simple matter of timing. When you create a new conversion action in Google Ads, the system needs time to propagate that new ID across all its servers. + * **The Fix**: Wait 6 to 24 hours. There is no way to bypass this with code or API settings. Google’s infrastructure requires a few hours to "wake up" and recognize that the new conversion action is ready to accept data. + * **Best Practices**: + 1. **Plan Ahead**: If you are launching a new campaign on Monday, create the conversion action on Friday or Saturday. + 2. **Test Later**: Don't try to run a "Test Upload" 5 minutes after clicking "Save" in the UI. + 3. **Error Handling**: If your script gets this error, you can build a simple "Retry" logic: + ```python + if "TOO_RECENT_CONVERSION_ACTION" in error_message: + print("Conversion action is too new. Sleeping for 6 hours...") + # Add logic to move this record to a queue for later + ``` + * **Summary**: If you see this error, don't panic and don't change your code. Your code is likely correct; you just need to give the Google Ads system a few more hours to "settle in." + * `EXPIRED_EVENT`: The click occurred before the conversion action's `click_through_lookback_window_days`. + * **Context**: To fix the `EXPIRED_EVENT` error, you need to align your upload data with the "Lookback Window" set for your conversion action. This error means the ad click happened so long ago that Google has already "closed the book" on it. + * **1. Increase the Lookback Window (The Easiest Fix)** + * If you are regularly uploading conversions that happen 45 days after the click, but your window is set to 30 days, they will all fail. + * *The Action*: Go to the Google Ads UI > Goals > Conversions. Click the action and edit the Click-through conversion window. + * *Maximum*: You can set this up to 90 days. If your window is already at 90 days and you're still getting this error, it means your sales cycle is too long for Google Ads to track. + * **2. Filter your Data Source (The Technical Fix)** + * Your script should automatically skip records that are too old to be accepted. This saves you from getting "failed" alerts and keeps your reports clean. + * *The Action*: In your SQL query or Python script, calculate the "age" of the click and only include it if it's within the window. + ```python + from datetime import datetime, timedelta + + # If your window is 90 days + lookback_limit = datetime.now() - timedelta(days=90) + + # Only process if click_date is newer than the limit + if click_date > lookback_limit: + upload_conversion(click_id) + else: + print(f"Skipping {click_id}: Click is too old.") + ``` + * **3. Check for Timezone Discrepancies** + * Sometimes a click might be right on the edge of the window. + * *The Action*: Ensure you are using the correct timezone offset in your upload. If you send the time without an offset (e.g., -05:00), Google might assume it's UTC, which could push a "Day 90" click into "Day 91." + * **4. Verify the "Creation" of the GCLID** + * Sometimes the GCLID itself is just old data from your CRM that got "stuck." + * *The Action*: Check your CRM to see if you are accidentally re-processing old leads from months ago. + * **Summary**: + * *Quick Fix*: Set the window to 90 days in the UI. + * *Long-term Fix*: Update your code to skip clicks that are older than your lookback window. + * `CONVERSION_PRECEDES_EVENT`: The conversion timestamp is before the click timestamp. + * **Context**: The `CONVERSION_PRECEDES_EVENT` error is a logical impossibility. Google is saying: "You're telling me this person bought the item at 1:00 PM, but they didn't even click the ad until 1:30 PM." Here is how to fix this data integrity issue: + * **1. Audit your Timestamp Logic (The "Why")** + * This almost always comes down to how you are pulling dates from your database. + * *The Problem*: You might be comparing the Lead Creation Date (Conversion) with the Click Date, but your Lead Creation Date is being recorded in a different timezone than the Click Date. + * *The Fix*: Ensure all timestamps are converted to the same timezone (preferably UTC) before you compare them in your script. + * **2. Check for System Clock Desync** + * If your web server (which records the click) and your CRM server (which records the sale) have clocks that are even 5 minutes apart, you can trigger this error for fast conversions. + * *The Fix*: Use a standard time synchronization service (like NTP) for all your servers. + * **3. Implement a "Safety Guard" in Code** + * Add a simple check to your script to ensure the conversion happened after the click. If it didn't, don't even try to upload it. + ```python + # Simple Python check + if conversion_time <= click_time: + print(f"ERROR: Conversion ({conversion_time}) happened before click ({click_time}). Skipping.") + # You might want to log this to investigate why the data is corrupt + continue + ``` + * **4. Human Error in Manual Uploads** + * If you are manually entering dates into a CSV or spreadsheet: + * *The Fix*: Double-check for typos (e.g., entering 10:00 instead of 22:00 for a late-night conversion). + * **Summary**: To fix this, you must ensure your conversion date is always later than your click date. Usually, this means fixing a timezone mismatch or a clock synchronization issue between your website and your CRM. + * `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: The same conversion is repeated in a single batch. + * **Context**: The `DUPLICATE_CLICK_CONVERSION_IN_REQUEST` error is very similar to the "Duplicate Order ID" error, but it refers to the Click ID (GCLID) itself. It means you have included the exact same GCLID and Conversion Action combination more than once in the same API request. Here is how to fix it: + * **1. De-duplicate your Batch (The Code Fix)** + * Before sending your list of conversions to the API, you must ensure that each (GCLID, ConversionAction) pair is unique within that specific batch. + ```python + # Use a set to track unique combinations of (GCLID, ActionID) + unique_batch = [] + seen_combinations = set() + + for conversion in my_list: + combo = (conversion.gclid, conversion.conversion_action) + if combo not in seen_combinations: + unique_batch.append(conversion) + seen_combinations.add(combo) + + # Now send 'unique_batch' to the API + ``` + * **2. Identify the "Loop Hole" in your Code** + * This error usually happens because of a logic bug in how you are building your upload list: + * *Duplicate Database Rows*: Your SQL query might be returning two rows for the same click if it’s joined with multiple products or sessions. + * *Nested Loops*: You might be accidentally appending the same conversion object to your list inside a loop. + * **3. Difference from DUPLICATE_ORDER_ID** + * `DUPLICATE_ORDER_ID`: You sent two different clicks but gave them the same transaction ID. + * `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: You sent the exact same click twice. Even if they have different Order IDs, Google won't allow the same click to convert twice in a single request. + * **4. Handling Partial Failures** + * If you are using `partial_failure = True` in your API request, the rest of your conversions will still process, but you will see this error for the duplicate entries. You can safely ignore these errors if you know your data source occasionally has duplicates, but it's better to clean the data first to save on API overhead. + * **Summary**: To fix this, filter your list of conversions to ensure that no GCLID appears more than once for the same conversion action in a single API call. ### 3. Verification Checklist From e9053a438d3972c4436c8134abde42bb6971561a Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 11 Feb 2026 16:31:55 -0500 Subject: [PATCH 20/81] Python installed by default in setup process. --- ChangeLog | 1 + conversions/GEMINI.md | 2 +- setup.ps1 | 16 ++++++++++------ setup.sh | 11 +++-------- tests/test_setup.ps1 | 32 ++++++++++++++++++++++++++++---- tests/test_setup.sh | 23 ++++++++++++----------- 6 files changed, 55 insertions(+), 30 deletions(-) diff --git a/ChangeLog b/ChangeLog index 4430a1d..31c01ba 100644 --- a/ChangeLog +++ b/ChangeLog @@ -7,6 +7,7 @@ - Added mandatory GAQL validation rules to GEMINI.md - Introduced dynamic grpc interceptor for Python calls within extension. - Added hierachical context file for conversions troubleshooting. +- Python is installed by default with setup.sh and setup.ps1. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 54d16bb..59c2b60 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -289,7 +289,7 @@ This document provides a technical reference for troubleshooting conversion-rela * If you are using `partial_failure = True` in your API request, the rest of your conversions will still process, but you will see this error for the duplicate entries. You can safely ignore these errors if you know your data source occasionally has duplicates, but it's better to clean the data first to save on API overhead. * **Summary**: To fix this, filter your list of conversions to ensure that no GCLID appears more than once for the same conversion action in a single API call. -### 3. Verification Checklist +### 3. Verification 1. **GCLID Ownership**: Query the `click_view` resource to verify if a GCLID belongs to the specific customer account. 2. **Customer Terms**: Check `customer.offline_conversion_tracking_info.accepted_customer_data_terms` via the `customer` resource. diff --git a/setup.ps1 b/setup.ps1 index 94ae56a..0eb9537 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -25,8 +25,8 @@ Include google-ads-dotnet. .EXAMPLE - .\setup.ps1 -Python -Java - Installs only Python and Java libraries. + .\setup.ps1 -Java + Installs Java and Python libraries. .EXAMPLE .\setup.ps1 @@ -34,11 +34,9 @@ #> param( - [switch]$Python, [switch]$Php, [switch]$Ruby, [switch]$Java, - [switch]$Java, [switch]$Dotnet, [switch]$InstallDeps ) @@ -77,10 +75,16 @@ function Get-RepoConfig { } # --- Defaults --- +$Python = $true +$AnySelected = $false + +if ($Php -or $Ruby -or $Java -or $Dotnet) { + $AnySelected = $true +} + # If no specific languages selected, select all -if (-not ($Python -or $Php -or $Ruby -or $Java -or $Dotnet)) { +if (-not $AnySelected) { Write-Host "No specific languages selected. Defaulting to ALL languages." - $Python = $true $Php = $true $Ruby = $true $Java = $true diff --git a/setup.sh b/setup.sh index 40ea69d..fc403ab 100755 --- a/setup.sh +++ b/setup.sh @@ -67,7 +67,7 @@ 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 @@ -115,11 +115,11 @@ 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 --install-deps Install dependencies (e.g. pip packages)" - echo " --python Include google-ads-python" echo " --php Include google-ads-php" echo " --ruby Include google-ads-ruby" echo " --java Include google-ads-java" @@ -128,7 +128,7 @@ usage() { echo " If no language flags are provided, ALL supported languages 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 "" } @@ -139,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 diff --git a/tests/test_setup.ps1 b/tests/test_setup.ps1 index d3ed296..c88161d 100644 --- a/tests/test_setup.ps1 +++ b/tests/test_setup.ps1 @@ -69,8 +69,8 @@ try { 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 setup.ps1 -Python -Php -Ruby -InstallDeps --- - Write-Host "--- Running setup.ps1 -Python -Php -Ruby -InstallDeps ---" + # --- Test Case 1: Run setup.ps1 -Php -Ruby -InstallDeps --- + Write-Host "--- Running setup.ps1 -Php -Ruby -InstallDeps ---" Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue # We must run it in the FakeProject dir so git rev-parse finds it? @@ -79,7 +79,7 @@ try { # Ah, our mock git `rev-parse` returns `$FakeProject`. # Execute setup.ps1 - & $SetupScriptPath -Python -Php -Ruby -InstallDeps + & $SetupScriptPath -Php -Ruby -InstallDeps if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } $LogContent = Get-Content -Raw $InstallLog -ErrorAction SilentlyContinue @@ -89,11 +89,18 @@ try { 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 setup.ps1 NO InstallDeps --- Write-Host "--- Running setup.ps1 (NO Deps) ---" Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue - & $SetupScriptPath -Python -Php -Ruby + & $SetupScriptPath -Php -Ruby if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } if (Test-Path $InstallLog) { @@ -102,6 +109,23 @@ try { 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 setup.ps1 Default (no flags) --- + Write-Host "--- Running setup.ps1 (Default) ---" + & $SetupScriptPath + if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } + + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + $IncludedDirs = $Settings.context.includeDirectories + $Langs = @("python", "php", "ruby", "java", "dotnet") + foreach ($L in $Langs) { + $Expected = Join-Path $FakeProject "client_libs/google-ads-$L" + if ($IncludedDirs -contains $Expected) { Write-Host "PASS: settings contains $L" } else { throw "FAIL: settings missing $L in default run" } + } + Write-Host "ALL TESTS PASSED" } diff --git a/tests/test_setup.sh b/tests/test_setup.sh index 58de2ee..f978694 100755 --- a/tests/test_setup.sh +++ b/tests/test_setup.sh @@ -69,10 +69,10 @@ echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settin 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" +# --- Test Case 1: Run setup.sh --- +echo "--- Running setup.sh ---" +if ! bash "${SETUP_SCRIPT_PATH}"; then + echo "FAIL: setup.sh failed" exit 1 fi @@ -124,11 +124,12 @@ else exit 1 fi -# Verify Python is gone (based on current implementation analysis) +# 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 (Accumulative?)" + echo "INFO: google-ads-python is STILL present (Always enabled)" else - echo "INFO: google-ads-python is GONE (Expected per current logic if overwriting)" + echo "FAIL: google-ads-python is GONE (It should always be present)" + exit 1 fi # Mock python @@ -164,11 +165,11 @@ touch "${FAKE_PROJECT}/client_libs/google-ads-ruby/Gemfile" # --- Test Case 3: Install Deps --- -echo "--- Running setup.sh --python --php --ruby --install-deps ---" +echo "--- Running setup.sh --php --ruby --install-deps ---" # Clear log rm -f "${TEST_TMP_DIR}/install_log.txt" -if ! bash "${SETUP_SCRIPT_PATH}" --python --php --ruby --install-deps; then +if ! bash "${SETUP_SCRIPT_PATH}" --php --ruby --install-deps; then echo "FAIL: setup.sh failed with --install-deps" exit 1 fi @@ -199,10 +200,10 @@ else fi # --- Test Case 4: No Install Deps (Verify NO install) --- -echo "--- Running setup.sh --python --php --ruby (NO deps) ---" +echo "--- Running setup.sh --php --ruby (NO deps) ---" rm -f "${TEST_TMP_DIR}/install_log.txt" -if ! bash "${SETUP_SCRIPT_PATH}" --python --php --ruby; then +if ! bash "${SETUP_SCRIPT_PATH}" --php --ruby; then echo "FAIL: setup.sh failed without --install-deps" exit 1 fi From 419e5000aa8537103716f18dcd67ae99f6754c07 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 12 Feb 2026 08:52:33 -0500 Subject: [PATCH 21/81] Removed debug log and print statements from exit hook --- .gemini/hooks/cleanup_config.py | 9 --------- .gemini/settings.json | 2 +- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py index 6c2e7a2..458db45 100644 --- a/.gemini/hooks/cleanup_config.py +++ b/.gemini/hooks/cleanup_config.py @@ -4,14 +4,6 @@ import datetime def cleanup(): - """Removes all files in the config directory.""" - log_path = os.path.expanduser("~/gemini_hook_log.txt") - try: - with open(log_path, "a") as f: - f.write(f"[{datetime.datetime.now()}] cleanup_config hook started\n") - except Exception: - pass - # Determine paths script_dir = os.path.dirname(os.path.abspath(__file__)) # .gemini/hooks/ -> project root is 2 levels up @@ -38,7 +30,6 @@ def cleanup(): print(f"Failed to delete {file_path}. Reason: {e}", file=sys.stderr) timestamp = datetime.datetime.now() - print(f"SUCCESS: SessionEnd hook cleaned up config directory at {timestamp}", file=sys.stdout) except Exception as e: print(f"Error cleaning up config directory: {e}", file=sys.stderr) diff --git a/.gemini/settings.json b/.gemini/settings.json index 25e73b8..9dc1a0b 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -15,7 +15,7 @@ "tools": { "enableHooks": true }, - "hooks": { + "hooks": { "SessionStart": [ { "matcher": "startup", From 3c559f895a8c9d01cb0b882d1234047169254498 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 12 Feb 2026 09:03:17 -0500 Subject: [PATCH 22/81] Modified setup proccess to install python by default. Other languages must be specified on the command line. Updated update process to allow adding additional client libraries. --- ChangeLog | 1 + setup.ps1 | 14 ++--- setup.sh | 11 ++-- tests/test_setup.ps1 | 16 ++++-- tests/test_setup.sh | 16 ++++++ update.ps1 | 75 ++++++++++++++++++++++++++ update.sh | 126 +++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 237 insertions(+), 22 deletions(-) diff --git a/ChangeLog b/ChangeLog index 31c01ba..06bc7f2 100644 --- a/ChangeLog +++ b/ChangeLog @@ -8,6 +8,7 @@ - Introduced dynamic grpc interceptor for Python calls within extension. - Added hierachical context file for conversions troubleshooting. - Python is installed by default with setup.sh and setup.ps1. +- Updated update process to allow for adding additional client libraries. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/setup.ps1 b/setup.ps1 index 0eb9537..13bf2e0 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -30,7 +30,11 @@ .EXAMPLE .\setup.ps1 - Installs ALL supported libraries. + Installs only the Python library. + +.EXAMPLE + .\setup.ps1 -Java + Installs Java and Python libraries. #> param( @@ -82,13 +86,9 @@ if ($Php -or $Ruby -or $Java -or $Dotnet) { $AnySelected = $true } -# If no specific languages selected, select all +# If no specific languages selected, default to Python only if (-not $AnySelected) { - Write-Host "No specific languages selected. Defaulting to ALL languages." - $Php = $true - $Ruby = $true - $Java = $true - $Dotnet = $true + Write-Host "No additional languages selected. Defaulting to Python only." } # --- Dependency Check --- diff --git a/setup.sh b/setup.sh index fc403ab..bd2bcfc 100755 --- a/setup.sh +++ b/setup.sh @@ -125,7 +125,7 @@ usage() { 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 (Installs Java and Python libraries)" @@ -172,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 --- diff --git a/tests/test_setup.ps1 b/tests/test_setup.ps1 index c88161d..a6e98e1 100644 --- a/tests/test_setup.ps1 +++ b/tests/test_setup.ps1 @@ -115,15 +115,25 @@ try { # --- Test Case 3: Run setup.ps1 Default (no flags) --- Write-Host "--- Running setup.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 + & $SetupScriptPath if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json $IncludedDirs = $Settings.context.includeDirectories - $Langs = @("python", "php", "ruby", "java", "dotnet") + + # 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) { - $Expected = Join-Path $FakeProject "client_libs/google-ads-$L" - if ($IncludedDirs -contains $Expected) { Write-Host "PASS: settings contains $L" } else { throw "FAIL: settings missing $L in default run" } + $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" diff --git a/tests/test_setup.sh b/tests/test_setup.sh index f978694..59c1801 100755 --- a/tests/test_setup.sh +++ b/tests/test_setup.sh @@ -82,6 +82,14 @@ if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-python/.git" ]]; then 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" @@ -91,6 +99,14 @@ else 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 setup.sh --java (update existing check) --- echo "--- Running setup.sh --java ---" if ! bash "${SETUP_SCRIPT_PATH}" --java; then diff --git a/update.ps1 b/update.ps1 index 8178a4d..68a3e6a 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,6 +191,51 @@ 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" diff --git a/update.sh b/update.sh index 0a07236..8a083e7 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,6 +231,44 @@ 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" From 0d447a0daa49874cef1e88f69385bd10f246c550 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 12 Feb 2026 12:51:48 -0500 Subject: [PATCH 23/81] - Setup now installs the extension after cloning from github. - Added script to uninstall. This uninstalls the extension and deletes the project directory. --- .gemini/settings.json | 10 ++ ChangeLog | 19 ++-- README.md | 32 ++++-- setup.ps1 => install.ps1 | 32 +++++- setup.sh => install.sh | 22 ++++- tests/{test_setup.ps1 => test_install.ps1} | 32 +++--- tests/{test_setup.sh => test_install.sh} | 34 +++---- tests/test_uninstall.ps1 | 83 ++++++++++++++++ tests/test_uninstall.sh | 94 ++++++++++++++++++ tests/test_update.sh | 110 +++++++++++++++++++++ uninstall.ps1 | 46 +++++++++ uninstall.sh | 54 ++++++++++ update.ps1 | 2 +- update.sh | 2 +- 14 files changed, 515 insertions(+), 57 deletions(-) rename setup.ps1 => install.ps1 (84%) rename setup.sh => install.sh (90%) rename tests/{test_setup.ps1 => test_install.ps1} (88%) rename tests/{test_setup.sh => test_install.sh} (86%) create mode 100644 tests/test_uninstall.ps1 create mode 100644 tests/test_uninstall.sh create mode 100644 tests/test_update.sh create mode 100644 uninstall.ps1 create mode 100644 uninstall.sh diff --git a/.gemini/settings.json b/.gemini/settings.json index 9dc1a0b..ea8682f 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -4,6 +4,16 @@ "disableLoadingPhrases": true, "enableLoadingPhrases": false } + }, + "general": { + "checkpointing": { + "enabled": true + }, + "sessionRetention": { + "enabled": true, + "maxAge": "30d", + "maxCount": 50 + } }, "context": { "includeDirectories": [ diff --git a/ChangeLog b/ChangeLog index 06bc7f2..4b92ec5 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,14 +1,15 @@ * 1.6.0 -- Added --install-deps option to setup.sh and setup.ps1. -- Added skills/ext_version to get the extension version. -- Added gemini-extension.json to register extensions with https://geminicli.com/extensions/ -- Added documentation resource for public protos. -- Added hooks for start and end of a session. -- Added mandatory GAQL validation rules to GEMINI.md -- Introduced dynamic grpc interceptor for Python calls within extension. -- Added hierachical context file for conversions troubleshooting. -- Python is installed by default with setup.sh and setup.ps1. +- 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. +- Hierachical context file for conversions troubleshooting. +- 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. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/README.md b/README.md index a5c3047..ab17b79 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,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 +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 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`. + * Run `./install.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. + * To install specific languages, use flags: `./install.sh --python --php`. + * Execute `./install.sh --help` for more details. * **Windows:** - * Open PowerShell and run `.\setup.ps1`. + * Open PowerShell and run `.\install.ps1`. * By default, this installs **ALL** supported client libraries to `$HOME\gaada`. - * To install specific languages, use parameters: `.\setup.ps1 -Python -Php`. + * To install specific languages, use parameters: `.\install.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 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. @@ -162,6 +162,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/setup.ps1 b/install.ps1 similarity index 84% rename from setup.ps1 rename to install.ps1 index 13bf2e0..5aa4b61 100644 --- a/setup.ps1 +++ b/install.ps1 @@ -25,15 +25,15 @@ Include google-ads-dotnet. .EXAMPLE - .\setup.ps1 -Java + .\install.ps1 -Java Installs Java and Python libraries. .EXAMPLE - .\setup.ps1 + .\install.ps1 Installs only the Python library. .EXAMPLE - .\setup.ps1 -Java + .\install.ps1 -Java Installs Java and Python libraries. #> @@ -208,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: $_" @@ -244,7 +268,7 @@ if ($Ruby -and $InstallDeps) { } } -Write-Host "Setup complete." +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.)" diff --git a/setup.sh b/install.sh similarity index 90% rename from setup.sh rename to install.sh index bd2bcfc..3f6ee43 100755 --- a/setup.sh +++ b/install.sh @@ -314,6 +314,26 @@ 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 @@ -345,7 +365,7 @@ 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 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.)" diff --git a/tests/test_setup.ps1 b/tests/test_install.ps1 similarity index 88% rename from tests/test_setup.ps1 rename to tests/test_install.ps1 index a6e98e1..35cf92d 100644 --- a/tests/test_setup.ps1 +++ b/tests/test_install.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Test script for setup.ps1 + Test script for install.ps1 #> $ErrorActionPreference = "Stop" @@ -8,7 +8,7 @@ $ErrorActionPreference = "Stop" # --- Test Setup --- $TestTmpDir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName() New-Item -ItemType Directory -Force -Path $TestTmpDir | Out-Null -$SetupScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "setup.ps1") +$InstallScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "install.ps1") Write-Host "Running tests in $TestTmpDir" @@ -69,18 +69,18 @@ try { 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 setup.ps1 -Php -Ruby -InstallDeps --- - Write-Host "--- Running setup.ps1 -Php -Ruby -InstallDeps ---" + # --- 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? - # setup.ps1 calls `git rev-parse --show-toplevel`. + # 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 setup.ps1 - & $SetupScriptPath -Php -Ruby -InstallDeps - if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } + # 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" @@ -96,12 +96,12 @@ try { 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 setup.ps1 NO InstallDeps --- - Write-Host "--- Running setup.ps1 (NO Deps) ---" + # --- Test Case 2: Run install.ps1 NO InstallDeps --- + Write-Host "--- Running install.ps1 (NO Deps) ---" Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue - & $SetupScriptPath -Php -Ruby - if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } + & $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" @@ -113,14 +113,14 @@ try { $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 setup.ps1 Default (no flags) --- - Write-Host "--- Running setup.ps1 (Default) ---" + # --- 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 - & $SetupScriptPath - if ($LASTEXITCODE -ne 0) { throw "setup.ps1 failed" } + & $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 diff --git a/tests/test_setup.sh b/tests/test_install.sh similarity index 86% rename from tests/test_setup.sh rename to tests/test_install.sh index 59c1801..43f0ce7 100755 --- a/tests/test_setup.sh +++ b/tests/test_install.sh @@ -3,7 +3,7 @@ set -u # --- Test Setup --- TEST_TMP_DIR=$(mktemp -d) -SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/setup.sh" +SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/install.sh" echo "Running tests in ${TEST_TMP_DIR}" @@ -60,19 +60,19 @@ if ! command -v jq &> /dev/null; then fi # 2. Setup "Project" in Temp Dir -# setup.sh expects to be run from within the repo +# 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 setup.sh references +# Create dummy directories that install.sh references mkdir -p "${FAKE_PROJECT}/api_examples" mkdir -p "${FAKE_PROJECT}/saved_code" -# --- Test Case 1: Run setup.sh --- -echo "--- Running setup.sh ---" +# --- Test Case 1: Run install.sh --- +echo "--- Running install.sh ---" if ! bash "${SETUP_SCRIPT_PATH}"; then - echo "FAIL: setup.sh failed" + echo "FAIL: install.sh failed" exit 1 fi @@ -107,10 +107,10 @@ for lang in php ruby java dotnet; do fi done -# --- Test Case 2: Run setup.sh --java (update existing check) --- -echo "--- Running setup.sh --java ---" +# --- Test Case 2: Run install.sh --java (update existing check) --- +echo "--- Running install.sh --java ---" if ! bash "${SETUP_SCRIPT_PATH}" --java; then - echo "FAIL: setup.sh failed with --java" + echo "FAIL: install.sh failed with --java" exit 1 fi @@ -120,16 +120,16 @@ if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-java/.git" ]]; then 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. +# 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 `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. +# 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. @@ -181,12 +181,12 @@ touch "${FAKE_PROJECT}/client_libs/google-ads-ruby/Gemfile" # --- Test Case 3: Install Deps --- -echo "--- Running setup.sh --php --ruby --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: setup.sh failed with --install-deps" + echo "FAIL: install.sh failed with --install-deps" exit 1 fi @@ -216,11 +216,11 @@ else fi # --- Test Case 4: No Install Deps (Verify NO install) --- -echo "--- Running setup.sh --php --ruby (NO deps) ---" +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: setup.sh failed without --install-deps" + echo "FAIL: install.sh failed without --install-deps" exit 1 fi 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/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 100644 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 68a3e6a..3cb1abc 100644 --- a/update.ps1 +++ b/update.ps1 @@ -241,7 +241,7 @@ $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 8a083e7..fefc20a 100755 --- a/update.sh +++ b/update.sh @@ -274,7 +274,7 @@ 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 8f4d6f5e5583455b88ada2c124beac0038232500 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 12 Feb 2026 18:22:01 +0000 Subject: [PATCH 24/81] Add instruction to not include env var for config file when explaining to the user you to execute a script. --- GEMINI.md | 1 + 1 file changed, 1 insertion(+) diff --git a/GEMINI.md b/GEMINI.md index ddfc098..14a4a67 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -149,6 +149,7 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.4.1. Python Configuration Loading - **Code Generation (to `saved_code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved_code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). - **CRITICAL Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. **NEVER** use `client_libs/google-ads-python/google-ads.yaml`. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. +- **User Instructions:** When providing commands or instructions to a user for running a script, you MUST NOT include the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable. This variable is strictly for internal use by the assistant when executing scripts within the Gemini CLI. User-facing instructions should assume the user has configured their `google-ads.yaml` in the standard default location (e.g., their home directory). - **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The `ex` object contains the high-level, structured Google Ads failure details in the `ex.failure` attribute. To access the detailed list of errors, you **MUST** iterate over `ex.failure.errors`. **NEVER** attempt to access `ex.error.errors`, as `ex.error` is the underlying gRPC call object and does not have this attribute, which will cause an `AttributeError`. A correct error handling loop looks like this: ```python try: From bcf89a3e570aa7acb63e992218a663aa6df5d27c Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Sat, 14 Feb 2026 02:23:52 +0000 Subject: [PATCH 25/81] Added rule for inter-field compatibility check in queries. --- GEMINI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 14a4a67..2fc7c01 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -122,6 +122,8 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 8. **Service-Specific Query Syntax:** The `GoogleAdsService` is the **only** service that accepts standard GAQL queries containing a `FROM` clause (e.g., `SELECT ... FROM ...`). When querying other services, such as the `GoogleAdsFieldService`, you **MUST** use their specific methods (e.g., `get_google_ads_field` or `search_google_ads_fields` with its specialized query format) and **MUST NOT** include a `FROM` clause in the request. +9. **Inter-Field Mutual Compatibility (CRITICAL):** Do not assume that because multiple fields are selectable with the resource in the `FROM` clause, they are compatible with each other. For every field included in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. This is especially important when combining high-level attributes (like campaign settings) with lower-level segments (like `segments.search_term_match_source`) or metrics. If Field A and Field B are in the same query, Field B must be in Field A's `selectable_with` list, AND Field A must be in Field B's `selectable_with` list. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. From 7cb873610be3ed68c76d827011fb42da3d7d23a9 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Sat, 14 Feb 2026 02:32:24 +0000 Subject: [PATCH 26/81] Update segment verification rules for GAQL --- GEMINI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 2fc7c01..41db53c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -109,7 +109,7 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali * Examine the `selectable_with` attribute of the main resource to find the correct fields for filtering and selection. * **MANDATORY TOOL CALL:** You MUST execute a tool call to `run_shell_command` or similar to query the `GoogleAdsFieldService` and physically see the `selectable_with` list before you present any query to the user. Skipping this is a critical failure. -5. 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). +5. Segment Rule: You MUST verify that any field (attribute or segment) 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). 5. 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. From 2519ca8e754cf31b5ffa1d67996ae47b5c534aec Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 13 Feb 2026 21:44:42 -0500 Subject: [PATCH 27/81] Removed documentation link for conversions from the top level GEMINI.md and placed it in conversions/GEMINI --- GEMINI.md | 4 +--- conversions/GEMINI.md | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 41db53c..09859e0 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -224,7 +224,6 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 5.2. References - **API Docs:** `https://developers.google.com/google-ads/api/docs/` -- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` - **Protos:** `https://github.com/googleapis/googleapis/tree/master/google/ads/googleads` #### 5.3. Disambiguation @@ -232,5 +231,4 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit - **'Import' vs 'Upload':** These terms are interchangeable for conversions. #### 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 +- 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. diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 59c2b60..6b1639c 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -307,4 +307,9 @@ This document provides a technical reference for troubleshooting conversion-rela #### 4.1. General 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. \ No newline at end of file + - Refer to official documentation for discrepancies and troubleshooting. + +### 5. Output and Documentation + +#### 5.1. References +- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` From 534ed3b5e194660ba6e0e06cfef1a025a2a89b54 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 15:18:38 +0000 Subject: [PATCH 28/81] - Link from main gemini.md to conversions/gemini.md troubleshooting workflow - Updated api_examples to use v23. --- GEMINI.md | 10 +++++--- api_examples/ai_max_reports.py | 4 +-- api_examples/capture_gclids.py | 25 ++++++++++++++++--- 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 | 4 +-- 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 | 6 ++--- .../target_campaign_with_user_list.py | 2 +- api_examples/tests/test_capture_gclids.py | 9 ++++--- conversions/GEMINI.md | 11 ++++---- 18 files changed, 56 insertions(+), 35 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 09859e0..e3e10aa 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -127,10 +127,11 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. -2. **VALIDATE:** You MUST rigorously validate the entire query against all rules in section **3.3.1. Rigorous GAQL Validation**. This is a non-negotiable checkpoint. -3. **PRESENT:** Display the validated query to the user in a `sql` block and explain what it does. -4. **EXECUTE:** Only after the query has been validated and presented, proceed to incorporate it into code and execute it. -5. **HANDLE ERRORS:** If the API returns a query validation error, you MUST return to step 2 and re-validate the entire query based on the new information from the error message. +2. **SYNTAX GUARD (CRITICAL):** Identify the target service. If the service is NOT `GoogleAdsService`, you MUST explicitly remove the `FROM` clause and any associated resource name from the query string before proceeding. +3. **VALIDATE:** You MUST rigorously validate the entire query against all rules in section **3.3.1. Rigorous GAQL Validation**. This is a non-negotiable checkpoint. +4. **PRESENT:** Display the validated query to the user in a `sql` block and explain what it does. +5. **EXECUTE:** Only after the query has been validated and presented, proceed to incorporate it into code and execute it. +6. **HANDLE ERRORS:** If the API returns a query validation error, you MUST return to step 2 and re-validate the entire query based on the new information from 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. @@ -173,6 +174,7 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.5. Troubleshooting - **Conversions:** + - **MANDATORY:** For ALL conversion-related troubleshooting, you MUST follow the workflow defined in `conversions/GEMINI.md`. The absolute first step is executing diagnostic queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. - 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:** diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index 493b277..952104c 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from google.ads.googleads.client import GoogleAdsClient - from google.ads.googleads.v22.services.types.google_ads_service import ( + from google.ads.googleads.v23.services.types.google_ads_service import ( SearchGoogleAdsStreamResponse, ) @@ -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..26849fe 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" @@ -63,7 +66,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." @@ -83,9 +86,23 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: required=True, help="The GCLID for the ad click.", ) + parser.add_argument( + "-t", + "--conversion_date_time", + type=str, + required=True, + help="The date and time of the conversion (should be after the click " + "time). The format is 'yyyy-mm-dd hh:mm:ss+|-hh:mm', e.g. " + "'2021-01-01 12:32:45-08:00'.", + ) args = parser.parse_args() 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/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..09fa947 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -158,9 +158,9 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: # 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". + # For example, "v23". # 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..f58f902 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -17,7 +17,7 @@ from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.v22.enums import AssetFieldTypeEnum +from google.ads.googleads.v23.enums import AssetFieldTypeEnum def main( @@ -120,13 +120,13 @@ def main( 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" + "https://developers.google.com/google-ads/api/reference/rpc/v23/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") + 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." 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/conversions/GEMINI.md b/conversions/GEMINI.md index 6b1639c..0b18ee4 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -298,11 +298,12 @@ This document provides a technical reference for troubleshooting conversion-rela ### 4. Troubleshooting Workflow -1. **Check API Error Details**: Inspect the `GoogleAdsException` for specific `ErrorCode` and `message`. -2. **Verify Timestamps**: Ensure `conversion_date_time` is in `yyyy-mm-dd hh:mm:ss+|-hh:mm` format and falls within the lookback window. -3. **Validate Identifiers**: For `CLICK_NOT_FOUND`, ensure you are not mixing `gclid` with `gbraid` or `wbraid` inappropriately. Use only one per conversion. -4. **Wait for Processing**: Conversions can take up to 3 hours to appear in reporting after a successful upload. -5. **Check Conversion Settings**: Ensure the conversion action's `status` is `ENABLED` and it is configured for the correct `type`. +1. **MANDATORY FIRST STEP: Diagnostic Summaries**: Before investigating specific errors or identifiers, you **MUST** execute queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. These resources provide the most accurate view of recent import health and systemic failures. +2. **Check API Error Details**: Inspect the `GoogleAdsException` for specific `ErrorCode` and `message`. +3. **Verify Timestamps**: Ensure `conversion_date_time` is in `yyyy-mm-dd hh:mm:ss+|-hh:mm` format and falls within the lookback window. +4. **Validate Identifiers**: For `CLICK_NOT_FOUND`, ensure you are not mixing `gclid` with `gbraid` or `wbraid` inappropriately. Use only one per conversion. +5. **Wait for Processing**: Conversions can take up to 3 hours to appear in reporting after a successful upload. +6. **Check Conversion Settings**: Ensure the conversion action's `status` is `ENABLED` and it is configured for the correct `type`. #### 4.1. General Troubleshooting - **Conversions:** From 326906a13e1d73cc9cc7f8479e8557b4f4a60198 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 16:08:36 +0000 Subject: [PATCH 29/81] Rules to GEMINI.md files to avoid errors in conversions debugging. --- GEMINI.md | 26 ++++++++++++++++++++++++++ conversions/GEMINI.md | 4 ++++ 2 files changed, 30 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index e3e10aa..8372d38 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -124,6 +124,22 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 9. **Inter-Field Mutual Compatibility (CRITICAL):** Do not assume that because multiple fields are selectable with the resource in the `FROM` clause, they are compatible with each other. For every field included in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. This is especially important when combining high-level attributes (like campaign settings) with lower-level segments (like `segments.search_term_match_source`) or metrics. If Field A and Field B are in the same query, Field B must be in Field A's `selectable_with` list, AND Field A must be in Field B's `selectable_with` list. +10. **No OR Operator:** GAQL does not support the `OR` operator in the `WHERE` clause. If multiple criteria are required (e.g., searching for multiple name patterns), you **MUST** generate separate queries or perform the "OR" logic by filtering results in Python. + +11. **Metadata Query Pitfall (CRITICAL):** If you receive `query_error: UNEXPECTED_FROM_CLAUSE` with message `"The FROM clause cannot be used in queries to any service except GoogleAdsService."`, it means you included a `FROM` clause in a `GoogleAdsFieldService` request (e.g., `SearchGoogleAdsFields`). You **MUST** remove the `FROM` clause and any resource name following it, and filter instead using `WHERE name = 'resource_name'` or `WHERE name LIKE 'resource_name.%'`. + +12. **Unrecognized Field Pitfall (CRITICAL):** If you receive `query_error: UNRECOGNIZED_FIELD`, it means one or more fields in your `SELECT` or `WHERE` clause do not exist in the confirmed API version. You **MUST NOT** guess or try alternative names based on intuition. You **MUST** immediately query the `GoogleAdsFieldService` to find the exact valid field names for that resource in the confirmed version before attempting the query again. + +13. **Mandatory Schema Discovery:** Before querying any resource for the first time in a session, you **MUST** execute a schema-discovery query against `GoogleAdsFieldService` to list its valid fields. Relying on memory for field names is strictly prohibited and leads to avoidable `UNRECOGNIZED_FIELD` errors. + +14. **Enum Value Verification (CRITICAL):** If you receive `query_error: BAD_ENUM_CONSTANT`, it means the enum value used in your `WHERE` clause is invalid for that field. You **MUST** query the `GoogleAdsFieldService` for the field's `enum_values` attribute to retrieve the exact valid string constants for the confirmed API version. + +15. **Finite Date Range for Change Status (CRITICAL):** When querying the `change_status` resource, you **MUST** include a finite date range filter on `change_status.last_change_date_time` using both a start and an end boundary (e.g., `BETWEEN 'YYYY-MM-DD HH:MM:SS' AND 'YYYY-MM-DD HH:MM:SS'`). Providing only a start boundary (e.g., `>=`) results in a `CHANGE_DATE_RANGE_INFINITE` error. + +16. **Limit Requirement for Change Status (CRITICAL):** When querying the `change_status` resource, you **MUST** specify a `LIMIT` clause in your query. The limit must be less than or equal to 10,000. Failure to specify a limit results in a `LIMIT_NOT_SPECIFIED` error. + +17. **Single Day Filter for Click View (CRITICAL):** When querying the `click_view` resource, you **MUST** include a filter that limits the results to a single day (e.g., `WHERE segments.date = 'YYYY-MM-DD'`). Failure to do so results in an `EXPECTED_FILTER_ON_A_SINGLE_DAY` error. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. @@ -170,11 +186,21 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit ``` +- **Suppress Tracebacks (CRITICAL):** When executing any Python code (including one-liners via `python3 -c`), you **MUST** wrap Google Ads API calls in a `try...except GoogleAdsException` block. You **MUST** print only the structured error details (Request ID, Error Code, and Error Messages). You **MUST NOT** allow the exception to bubble up unhandled, as the library's internal gRPC interceptors will trigger noisy "During handling of the above exception, another exception occurred" tracebacks. + For other languages, use the equivalent exception type and inspect its structure. +#### 3.4.2. Safe Attribute and Method Access (CRITICAL) +- **Favor GoogleAdsService for Retrieval:** Most resource-specific `get_` methods (e.g., `get_campaign`, `get_conversion_action`) are deprecated or removed in modern API versions. You **MUST** always use `GoogleAdsService.search` or `GoogleAdsService.search_stream` to retrieve individual resources by filtering on their resource name or ID. +- **Attribute Verification for Nested Messages:** Do not assume attribute names for nested messages or repeated fields (e.g., fields inside `alerts` or `policy_summary`). You **MUST** verify the correct attribute names by: + 1. Querying `GoogleAdsFieldService` to find the `type_url` of the field. + 2. Using a one-liner script (e.g., `python3 -c "from google.ads.googleads.vXX.resources.types... import ...; print(dir(...))"`) to inspect the actual class attributes if you cannot find them in documentation. +- **Triple-Quote Safety:** When generating Python scripts that include multiline strings or SQL queries, you **MUST** use triple quotes (`"""`) and ensure there are no unescaped literal newlines within a single-quoted string to avoid `SyntaxError`. + #### 3.5. Troubleshooting - **Conversions:** - **MANDATORY:** For ALL conversion-related troubleshooting, you MUST follow the workflow defined in `conversions/GEMINI.md`. The absolute first step is executing diagnostic queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. + - **Upload Validation:** When generating or executing conversion upload scripts, you MUST implement logical time checks (timestamp normalization and lookback window validation) as defined in `conversions/GEMINI.md`. - 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:** diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 0b18ee4..1a88617 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -295,6 +295,10 @@ This document provides a technical reference for troubleshooting conversion-rela 2. **Customer Terms**: Check `customer.offline_conversion_tracking_info.accepted_customer_data_terms` via the `customer` resource. 3. **Data Normalization**: Ensure email addresses, phone numbers, and names are correctly normalized (trimmed, lowercased) and hashed (SHA-256) before sending. 4. **Consent**: Verify that `ClickConversion.consent` is properly set in the upload if required by regional policies. +5. **Logical Time Verification**: Before uploading any conversion, you MUST verify that the `conversion_date_time` is logically valid: + * **Normalization**: Ensure both click and conversion timestamps are in the same timezone (preferably UTC) before comparing. + * **No Pre-Click Conversions**: The conversion timestamp MUST be strictly after the click timestamp to avoid `CONVERSION_PRECEDES_EVENT`. + * **Lookback Window**: The click MUST have occurred within the `click_through_lookback_window_days` defined for the conversion action to avoid `EXPIRED_EVENT`. ### 4. Troubleshooting Workflow From 73eaaae6594b751827c642724181d67778ed4a0b Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 11:16:43 -0500 Subject: [PATCH 30/81] Changed directory structue to have one save directory with sub-directories for the types of artifacts saved. --- .gemini/settings.json | 2 +- GEMINI.md | 10 +++++----- README.md | 12 ++++++------ install.ps1 | 2 +- install.sh | 2 +- {saved_code => saved}/.gitkeep | 0 {saved_csv => saved/code}/.gitkeep | 0 saved/csv/.gitkeep | 0 {saved_csv => saved/csv}/campaigns.csv | 0 tests/test_install.ps1 | 2 +- tests/test_install.sh | 2 +- 11 files changed, 16 insertions(+), 16 deletions(-) rename {saved_code => saved}/.gitkeep (100%) rename {saved_csv => saved/code}/.gitkeep (100%) create mode 100644 saved/csv/.gitkeep rename {saved_csv => saved/csv}/campaigns.csv (100%) diff --git a/.gemini/settings.json b/.gemini/settings.json index ea8682f..5496f66 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -18,7 +18,7 @@ "context": { "includeDirectories": [ "/full/path/google-ads-api-developer-assistant/api_examples", - "/full/path/google-ads-api-developer-assistant/saved_code", + "/full/path/google-ads-api-developer-assistant/saved/code", "/full/path/google-ads-api-developer-assistant/client_libs/google-ads-python" ] }, diff --git a/GEMINI.md b/GEMINI.md index 8372d38..3075599 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -64,10 +64,10 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali - 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/`. +- **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.** +- **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. @@ -166,7 +166,7 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit - Use type hints, annotations, or other static typing features if the language supports them. #### 3.4.1. Python Configuration Loading -- **Code Generation (to `saved_code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved_code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). +- **Code Generation (to `saved/code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved/code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). - **CRITICAL Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. **NEVER** use `client_libs/google-ads-python/google-ads.yaml`. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. - **User Instructions:** When providing commands or instructions to a user for running a script, you MUST NOT include the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable. This variable is strictly for internal use by the assistant when executing scripts within the Gemini CLI. User-facing instructions should assume the user has configured their `google-ads.yaml` in the standard default location (e.g., their home directory). - **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The `ex` object contains the high-level, structured Google Ads failure details in the `ex.failure` attribute. To access the detailed list of errors, you **MUST** iterate over `ex.failure.errors`. **NEVER** attempt to access `ex.error.errors`, as `ex.error` is the underlying gRPC call object and does not have this attribute, which will cause an `AttributeError`. A correct error handling loop looks like this: @@ -259,4 +259,4 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit - **'Import' vs 'Upload':** These terms are interchangeable for conversions. #### 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. +- 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. diff --git a/README.md b/README.md index ab17b79..a52e87d 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,14 @@ 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"* ## Supported Languages @@ -83,7 +83,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,7 +91,7 @@ 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" ] @@ -140,8 +140,8 @@ 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. * `customer_id.txt`: (Optional) Stores the default customer ID. ## Mutate Operations diff --git a/install.ps1 b/install.ps1 index 5aa4b61..6d976c8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -176,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 diff --git a/install.sh b/install.sh index 3f6ee43..9828ae2 100755 --- a/install.sh +++ b/install.sh @@ -266,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=( diff --git a/saved_code/.gitkeep b/saved/.gitkeep similarity index 100% rename from saved_code/.gitkeep rename to saved/.gitkeep diff --git a/saved_csv/.gitkeep b/saved/code/.gitkeep similarity index 100% rename from saved_csv/.gitkeep rename to saved/code/.gitkeep diff --git a/saved/csv/.gitkeep b/saved/csv/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved_csv/campaigns.csv b/saved/csv/campaigns.csv similarity index 100% rename from saved_csv/campaigns.csv rename to saved/csv/campaigns.csv diff --git a/tests/test_install.ps1 b/tests/test_install.ps1 index 35cf92d..6ee5b56 100644 --- a/tests/test_install.ps1 +++ b/tests/test_install.ps1 @@ -58,7 +58,7 @@ try { 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 + 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" diff --git a/tests/test_install.sh b/tests/test_install.sh index 43f0ce7..3c91865 100755 --- a/tests/test_install.sh +++ b/tests/test_install.sh @@ -67,7 +67,7 @@ echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settin # Create dummy directories that install.sh references mkdir -p "${FAKE_PROJECT}/api_examples" -mkdir -p "${FAKE_PROJECT}/saved_code" +mkdir -p "${FAKE_PROJECT}/saved/code" # --- Test Case 1: Run install.sh --- echo "--- Running install.sh ---" From a1e6a41514ec1744e45fa4afc0a82348723040bc Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 16:45:55 +0000 Subject: [PATCH 31/81] Additional rules for Python one liners to ensure correct syntax. --- GEMINI.md | 17 +++++++++++++++++ conversions/GEMINI.md | 1 + 2 files changed, 18 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 3075599..489ba89 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -128,6 +128,8 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 11. **Metadata Query Pitfall (CRITICAL):** If you receive `query_error: UNEXPECTED_FROM_CLAUSE` with message `"The FROM clause cannot be used in queries to any service except GoogleAdsService."`, it means you included a `FROM` clause in a `GoogleAdsFieldService` request (e.g., `SearchGoogleAdsFields`). You **MUST** remove the `FROM` clause and any resource name following it, and filter instead using `WHERE name = 'resource_name'` or `WHERE name LIKE 'resource_name.%'`. +12. **Metadata Query Syntax Restriction (CRITICAL):** The `GoogleAdsFieldService` (e.g., `SearchGoogleAdsFields`) uses a highly restricted query language. You **MUST NOT** use parentheses `()` or complex boolean logic (like combining `AND` and `OR`) in the `WHERE` clause. Queries MUST be flat and simple. If you need complex filtering for metadata, you **MUST** retrieve a broader set of results and perform the filtering in your Python code. + 12. **Unrecognized Field Pitfall (CRITICAL):** If you receive `query_error: UNRECOGNIZED_FIELD`, it means one or more fields in your `SELECT` or `WHERE` clause do not exist in the confirmed API version. You **MUST NOT** guess or try alternative names based on intuition. You **MUST** immediately query the `GoogleAdsFieldService` to find the exact valid field names for that resource in the confirmed version before attempting the query again. 13. **Mandatory Schema Discovery:** Before querying any resource for the first time in a session, you **MUST** execute a schema-discovery query against `GoogleAdsFieldService` to list its valid fields. Relying on memory for field names is strictly prohibited and leads to avoidable `UNRECOGNIZED_FIELD` errors. @@ -140,6 +142,10 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 17. **Single Day Filter for Click View (CRITICAL):** When querying the `click_view` resource, you **MUST** include a filter that limits the results to a single day (e.g., `WHERE segments.date = 'YYYY-MM-DD'`). Failure to do so results in an `EXPECTED_FILTER_ON_A_SINGLE_DAY` error. +18. **Field Name Verification (CRITICAL):** To prevent `UNRECOGNIZED_FIELD` errors, you **MUST** verify the exact name of every field used in a `SELECT` or `WHERE` clause by querying the `GoogleAdsFieldService` before presenting or executing the query. This is especially mandatory when querying a resource for the first time in a session or when attempting to 'join' related resources. Guessing field names based on resource names is strictly prohibited. + +19. **Metadata Resource Pitfall (CRITICAL):** The `google_ads_field` resource can ONLY be queried using the `GoogleAdsFieldService`. You MUST NOT attempt to query it using `GoogleAdsService.search` or `search_stream`. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. @@ -192,11 +198,22 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.4.2. Safe Attribute and Method Access (CRITICAL) - **Favor GoogleAdsService for Retrieval:** Most resource-specific `get_` methods (e.g., `get_campaign`, `get_conversion_action`) are deprecated or removed in modern API versions. You **MUST** always use `GoogleAdsService.search` or `GoogleAdsService.search_stream` to retrieve individual resources by filtering on their resource name or ID. +- **Query Result Processing Pitfall:** When processing results from a `search` or `search_stream` call, if a field is a message type (e.g., `row.resource.nested_message` or a repeated field like `alerts`), you **MUST NOT** assume the attribute names of that nested message. You MUST verify the attribute names by executing a one-liner to inspect an instance of the message using `dir()` or `instance.pb.DESCRIPTOR.fields_by_name` before writing code that accesses those attributes. +- **Proto-plus Class Inspection Pitfall (CRITICAL):** When inspecting a Google Ads API message **class** (rather than an instance) to discover field names, you **MUST NOT** assume that `Class.pb.DESCRIPTOR` is accessible. In many versions of the library, `Class.pb` is a property or function, and accessing it directly on the class will result in an `AttributeError: 'function' object has no attribute 'DESCRIPTOR'`. Instead, you MUST use `Class.meta.pb.DESCRIPTOR` to access the underlying protobuf descriptor for discovery, or preferably, instantiate the class and use the instance inspection rules defined above. +- **Python Object Inspection Mandate (CRITICAL):** When encountering a Google Ads API object in Python for the first time, or if an attribute access fails, you MUST NOT guess its structure. You MUST execute a one-liner to perform a "deep inspection" that prints: 1) `type(instance)`, 2) `dir(instance)`, and 3) `str(instance)`. You MUST NOT use `.pb` or `.DESCRIPTOR` unless they are explicitly confirmed to exist in the `dir()` output. +- **Proto-plus Descriptor Pitfall:** When using the Python client library, objects returned from the API are typically proto-plus messages. These objects **DO NOT** have a top-level `DESCRIPTOR` attribute. If you need to access the descriptor (e.g., to see `fields_by_name`), you **MUST** first verify that the object has a `.pb` attribute using `dir()`. If it does, use `message_instance.pb.DESCRIPTOR`. If it does not, it may be a pure protobuf message (which has `DESCRIPTOR` but no `.pb`) or a specialized wrapper. +- **Nested Message Class Pitfall:** When using the Python client library, you **MUST NOT** assume that nested messages are accessible as class attributes of their parent message (e.g., `ParentMessage.NestedMessage`). Accessing them this way often leads to `AttributeError`. Instead, you MUST always use the `.pb` attribute of an instance to access the underlying protobuf message and its types, or use `dir(instance)` to discover the correct proto-plus attributes. - **Attribute Verification for Nested Messages:** Do not assume attribute names for nested messages or repeated fields (e.g., fields inside `alerts` or `policy_summary`). You **MUST** verify the correct attribute names by: 1. Querying `GoogleAdsFieldService` to find the `type_url` of the field. 2. Using a one-liner script (e.g., `python3 -c "from google.ads.googleads.vXX.resources.types... import ...; print(dir(...))"`) to inspect the actual class attributes if you cannot find them in documentation. - **Triple-Quote Safety:** When generating Python scripts that include multiline strings or SQL queries, you **MUST** use triple quotes (`"""`) and ensure there are no unescaped literal newlines within a single-quoted string to avoid `SyntaxError`. +#### 3.4.3. Python One-Liner Constraints (CRITICAL) +- When executing Python code via `run_shell_command` using the `-c` flag, you MUST keep the script extremely simple. +- You MUST NOT use `for` loops, `if` statements, or complex multi-line logic in a one-liner. +- You MUST NOT use `f-strings` in a one-liner that contain nested quotes that could break the shell command's quoting. +- For any operation requiring iteration, conditional logic, or complex setup, you MUST write the code to a temporary file and execute the file. + #### 3.5. Troubleshooting - **Conversions:** - **MANDATORY:** For ALL conversion-related troubleshooting, you MUST follow the workflow defined in `conversions/GEMINI.md`. The absolute first step is executing diagnostic queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 1a88617..b67e8cd 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -299,6 +299,7 @@ This document provides a technical reference for troubleshooting conversion-rela * **Normalization**: Ensure both click and conversion timestamps are in the same timezone (preferably UTC) before comparing. * **No Pre-Click Conversions**: The conversion timestamp MUST be strictly after the click timestamp to avoid `CONVERSION_PRECEDES_EVENT`. * **Lookback Window**: The click MUST have occurred within the `click_through_lookback_window_days` defined for the conversion action to avoid `EXPIRED_EVENT`. +6. **No OR Operator (CRITICAL)**: GAQL does not support the `OR` operator in the `WHERE` clause. You **MUST** perform multiple separate queries or filter results in code to achieve "OR" logic. ### 4. Troubleshooting Workflow From 3c9eb11d1e19592dec7a60e0e8c836f132152df0 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 12:02:09 -0500 Subject: [PATCH 32/81] Command to create support data for a ticket. --- .gemini/commands/conversions_support_data.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .gemini/commands/conversions_support_data.toml diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml new file mode 100644 index 0000000..af6ba73 --- /dev/null +++ b/.gemini/commands/conversions_support_data.toml @@ -0,0 +1,15 @@ +description = "Collects diagnostic data to help gTech debug conversion problems." + +prompt = """ +You are a helpful Google Ads API troubleshooting assistant. +The User is experiencing issues with conversions and needs to collect diagnostic data for gTech support. + +Please execute the following actions: +1. Locate the current `customer_id` from `customer_id.txt` or context. +2. Run the diagnostic script using the command: `python3 collect_conversions_diagnostic_data.py --customer_id ` +3. Summarize the results from the terminal output. +4. Mention that a detailed CSV report has been saved to `saved/csv/conversions_diagnostic_report.csv`. + +Here are the details from the user: +{{args}} +""" From d99b55a942cb3c75acf31d3656532079744709d3 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 12:23:46 -0500 Subject: [PATCH 33/81] Reformated process to compose support data. --- .../commands/conversions_support_data.toml | 10 +- ...ollect_conversions_troubleshooting_data.py | 181 ++++++++++++++++++ saved/data/.gitkeep | 0 3 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 api_examples/collect_conversions_troubleshooting_data.py create mode 100644 saved/data/.gitkeep diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml index af6ba73..36886b7 100644 --- a/.gemini/commands/conversions_support_data.toml +++ b/.gemini/commands/conversions_support_data.toml @@ -1,14 +1,14 @@ -description = "Collects diagnostic data to help gTech debug conversion problems." +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 diagnostic data for gTech support. +The User is experiencing issues with conversions and needs to collect structured diagnostic data for gTech support. Please execute the following actions: 1. Locate the current `customer_id` from `customer_id.txt` or context. -2. Run the diagnostic script using the command: `python3 collect_conversions_diagnostic_data.py --customer_id ` -3. Summarize the results from the terminal output. -4. Mention that a detailed CSV report has been saved to `saved/csv/conversions_diagnostic_report.csv`. +2. Run the structured troubleshooting script using the command: `python3 api_examples/collect_conversions_troubleshooting_data.py --customer_id ` +3. Summarize the findings from the script's terminal output (Summary and Error sections). +4. Mention that a detailed, shareable text report has been saved to the `saved/data/` directory. Here are the details from the user: {{args}} diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py new file mode 100644 index 0000000..2a49508 --- /dev/null +++ b/api_examples/collect_conversions_troubleshooting_data.py @@ -0,0 +1,181 @@ +# 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. + +"""Collects diagnostic data for conversion troubleshooting in a structured format.""" + +import argparse +import os +import sys +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 and returns the results.""" + ga_service = client.get_service("GoogleAdsService") + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + results = [] + for batch in response: + for row in batch.results: + results.append(row) + return results + 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}'.") + sys.exit(1) + + +def main(client: GoogleAdsClient, customer_id: str): + epoch = int(time.time()) + output_dir = "saved/data" + os.makedirs(output_dir, exist_ok=True) + output_filename = f"conversions_support_data_{epoch}.txt" + output_path = os.path.join(output_dir, output_filename) + + summary = [] + errors = [] + details = [] + + details.append(f"Diagnostic Report for Customer ID: {customer_id}") + details.append(f"Timestamp: {time.ctime()} (Epoch: {epoch})") + details.append("-" * 40) + + # 1. Customer Settings + details.append("\n[1] Customer Settings") + customer_query = """ + SELECT + customer.id, + customer.descriptive_name, + customer.conversion_tracking_setting.accepted_customer_data_terms, + customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled + FROM customer + """ + customer_results = run_query(client, customer_id, customer_query) + for row in customer_results: + settings = row.customer.conversion_tracking_setting + details.append(f"Customer Name: {row.customer.descriptive_name}") + details.append(f"EC for Leads Enabled: {settings.enhanced_conversions_for_leads_enabled}") + details.append(f"Customer Data Terms Accepted: {settings.accepted_customer_data_terms}") + + if not settings.accepted_customer_data_terms: + errors.append("CRITICAL: Customer Data Terms NOT accepted.") + if not settings.enhanced_conversions_for_leads_enabled: + summary.append("Note: Enhanced Conversions for Leads represents a potential growth area (currently disabled).") + + # 2. Conversion Actions + details.append("\n[2] Conversion Actions") + ca_query = """ + SELECT + conversion_action.id, + conversion_action.name, + conversion_action.type, + conversion_action.status, + conversion_action.owner_customer + FROM conversion_action + WHERE conversion_action.status != 'REMOVED' + """ + ca_results = run_query(client, customer_id, ca_query) + upload_clks_found = False + for row in ca_results: + ca = row.conversion_action + details.append(f"- {ca.name} (ID: {ca.id}): Type={ca.type.name}, Status={ca.status.name}") + if ca.type.name == "UPLOAD_CLICKS": + upload_clks_found = True + + if not upload_clks_found: + errors.append("WARNING: No UPLOAD_CLICKS conversion actions found. Mandatory for offline imports.") + + # 3. Offline Conversion Upload Summaries + details.append("\n[3] Offline Conversion Upload Summaries") + + # Client Summary + client_summary_query = """ + SELECT + 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.last_upload_date_time, + offline_conversion_upload_client_summary.client + FROM offline_conversion_upload_client_summary + """ + client_summary_results = run_query(client, customer_id, client_summary_query) + for row in client_summary_results: + cs = row.offline_conversion_upload_client_summary + details.append( + f"Client: {cs.client.name}, Status={cs.status.name}, Last Upload={cs.last_upload_date_time}, " + f"Success={cs.successful_event_count}/{cs.total_event_count}" + ) + if cs.status.name == "NEEDS_ATTENTION": + errors.append(f"ISSUE: Client '{cs.client.name}' NEEDS_ATTENTION. Check upload logs.") + + # Action Summary + action_summary_query = """ + SELECT + offline_conversion_upload_conversion_action_summary.status, + offline_conversion_upload_conversion_action_summary.successful_event_count, + offline_conversion_upload_conversion_action_summary.total_event_count, + offline_conversion_upload_conversion_action_summary.last_upload_date_time, + offline_conversion_upload_conversion_action_summary.conversion_action_name + FROM offline_conversion_upload_conversion_action_summary + """ + action_summary_results = run_query(client, customer_id, action_summary_query) + for row in action_summary_results: + asum = row.offline_conversion_upload_conversion_action_summary + details.append( + f"Action: {asum.conversion_action_name}, Status={asum.status.name}, Last Upload={asum.last_upload_date_time}, " + f"Success={asum.successful_event_count}/{asum.total_event_count}" + ) + if asum.status.name == "NEEDS_ATTENTION": + errors.append(f"ISSUE: Action '{asum.conversion_action_name}' NEEDS_ATTENTION. High failure rate detected.") + + # Final Summary Construction + if not errors: + summary.insert(0, "Overall Status: HEALTHY. No major conversion configuration issues detected.") + else: + summary.insert(0, f"Overall Status: UNHEALTHY. {len(errors)} potential issues identified.") + + # Write the file + with open(output_path, "w", encoding="utf-8") as f: + f.write("=== SUMMARY OF FINDINGS ===\n") + f.write("\n".join(summary) + "\n\n") + + f.write("=== ERRORS FOUND ===\n") + if not errors: + f.write("No errors detected.\n") + else: + f.write("\n".join(errors) + "\n") + f.write("\n") + + f.write("=== DETAILS ===\n") + f.write("\n".join(details) + "\n") + + print(f"Troubleshooting report generated: {output_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Collects troubleshooting data for conversions.") + parser.add_argument("-c", "--customer_id", dest="customer_id", required=True, help="The Google Ads customer ID.") + args = parser.parse_args() + + googleads_client = GoogleAdsClient.load_from_storage(version="v23") + + main(googleads_client, args.customer_id) diff --git a/saved/data/.gitkeep b/saved/data/.gitkeep new file mode 100644 index 0000000..e69de29 From 8bc6dff82ef4e4a03321aec67d96d166793a28fd Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 12:23:46 -0500 Subject: [PATCH 34/81] Reformated process to compose support data. --- .../commands/conversions_support_data.toml | 10 +- ...ollect_conversions_troubleshooting_data.py | 181 ++++++++++++++++++ conversions/GEMINI.md | 8 +- saved/data/.gitkeep | 0 4 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 api_examples/collect_conversions_troubleshooting_data.py create mode 100644 saved/data/.gitkeep diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml index af6ba73..36886b7 100644 --- a/.gemini/commands/conversions_support_data.toml +++ b/.gemini/commands/conversions_support_data.toml @@ -1,14 +1,14 @@ -description = "Collects diagnostic data to help gTech debug conversion problems." +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 diagnostic data for gTech support. +The User is experiencing issues with conversions and needs to collect structured diagnostic data for gTech support. Please execute the following actions: 1. Locate the current `customer_id` from `customer_id.txt` or context. -2. Run the diagnostic script using the command: `python3 collect_conversions_diagnostic_data.py --customer_id ` -3. Summarize the results from the terminal output. -4. Mention that a detailed CSV report has been saved to `saved/csv/conversions_diagnostic_report.csv`. +2. Run the structured troubleshooting script using the command: `python3 api_examples/collect_conversions_troubleshooting_data.py --customer_id ` +3. Summarize the findings from the script's terminal output (Summary and Error sections). +4. Mention that a detailed, shareable text report has been saved to the `saved/data/` directory. Here are the details from the user: {{args}} diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py new file mode 100644 index 0000000..2a49508 --- /dev/null +++ b/api_examples/collect_conversions_troubleshooting_data.py @@ -0,0 +1,181 @@ +# 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. + +"""Collects diagnostic data for conversion troubleshooting in a structured format.""" + +import argparse +import os +import sys +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 and returns the results.""" + ga_service = client.get_service("GoogleAdsService") + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + results = [] + for batch in response: + for row in batch.results: + results.append(row) + return results + 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}'.") + sys.exit(1) + + +def main(client: GoogleAdsClient, customer_id: str): + epoch = int(time.time()) + output_dir = "saved/data" + os.makedirs(output_dir, exist_ok=True) + output_filename = f"conversions_support_data_{epoch}.txt" + output_path = os.path.join(output_dir, output_filename) + + summary = [] + errors = [] + details = [] + + details.append(f"Diagnostic Report for Customer ID: {customer_id}") + details.append(f"Timestamp: {time.ctime()} (Epoch: {epoch})") + details.append("-" * 40) + + # 1. Customer Settings + details.append("\n[1] Customer Settings") + customer_query = """ + SELECT + customer.id, + customer.descriptive_name, + customer.conversion_tracking_setting.accepted_customer_data_terms, + customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled + FROM customer + """ + customer_results = run_query(client, customer_id, customer_query) + for row in customer_results: + settings = row.customer.conversion_tracking_setting + details.append(f"Customer Name: {row.customer.descriptive_name}") + details.append(f"EC for Leads Enabled: {settings.enhanced_conversions_for_leads_enabled}") + details.append(f"Customer Data Terms Accepted: {settings.accepted_customer_data_terms}") + + if not settings.accepted_customer_data_terms: + errors.append("CRITICAL: Customer Data Terms NOT accepted.") + if not settings.enhanced_conversions_for_leads_enabled: + summary.append("Note: Enhanced Conversions for Leads represents a potential growth area (currently disabled).") + + # 2. Conversion Actions + details.append("\n[2] Conversion Actions") + ca_query = """ + SELECT + conversion_action.id, + conversion_action.name, + conversion_action.type, + conversion_action.status, + conversion_action.owner_customer + FROM conversion_action + WHERE conversion_action.status != 'REMOVED' + """ + ca_results = run_query(client, customer_id, ca_query) + upload_clks_found = False + for row in ca_results: + ca = row.conversion_action + details.append(f"- {ca.name} (ID: {ca.id}): Type={ca.type.name}, Status={ca.status.name}") + if ca.type.name == "UPLOAD_CLICKS": + upload_clks_found = True + + if not upload_clks_found: + errors.append("WARNING: No UPLOAD_CLICKS conversion actions found. Mandatory for offline imports.") + + # 3. Offline Conversion Upload Summaries + details.append("\n[3] Offline Conversion Upload Summaries") + + # Client Summary + client_summary_query = """ + SELECT + 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.last_upload_date_time, + offline_conversion_upload_client_summary.client + FROM offline_conversion_upload_client_summary + """ + client_summary_results = run_query(client, customer_id, client_summary_query) + for row in client_summary_results: + cs = row.offline_conversion_upload_client_summary + details.append( + f"Client: {cs.client.name}, Status={cs.status.name}, Last Upload={cs.last_upload_date_time}, " + f"Success={cs.successful_event_count}/{cs.total_event_count}" + ) + if cs.status.name == "NEEDS_ATTENTION": + errors.append(f"ISSUE: Client '{cs.client.name}' NEEDS_ATTENTION. Check upload logs.") + + # Action Summary + action_summary_query = """ + SELECT + offline_conversion_upload_conversion_action_summary.status, + offline_conversion_upload_conversion_action_summary.successful_event_count, + offline_conversion_upload_conversion_action_summary.total_event_count, + offline_conversion_upload_conversion_action_summary.last_upload_date_time, + offline_conversion_upload_conversion_action_summary.conversion_action_name + FROM offline_conversion_upload_conversion_action_summary + """ + action_summary_results = run_query(client, customer_id, action_summary_query) + for row in action_summary_results: + asum = row.offline_conversion_upload_conversion_action_summary + details.append( + f"Action: {asum.conversion_action_name}, Status={asum.status.name}, Last Upload={asum.last_upload_date_time}, " + f"Success={asum.successful_event_count}/{asum.total_event_count}" + ) + if asum.status.name == "NEEDS_ATTENTION": + errors.append(f"ISSUE: Action '{asum.conversion_action_name}' NEEDS_ATTENTION. High failure rate detected.") + + # Final Summary Construction + if not errors: + summary.insert(0, "Overall Status: HEALTHY. No major conversion configuration issues detected.") + else: + summary.insert(0, f"Overall Status: UNHEALTHY. {len(errors)} potential issues identified.") + + # Write the file + with open(output_path, "w", encoding="utf-8") as f: + f.write("=== SUMMARY OF FINDINGS ===\n") + f.write("\n".join(summary) + "\n\n") + + f.write("=== ERRORS FOUND ===\n") + if not errors: + f.write("No errors detected.\n") + else: + f.write("\n".join(errors) + "\n") + f.write("\n") + + f.write("=== DETAILS ===\n") + f.write("\n".join(details) + "\n") + + print(f"Troubleshooting report generated: {output_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Collects troubleshooting data for conversions.") + parser.add_argument("-c", "--customer_id", dest="customer_id", required=True, help="The Google Ads customer ID.") + args = parser.parse_args() + + googleads_client = GoogleAdsClient.load_from_storage(version="v23") + + main(googleads_client, args.customer_id) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index b67e8cd..e3be55f 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -45,8 +45,8 @@ This document provides a technical reference for troubleshooting conversion-rela * If you want to check if the setting is correctly enabled using code, Gemini can execute this query against the `customer` resource for you: ```sql SELECT - customer.offline_conversion_tracking_info.enable_enhanced_conversions_for_leads, - customer.offline_conversion_tracking_info.accepted_customer_data_terms + customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled, + customer.conversion_tracking_setting.accepted_customer_data_terms FROM customer ``` * **Note**: After enabling the setting, it can sometimes take a few minutes for the API to recognize the change. If you still see the error immediately after saving, wait about 15–30 minutes and try your upload again. @@ -140,7 +140,7 @@ This document provides a technical reference for troubleshooting conversion-rela * You can run a quick check via the API to confirm the terms are now "Accepted": ```sql SELECT - customer.offline_conversion_tracking_info.accepted_customer_data_terms + customer.conversion_tracking_setting.accepted_customer_data_terms FROM customer ``` @@ -292,7 +292,7 @@ This document provides a technical reference for troubleshooting conversion-rela ### 3. Verification 1. **GCLID Ownership**: Query the `click_view` resource to verify if a GCLID belongs to the specific customer account. -2. **Customer Terms**: Check `customer.offline_conversion_tracking_info.accepted_customer_data_terms` via the `customer` resource. +2. **Customer Terms**: Check `customer.conversion_tracking_setting.accepted_customer_data_terms` via the `customer` resource. 3. **Data Normalization**: Ensure email addresses, phone numbers, and names are correctly normalized (trimmed, lowercased) and hashed (SHA-256) before sending. 4. **Consent**: Verify that `ClickConversion.consent` is properly set in the upload if required by regional policies. 5. **Logical Time Verification**: Before uploading any conversion, you MUST verify that the `conversion_date_time` is logically valid: diff --git a/saved/data/.gitkeep b/saved/data/.gitkeep new file mode 100644 index 0000000..e69de29 From 97ac45bf894af4c0d9b6428dbfb92768cd2947b3 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 16 Feb 2026 13:10:53 -0500 Subject: [PATCH 35/81] Test for api_examples/collect converstion diagnostics --- ...ollect_conversions_troubleshooting_data.py | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 api_examples/tests/test_collect_conversions_troubleshooting_data.py 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..e7d1a09 --- /dev/null +++ b/api_examples/tests/test_collect_conversions_troubleshooting_data.py @@ -0,0 +1,166 @@ +# 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 +import tempfile +import shutil +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 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" + + # Patching os.makedirs and open to avoid actual file system interaction + self.test_dir = tempfile.mkdtemp() + self.patch_makedirs = patch("os.makedirs") + self.mock_makedirs = self.patch_makedirs.start() + + # We need to mock open carefully because it's used by many things + self.patch_open = patch("builtins.open", unittest.mock.mock_open()) + self.mock_open = self.patch_open.start() + + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def tearDown(self): + sys.stdout = sys.__stdout__ + self.patch_makedirs.stop() + self.patch_open.stop() + shutil.rmtree(self.test_dir) + + def test_main_success_healthy(self): + # 1. Customer Settings Mock + mock_batch_customer = MagicMock() + 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 + mock_batch_customer.results = [mock_row_customer] + + # 2. Conversion Actions Mock + mock_batch_ca = MagicMock() + mock_row_ca = MagicMock() + mock_row_ca.conversion_action.id = 123 + mock_row_ca.conversion_action.name = "Test Action" + mock_row_ca.conversion_action.type.name = "UPLOAD_CLICKS" + mock_row_ca.conversion_action.status.name = "ENABLED" + mock_batch_ca.results = [mock_row_ca] + + # 3. Client Summary Mock + mock_batch_cs = MagicMock() + mock_row_cs = MagicMock() + mock_cs = mock_row_cs.offline_conversion_upload_client_summary + mock_cs.client.name = "GOOGLE_ADS_API" + mock_cs.status.name = "SUCCESS" + mock_cs.successful_event_count = 100 + mock_cs.total_event_count = 100 + mock_cs.last_upload_date_time = "2024-01-01 12:00:00" + mock_batch_cs.results = [mock_row_cs] + + # 4. Action Summary Mock + mock_batch_as = MagicMock() + mock_row_as = MagicMock() + mock_as = mock_row_as.offline_conversion_upload_conversion_action_summary + mock_as.conversion_action_name = "Test Action" + mock_as.status.name = "SUCCESS" + mock_as.successful_event_count = 50 + mock_as.total_event_count = 50 + mock_as.last_upload_date_time = "2024-01-01 12:00:00" + mock_batch_as.results = [mock_row_as] + + self.mock_ga_service.search_stream.side_effect = [ + [mock_batch_customer], + [mock_batch_ca], + [mock_batch_cs], + [mock_batch_as] + ] + + main(self.mock_client, self.customer_id) + + # Verify file write + self.mock_open.assert_called() + handle = self.mock_open() + + # Collect all written content + written_content = "".join(call.args[0] for call in handle.write.call_args_list) + + self.assertIn("Overall Status: HEALTHY", written_content) + self.assertIn("Customer Data Terms Accepted: True", written_content) + self.assertIn("Type=UPLOAD_CLICKS", written_content) + self.assertIn("Status=SUCCESS", written_content) + + def test_main_unhealthy_terms_not_accepted(self): + # 1. Customer Settings Mock (Terms NOT accepted) + mock_batch_customer = MagicMock() + 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_row_customer.customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled = True + mock_batch_customer.results = [mock_row_customer] + + # Mocks for other queries (empty or success) + mock_batch_empty = MagicMock() + mock_batch_empty.results = [] + + self.mock_ga_service.search_stream.side_effect = [ + [mock_batch_customer], + [mock_batch_empty], + [mock_batch_empty], + [mock_batch_empty] + ] + + main(self.mock_client, self.customer_id) + + handle = self.mock_open() + written_content = "".join(call.args[0] for call in handle.write.call_args_list) + + self.assertIn("Overall Status: UNHEALTHY", written_content) + self.assertIn("CRITICAL: Customer Data Terms NOT accepted", written_content) + + 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() From e9b72e60dda98559ce46b86c76e265ca0073d156 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 17 Feb 2026 16:39:53 +0000 Subject: [PATCH 36/81] Admonition in GEMINI.md to not use OR in WHERE clause. --- GEMINI.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 489ba89..9ed594c 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -124,7 +124,11 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 9. **Inter-Field Mutual Compatibility (CRITICAL):** Do not assume that because multiple fields are selectable with the resource in the `FROM` clause, they are compatible with each other. For every field included in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. This is especially important when combining high-level attributes (like campaign settings) with lower-level segments (like `segments.search_term_match_source`) or metrics. If Field A and Field B are in the same query, Field B must be in Field A's `selectable_with` list, AND Field A must be in Field B's `selectable_with` list. -10. **No OR Operator:** GAQL does not support the `OR` operator in the `WHERE` clause. If multiple criteria are required (e.g., searching for multiple name patterns), you **MUST** generate separate queries or perform the "OR" logic by filtering results in Python. +10. **No OR Operator (CRITICAL):** GAQL does NOT support the `OR` operator in the `WHERE` clause for any service, including `GoogleAdsService` and `GoogleAdsFieldService`. If you need to filter by multiple conditions that would normally use `OR`, you MUST either: + - Use the `IN` operator if the conditions apply to the same field (e.g., `WHERE resource.status IN ('ENABLED', 'PAUSED')`). + - Execute multiple separate queries and combine the results in your code. + - Retrieve a broader set of results (e.g., using `LIKE`) and perform the "OR" logic by filtering the data in Python. + - Failure to follow this will result in a `query_error: UNEXPECTED_INPUT` with the message `"Error in query: unexpected input OR."` 11. **Metadata Query Pitfall (CRITICAL):** If you receive `query_error: UNEXPECTED_FROM_CLAUSE` with message `"The FROM clause cannot be used in queries to any service except GoogleAdsService."`, it means you included a `FROM` clause in a `GoogleAdsFieldService` request (e.g., `SearchGoogleAdsFields`). You **MUST** remove the `FROM` clause and any resource name following it, and filter instead using `WHERE name = 'resource_name'` or `WHERE name LIKE 'resource_name.%'`. From 2877039ce4f60b698e573c2a8a536fce2e85a1f8 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 17 Feb 2026 18:31:31 +0000 Subject: [PATCH 37/81] Additional rules for specific GAQL queries. --- GEMINI.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 9ed594c..8d7cd50 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -113,7 +113,7 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 5. 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. -6. **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`). +6. **Core Date Segment Requirement (CRITICAL):** If any core date segment (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`) is present in the `SELECT` or `WHERE` clause, you MUST verify that the `WHERE` clause contains a **finite** date range filter. This MUST be achieved using either `DURING` with a valid constant (e.g., `LAST_30_DAYS`) or `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'`. Using single-sided operators like `>=` or `<=` is strictly prohibited for date segments as it triggers `query_error: EXPECTED_FILTERS_ON_DATE_RANGE` with the message `"Expects filters on the following field to limit a finite date range: 'segments.date'"`. 7. **Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` @@ -150,6 +150,8 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 19. **Metadata Resource Pitfall (CRITICAL):** The `google_ads_field` resource can ONLY be queried using the `GoogleAdsFieldService`. You MUST NOT attempt to query it using `GoogleAdsService.search` or `search_stream`. +20. **Prohibited 'OR' Operator (CRITICAL):** You are strictly forbidden from using the `OR` operator in the `WHERE` clause of any GAQL query for any service. This includes `GoogleAdsService` and `GoogleAdsFieldService`. Any attempt to use `OR` will result in a `query_error: UNEXPECTED_INPUT` with the message `"Error in query: unexpected input OR."` To achieve "OR" logic, you MUST either use the `IN` operator (if for the same field) or execute multiple separate queries and combine the results. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. From 2d6bcf2677e71d2d76d5f3beed9724fef92803ba Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 18 Feb 2026 14:28:00 +0000 Subject: [PATCH 38/81] Rules for GAQL and conversions --- GEMINI.md | 1 + conversions/GEMINI.md | 1 + 2 files changed, 2 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 8d7cd50..99de12b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -135,6 +135,7 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 12. **Metadata Query Syntax Restriction (CRITICAL):** The `GoogleAdsFieldService` (e.g., `SearchGoogleAdsFields`) uses a highly restricted query language. You **MUST NOT** use parentheses `()` or complex boolean logic (like combining `AND` and `OR`) in the `WHERE` clause. Queries MUST be flat and simple. If you need complex filtering for metadata, you **MUST** retrieve a broader set of results and perform the filtering in your Python code. 12. **Unrecognized Field Pitfall (CRITICAL):** If you receive `query_error: UNRECOGNIZED_FIELD`, it means one or more fields in your `SELECT` or `WHERE` clause do not exist in the confirmed API version. You **MUST NOT** guess or try alternative names based on intuition. You **MUST** immediately query the `GoogleAdsFieldService` to find the exact valid field names for that resource in the confirmed version before attempting the query again. + - **Note for google_ads_field:** If you are querying the `google_ads_field` resource using `GoogleAdsService.search` (though `GoogleAdsFieldService` is preferred), you **MUST** prefix all fields with `google_ads_field.` (e.g., `SELECT google_ads_field.name`). Failure to do so results in `UNRECOGNIZED_FIELD` errors for basic fields like `name` or `selectable`. 13. **Mandatory Schema Discovery:** Before querying any resource for the first time in a session, you **MUST** execute a schema-discovery query against `GoogleAdsFieldService` to list its valid fields. Relying on memory for field names is strictly prohibited and leads to avoidable `UNRECOGNIZED_FIELD` errors. diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index e3be55f..440c0eb 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -304,6 +304,7 @@ This document provides a technical reference for troubleshooting conversion-rela ### 4. Troubleshooting Workflow 1. **MANDATORY FIRST STEP: Diagnostic Summaries**: Before investigating specific errors or identifiers, you **MUST** execute queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. These resources provide the most accurate view of recent import health and systemic failures. + - **Attribute Pitfall (CRITICAL)**: When processing the `daily_summaries` list (which contains `OfflineConversionSummary` objects), the fields are `successful_count` and `failed_count`. You **MUST NOT** use `success_count` or `total_count`, as these will trigger an `AttributeError`. Calculate the total as the sum of successful and failed counts if needed. 2. **Check API Error Details**: Inspect the `GoogleAdsException` for specific `ErrorCode` and `message`. 3. **Verify Timestamps**: Ensure `conversion_date_time` is in `yyyy-mm-dd hh:mm:ss+|-hh:mm` format and falls within the lookback window. 4. **Validate Identifiers**: For `CLICK_NOT_FOUND`, ensure you are not mixing `gclid` with `gbraid` or `wbraid` inappropriately. Use only one per conversion. From 35e0557cb586d57dd00ca6a1a0fdd646c9997309 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 18 Feb 2026 15:34:20 +0000 Subject: [PATCH 39/81] GAQL rule specific to conversions --- conversions/GEMINI.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 440c0eb..7001cbe 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -299,7 +299,13 @@ This document provides a technical reference for troubleshooting conversion-rela * **Normalization**: Ensure both click and conversion timestamps are in the same timezone (preferably UTC) before comparing. * **No Pre-Click Conversions**: The conversion timestamp MUST be strictly after the click timestamp to avoid `CONVERSION_PRECEDES_EVENT`. * **Lookback Window**: The click MUST have occurred within the `click_through_lookback_window_days` defined for the conversion action to avoid `EXPIRED_EVENT`. -6. **No OR Operator (CRITICAL)**: GAQL does not support the `OR` operator in the `WHERE` clause. You **MUST** perform multiple separate queries or filter results in code to achieve "OR" logic. + +### 3.1 Rigorous GAQL Validation for Conversions +1. **No OR Operator (CRITICAL)**: GAQL does not support the `OR` operator in the `WHERE` clause. You **MUST** perform multiple separate queries or filter results in code to achieve "OR" logic. +2. **Conversion Metric Incompatibility (CRITICAL):** The `metrics.conversions` field is incompatible with the `conversion_action` resource in the `FROM` clause. To retrieve conversion metrics segmented by conversion action, you MUST use a compatible resource such as `customer`, `campaign`, or `ad_group` in the `FROM` clause and include `segments.conversion_action` in the `SELECT` clause. Any attempt to use `FROM conversion_action` with `metrics.conversions` will result in a `PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE` error. +3. **Metadata Query Syntax (CRITICAL):** When querying metadata resources (like `google_ads_field`) via services like `GoogleAdsFieldService`, you **MUST NOT** include a `FROM` clause in your GAQL query. Including a `FROM` clause will result in a `query_error: UNEXPECTED_FROM_CLAUSE`. Filter by `name` or other attributes in the `WHERE` clause instead. + - **CORRECT:** `SELECT name, selectable WHERE name = 'campaign.id'` + - **INCORRECT:** `SELECT name, selectable FROM google_ads_field WHERE name = 'campaign.id'` ### 4. Troubleshooting Workflow From 77782c765daf36fdb86c6d5d74831ef18ff13881 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 18 Feb 2026 10:38:46 -0500 Subject: [PATCH 40/81] Command for conversion report adds additional data --- .gemini/commands/conversions_support_data.toml | 10 ++++++---- ChangeLog | 11 +++++++++-- README_BEFORE_INSTALLATION.md | 8 ++++++++ gemini-extension.json | 2 +- 4 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 README_BEFORE_INSTALLATION.md diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml index 36886b7..fed416d 100644 --- a/.gemini/commands/conversions_support_data.toml +++ b/.gemini/commands/conversions_support_data.toml @@ -5,10 +5,12 @@ 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. Locate the current `customer_id` from `customer_id.txt` or context. -2. Run the structured troubleshooting script using the command: `python3 api_examples/collect_conversions_troubleshooting_data.py --customer_id ` -3. Summarize the findings from the script's terminal output (Summary and Error sections). -4. Mention that a detailed, shareable text report has been saved to the `saved/data/` directory. +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. Summarize the findings from the script's terminal output (Summary and Error sections). +6. Mention that a detailed, shareable text report has been saved to the `saved/data/` directory. Here are the details from the user: {{args}} diff --git a/ChangeLog b/ChangeLog index 4b92ec5..2ebb676 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,4 +1,11 @@ -* 1.6.0 +* 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/ @@ -6,10 +13,10 @@ - Hooks for start and end of a session. - Mandatory GAQL validation rules to GEMINI.md - Dynamic grpc interceptor for Python calls within extension. -- Hierachical context file for conversions troubleshooting. - 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. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/README_BEFORE_INSTALLATION.md b/README_BEFORE_INSTALLATION.md new file mode 100644 index 0000000..c50d2e5 --- /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. In addition to a 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. +* Clone the repository again. (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/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" } From ee58b599ce5b089c2ee1dea91d9826f0a005d89e Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 18 Feb 2026 16:08:35 +0000 Subject: [PATCH 41/81] Added rule to ensure complete diagnostics in output files --- GEMINI.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 99de12b..7bfaf20 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -43,7 +43,7 @@ This document outlines mandatory operational guidelines, constraints, and best p #### 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.1. Manual Version Confirmation Fallback +#### 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. @@ -237,6 +237,13 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit - **SharedSet:** Reusable collection of criteria. - **SharedCriterion:** Criterion within a SharedSet. +#### 3.7. Structured Reporting Mandate +When generating diagnostic reports or using automated troubleshooting scripts (e.g., 'collect_conversions_troubleshooting_data.py'): +1. **Manual File Post-Processing:** You MUST NOT assume that the script handles custom formatting. After the script executes, you MUST use `read_file` to verify the output and `write_file` to manually prepend: + - The mandatory header: "Created by the Google Ads API Developer Assistant". + - Any "Previous Diagnostic Analysis" found in the current session or in recent files within `saved/data/`. +2. **Verification Check:** You MUST confirm the final file content contains all requested elements before reporting completion to the user. + --- ### 4. Tool Usage @@ -252,7 +259,7 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit * **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. + - **Parameter Retrieval:** Retrieve script parameters (e.g., `customer_id`) from the user prompt or session context if available. Only use `customer_id.txt` as a fallback if no ID is specified by the user. 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. From 40bf0851dc88480e1e6390fac10640252e067b58 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 18 Feb 2026 14:03:25 -0500 Subject: [PATCH 42/81] Updates to README and associated instructional files. --- CONTRIBUTING.md | 28 ---------------------------- FAQ.md | 23 +++++++++++++++++------ README.md | 24 +++++++++++++++--------- README_BEFORE_INSTALLATION.md | 4 ++-- 4 files changed, 34 insertions(+), 45 deletions(-) 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/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/README.md b/README.md index a52e87d..f06cf84 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,19 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the * **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'."* + ## 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. @@ -62,13 +66,13 @@ By default, Python is used for code generation. You can change this by prefacing * **Linux/macOS:** * Ensure that [jq](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. * Run `./install.sh`. - * By default (no arguments), this installs **ALL** supported client libraries to `$HOME/gaada`. - * To install specific languages, use flags: `./install.sh --python --php`. + * 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 `.\install.ps1`. - * By default, this installs **ALL** supported client libraries to `$HOME\gaada`. - * To install specific languages, use parameters: `.\install.ps1 -Python -Php`. + * 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. @@ -126,8 +130,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` @@ -142,6 +147,7 @@ Or, you can execute `run list_commands.py` from within the Assistant to see the * `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/data/`: Stores diagnostic and troubleshooting reports. * `customer_id.txt`: (Optional) Stores the default customer ID. ## Mutate Operations @@ -153,7 +159,7 @@ 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. ## Maintenance diff --git a/README_BEFORE_INSTALLATION.md b/README_BEFORE_INSTALLATION.md index c50d2e5..2e8b15b 100644 --- a/README_BEFORE_INSTALLATION.md +++ b/README_BEFORE_INSTALLATION.md @@ -1,8 +1,8 @@ # Google Ads API Developer Assistant v2.0.0 -v2.0.0 is a major release of the Google Ads API Developer Assistant. In addition to a many new features there is also a new directory structure. If you are upgrading from a previous version: +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. +* 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 again. (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 From 1d43a2dede5c392e264d2e465aea040ca5d25866 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 13:15:43 +0000 Subject: [PATCH 43/81] Rules to make conversion diagnostic report clearer. --- .gemini/hooks/custom_config.py | 97 +++++++++++++++++++++++++++ .gemini/hooks/custom_config_python.py | 63 ----------------- .gemini/settings.json | 14 ++-- GEMINI.md | 9 +-- README.md | 6 +- conversions/GEMINI.md | 16 ++++- 6 files changed, 127 insertions(+), 78 deletions(-) create mode 100644 .gemini/hooks/custom_config.py delete mode 100644 .gemini/hooks/custom_config_python.py diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py new file mode 100644 index 0000000..7990fc1 --- /dev/null +++ b/.gemini/hooks/custom_config.py @@ -0,0 +1,97 @@ +import os +import shutil +import subprocess +import json +import sys + +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 None + +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): + if is_python: + print(f"Error: {home_config} does not exist. Please create it in your home directory.", file=sys.stderr) + sys.exit(1) + else: + print(f"Warning: {home_config} does not exist for {lang_name}. Skipping.", file=sys.stderr) + return False + + try: + shutil.copy2(home_config, target_config) + with open(target_config, "a", encoding="utf-8") as f: + # Python/YAML uses :, INI/Ruby often can use = in these formats + sep = ":" if is_python else "=" + f.write(f"\ngaada {sep} \"{version}\"\n") + + if is_python: + # Python hook originally exported this env var + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_config}\"", file=sys.stdout) + + print(f"Configured {lang_name} at {target_config}") + 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, "../..")) + settings_path = os.path.join(project_root, ".gemini/settings.json") + 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) + if not version: + sys.exit(1) + + # 1. Configure Python (Always) + python_home = os.path.join(os.path.expanduser("~"), "google-ads.yaml") + python_target = os.path.join(config_dir, "google-ads.yaml") + configure_language("Python", python_home, python_target, version, is_python=True) + + # 2. Configure other languages (Conditional) + 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 as e: + print(f"Error reading settings.json: {e}", file=sys.stderr) + include_dirs = [] + + languages = [ + { + "id": "google-ads-php", + "name": "PHP", + "filename": "google_ads_php.ini", + "home": os.path.join(os.path.expanduser("~"), "google_ads_php.ini") + }, + { + "id": "google-ads-ruby", + "name": "Ruby", + "filename": "google_ads_config.rb", + "home": os.path.join(os.path.expanduser("~"), "google_ads_config.rb") + } + ] + + for lang in languages: + enabled = any(lang["id"] in d for d in include_dirs) + if enabled: + target = os.path.join(config_dir, lang["filename"]) + configure_language(lang["name"], lang["home"], target, version) + +if __name__ == "__main__": + main() diff --git a/.gemini/hooks/custom_config_python.py b/.gemini/hooks/custom_config_python.py deleted file mode 100644 index 9f623af..0000000 --- a/.gemini/hooks/custom_config_python.py +++ /dev/null @@ -1,63 +0,0 @@ -import os -import shutil -import subprocess -import sys -import datetime - -def configure(): - """Configures the Google Ads environment.""" - - # Determine paths - script_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.abspath(os.path.join(script_dir, "../..")) - - source_yaml = os.path.join(os.path.expanduser("~"), "google-ads.yaml") - config_dir = os.path.join(project_root, "config") - target_yaml = os.path.join(config_dir, "google-ads.yaml") - ext_version_script = os.path.join(project_root, ".gemini/skills/ext_version/scripts/get_extension_version.py") - - # Check if source exists - if not os.path.exists(source_yaml): - # Fail silently or print error? Hooks might be noisy. - # But user requested "If it does not exist display an error and stop" originally. - # We will keep the error message but maybe not exit(1) if we don't want to break the session? - # User requirement said "If it does not exist display an error and stop", so we stick to it. - print(f"Error: {source_yaml} does not exist. Please create it in the project root.", file=sys.stderr) - sys.exit(1) - - # Create config directory - os.makedirs(config_dir, exist_ok=True) - - # Copy file - shutil.copy2(source_yaml, target_yaml) - - # Get extension version - try: - # Run the extension version script via python3 - result = subprocess.run( - [sys.executable, ext_version_script], - capture_output=True, - text=True, - check=True - ) - version = result.stdout.strip() - except subprocess.CalledProcessError as e: - print(f"Error getting extension version: {e.stderr}", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"Error executing version script: {e}", file=sys.stderr) - sys.exit(1) - - # Append version to target yaml - try: - with open(target_yaml, "a", encoding="utf-8") as f: - f.write(f"\ngaada: \"{version}\"\n") - except Exception as e: - print(f"Error appending to {target_yaml}: {e}", file=sys.stderr) - sys.exit(1) - - # Output env var command - print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_yaml}\"", file=sys.stdout) - -if __name__ == "__main__": - configure() diff --git a/.gemini/settings.json b/.gemini/settings.json index 5496f66..82189b2 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -5,11 +5,11 @@ "enableLoadingPhrases": false } }, - "general": { + "general": { "checkpointing": { "enabled": true }, - "sessionRetention": { + "sessionRetention": { "enabled": true, "maxAge": "30d", "maxCount": 50 @@ -17,9 +17,9 @@ }, "context": { "includeDirectories": [ - "/full/path/google-ads-api-developer-assistant/api_examples", - "/full/path/google-ads-api-developer-assistant/saved/code", - "/full/path/google-ads-api-developer-assistant/client_libs/google-ads-python" + "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/api_examples", + "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/saved/code", + "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/client_libs/google-ads-python" ] }, "tools": { @@ -33,7 +33,7 @@ { "name": "init", "type": "command", - "command": "python3 .gemini/hooks/custom_config_python.py" + "command": "python3 .gemini/hooks/custom_config.py" } ] } @@ -51,4 +51,4 @@ } ] } -} +} \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md index 7bfaf20..a41693d 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -36,7 +36,8 @@ This document outlines mandatory operational guidelines, constraints, and best p 4. **CONFIRM:** You must state the version you found and the source URL, then ask for confirmation. For example: "Based on the release notes at [URL], the latest stable Google Ads API version appears to be vXX. Is it OK to proceed?". 5. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. 6. **REJECT/RETRY:** If the user rejects the version, repeat step 1. -7. **NEVER** save the confirmed API version to memory. +7. **SESSION PERSISTENCE:** Once the latest stable version has been confirmed by the user within a specific session, you MUST NOT repeat the validation workflow for subsequent tasks in that same session. +8. **NEVER** save the confirmed API version to memory. **FAILURE TO FOLLOW THIS IS A CRITICAL ERROR.** @@ -109,9 +110,9 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali * Examine the `selectable_with` attribute of the main resource to find the correct fields for filtering and selection. * **MANDATORY TOOL CALL:** You MUST execute a tool call to `run_shell_command` or similar to query the `GoogleAdsFieldService` and physically see the `selectable_with` list before you present any query to the user. Skipping this is a critical failure. -5. Segment Rule: You MUST verify that any field (attribute or segment) 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). +5. **Referenced Field Rule (CRITICAL):** You MUST verify that any field (attribute or segment) used in the `WHERE` clause is also present in the `SELECT` clause. Failure to do so will result in a `query_error: EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. The only exceptions are core date segments (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`). -5. 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. +6. 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. 6. **Core Date Segment Requirement (CRITICAL):** If any core date segment (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`) is present in the `SELECT` or `WHERE` clause, you MUST verify that the `WHERE` clause contains a **finite** date range filter. This MUST be achieved using either `DURING` with a valid constant (e.g., `LAST_30_DAYS`) or `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'`. Using single-sided operators like `>=` or `<=` is strictly prohibited for date segments as it triggers `query_error: EXPECTED_FILTERS_ON_DATE_RANGE` with the message `"Expects filters on the following field to limit a finite date range: 'segments.date'"`. @@ -259,7 +260,7 @@ When generating diagnostic reports or using automated troubleshooting scripts (e * **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 the user prompt or session context if available. Only use `customer_id.txt` as a fallback if no ID is specified by the user. NEVER ask the user. + - **Parameter Retrieval:** Retrieve script parameters (e.g., `customer_id`) from the user prompt or session context if available. If the session is already investigating a specific `customer_id`, you MUST NOT check `customer_id.txt` for that parameter. Only use `customer_id.txt` as a fallback if no ID is specified by the user and no active investigation CID exists in the session context. 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. diff --git a/README.md b/README.md index f06cf84..2888dc5 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,13 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of "/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-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 @@ -132,7 +134,7 @@ This is a partial list of custom commands: * `/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/`. +* `/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` diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 7001cbe..e4dc3c2 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -322,7 +322,19 @@ This document provides a technical reference for troubleshooting conversion-rela - 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. -### 5. Output and Documentation +### 6. Structured Diagnostic Reporting +When providing a final diagnostic summary to the user, you MUST follow this structured format to ensure maximum technical clarity: + +1. **Introductory Analysis Statement**: Start with "I have analyzed the data for Customer ID XXX-XXX-XXXX [and Job ID/Action ID if applicable], and I have identified the primary reason why [describe the core issue]..." +2. **Numbered Technical Findings**: Provide detailed, numbered sections for each key factor (e.g., "1. 'Include in Conversions' Setting", "2. Job Status and Processing"). +3. **Specific Observations**: Use bullet points within findings to highlight data-backed observations (e.g., success rates, metric discrepancies, or attribute settings). +4. **Actionable Recommendations**: Conclude with a "Recommendations" section listing specific steps for the user or partner. +5. **Handling Empty Diagnostic Sections (CRITICAL)**: If the automated diagnostic report (e.g., from `collect_conversions_troubleshooting_data.py`) contains empty sections for "[2] Conversion Actions" or "[3] Offline Conversion Upload Summaries", you MUST perform a manual update to the report file (using `write_file` or `replace`) to append the reason why those sections are blank: + - **Empty Conversion Actions**: Append: "Reason: No non-removed conversion actions found for this Customer ID." + - **Empty Upload Summaries**: Append: "Reason: These summaries only track standard offline imports (GCLID/Call). No such imports have been detected in the last 90 days. Note that Store Sales (managed via Offline User Data Jobs) are not reflected in these specific summaries." + - This explanation MUST appear within the file itself before you present it to the user. -#### 5.1. References +--- + +### 7. References - **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` From d8d4a34e7bad7af6f8847a9a1ebc3a1bfe5e0ba6 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 14:26:15 +0000 Subject: [PATCH 44/81] Added rule to the the environment variable to the correct configuration file. in the config sub-directory. --- GEMINI.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index a41693d..890d79e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -179,10 +179,15 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit - Pass `customer_id` as a command-line argument. - Use type hints, annotations, or other static typing features if the language supports them. -#### 3.4.1. Python Configuration Loading -- **Code Generation (to `saved/code/`):** When generating Python code that uses the `google-ads-python` client library and saves it to the `saved/code/` directory, any calls to `GoogleAdsClient.load_from_storage()` MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for `google-ads.yaml` in their home directory (or other default locations as per the client library's behavior). -- **CRITICAL Execution within Gemini CLI:** When executing Python code that uses `GoogleAdsClient.load_from_storage()` within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to `config/google-ads.yaml` before running the script. **NEVER** use `client_libs/google-ads-python/google-ads.yaml`. This ensures the script uses the project's configuration file located at `config/google-ads.yaml` during execution within the CLI environment. -- **User Instructions:** When providing commands or instructions to a user for running a script, you MUST NOT include the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable. This variable is strictly for internal use by the assistant when executing scripts within the Gemini CLI. User-facing instructions should assume the user has configured their `google-ads.yaml` in the standard default location (e.g., their home directory). +#### 3.4.1. Configuration Loading +- **Code Generation (to `saved/code/`):** When generating code that uses the Google Ads API client libraries and saves it to the `saved/code/` directory, any calls to load configuration (e.g., `GoogleAdsClient.load_from_storage()` in Python) MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for the configuration file in their home directory (or other default locations as per the client library's behavior). +- **CRITICAL Execution within Gemini CLI:** When executing code within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to point to the correct configuration file in the `config/` directory for the preferred language: + - **Python:** `config/google-ads.yaml` + - **Ruby:** `config/google_ads_config.rb` + - **PHP:** `config/google_ads_php.ini` +- **Absolute Path Requirement:** If the user or environment requires an absolute path, ensure it follows the format: `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/config/`. +- **NEVER** use configuration files located within `client_libs/`. This ensures the script uses the project's configuration file located at `config/` during execution within the CLI environment. +- **User Instructions:** When providing commands or instructions to a user for running a script, you MUST NOT include the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable. This variable is strictly for internal use by the assistant when executing scripts within the Gemini CLI. User-facing instructions should assume the user has configured their credentials in the standard default location (e.g., their home directory). - **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The `ex` object contains the high-level, structured Google Ads failure details in the `ex.failure` attribute. To access the detailed list of errors, you **MUST** iterate over `ex.failure.errors`. **NEVER** attempt to access `ex.error.errors`, as `ex.error` is the underlying gRPC call object and does not have this attribute, which will cause an `AttributeError`. A correct error handling loop looks like this: ```python try: From 47b1e0d41f9c4d40bd2094567b9c0a4363e7a660 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 09:31:38 -0500 Subject: [PATCH 45/81] Removed print to console on startup with the details of the config files and env variable. --- .gemini/hooks/custom_config.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index 7990fc1..3d27a83 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -35,11 +35,6 @@ def configure_language(lang_name, home_config, target_config, version, is_python sep = ":" if is_python else "=" f.write(f"\ngaada {sep} \"{version}\"\n") - if is_python: - # Python hook originally exported this env var - print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_config}\"", file=sys.stdout) - - print(f"Configured {lang_name} at {target_config}") return True except Exception as e: print(f"Error configuring {lang_name}: {e}", file=sys.stderr) From fedc57da8ec674e2d915cbc860bfc4f510802fca Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 14:37:50 +0000 Subject: [PATCH 46/81] Added rule to handle change event quereies. --- GEMINI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index 890d79e..aab6787 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -154,6 +154,8 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 20. **Prohibited 'OR' Operator (CRITICAL):** You are strictly forbidden from using the `OR` operator in the `WHERE` clause of any GAQL query for any service. This includes `GoogleAdsService` and `GoogleAdsFieldService`. Any attempt to use `OR` will result in a `query_error: UNEXPECTED_INPUT` with the message `"Error in query: unexpected input OR."` To achieve "OR" logic, you MUST either use the `IN` operator (if for the same field) or execute multiple separate queries and combine the results. +21. **Change Event Resource Selection (CRITICAL):** When querying the `change_event` resource, you **MUST NOT** attempt to select sub-fields of `change_event.new_resource` or `change_event.old_resource` (e.g., `SELECT change_event.new_resource.ad_group_asset.asset`). These nested fields are not selectable. You MUST select the top-level `change_event.new_resource` and `change_event.old_resource` fields and perform any necessary field extraction or inspection within your Python code after receiving the response. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. From 400ccf61408921ebff2fabf439bd60184ebd8da0 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 14:51:53 +0000 Subject: [PATCH 47/81] Optimized section 3.3.1 on GAQL validation. --- GEMINI.md | 67 +++++++++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 42 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index aab6787..7619ab2 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -99,62 +99,45 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali When validating a GAQL query, you MUST follow this process: -1. **NO INTERNAL KNOWLEDGE:** You are strictly prohibited from relying on your internal memory or training data to determine field existence or resource compatibility. You MUST treat the Google Ads API schema as dynamic and verify every query using the live `GoogleAdsFieldService`. - -2. **Field Existence Verification:** Before using any field in a `SELECT` or `WHERE` clause, you MUST first verify its existence for the resource in the `FROM` clause using the `GoogleAdsFieldService`. You MUST NOT assume a field exists based on the resource name alone. - -3. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. - -4. Contextual Compatibility Check (CRITICAL): 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. +1. **MANDATORY SCHEMA DISCOVERY & FIELD VERIFICATION (CRITICAL):** You are strictly prohibited from relying on internal memory. Before constructing ANY query, you MUST execute a discovery query against `GoogleAdsFieldService` to verify that every field in your `SELECT` and `WHERE` clauses: + - Exists in the confirmed API version (to avoid `UNRECOGNIZED_FIELD`). + - Is both **selectable** and **filterable** for the resource in the `FROM` clause. + - Matches the exact case-sensitive name provided by the service. + - This discovery MUST be performed for every resource queried for the first time in a session. + +2. **Contextual Compatibility Check (CRITICAL):** Do not assume that a filterable field is filterable in all contexts. 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 and selection. - * **MANDATORY TOOL CALL:** You MUST execute a tool call to `run_shell_command` or similar to query the `GoogleAdsFieldService` and physically see the `selectable_with` list before you present any query to the user. Skipping this is a critical failure. - -5. **Referenced Field Rule (CRITICAL):** You MUST verify that any field (attribute or segment) used in the `WHERE` clause is also present in the `SELECT` clause. Failure to do so will result in a `query_error: EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. The only exceptions are core date segments (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`). - -6. 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. - -6. **Core Date Segment Requirement (CRITICAL):** If any core date segment (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`) is present in the `SELECT` or `WHERE` clause, you MUST verify that the `WHERE` clause contains a **finite** date range filter. This MUST be achieved using either `DURING` with a valid constant (e.g., `LAST_30_DAYS`) or `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'`. Using single-sided operators like `>=` or `<=` is strictly prohibited for date segments as it triggers `query_error: EXPECTED_FILTERS_ON_DATE_RANGE` with the message `"Expects filters on the following field to limit a finite date range: 'segments.date'"`. - -7. **Policy-Summary Field Rules:** The `ad_group_ad.policy_summary` field is a special case. You **MUST NOT** select the entire `ad_group_ad.policy_summary` object or its individual sub-fields (like `approval_status`, `policy_topic_entries.topic`, etc.) directly. The **ONLY** valid way to retrieve policy information is to select the `ad_group_ad.policy_summary.policy_topic_entries` field. You must then iterate through the results of this field in your code to access the individual policy topics. - - **CORRECT:** `SELECT ad_group_ad.policy_summary.policy_topic_entries FROM ad_group_ad` - - **INCORRECT:** `SELECT ad_group_ad.policy_summary FROM ad_group_ad` - - **INCORRECT:** `SELECT ad_group_ad.policy_summary.approval_status FROM ad_group_ad` - -8. **Service-Specific Query Syntax:** The `GoogleAdsService` is the **only** service that accepts standard GAQL queries containing a `FROM` clause (e.g., `SELECT ... FROM ...`). When querying other services, such as the `GoogleAdsFieldService`, you **MUST** use their specific methods (e.g., `get_google_ads_field` or `search_google_ads_fields` with its specialized query format) and **MUST NOT** include a `FROM` clause in the request. - -9. **Inter-Field Mutual Compatibility (CRITICAL):** Do not assume that because multiple fields are selectable with the resource in the `FROM` clause, they are compatible with each other. For every field included in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. This is especially important when combining high-level attributes (like campaign settings) with lower-level segments (like `segments.search_term_match_source`) or metrics. If Field A and Field B are in the same query, Field B must be in Field A's `selectable_with` list, AND Field A must be in Field B's `selectable_with` list. + * **MANDATORY TOOL CALL:** You MUST physically see the `selectable_with` list via a tool call before presenting any query to the user. -10. **No OR Operator (CRITICAL):** GAQL does NOT support the `OR` operator in the `WHERE` clause for any service, including `GoogleAdsService` and `GoogleAdsFieldService`. If you need to filter by multiple conditions that would normally use `OR`, you MUST either: - - Use the `IN` operator if the conditions apply to the same field (e.g., `WHERE resource.status IN ('ENABLED', 'PAUSED')`). - - Execute multiple separate queries and combine the results in your code. - - Retrieve a broader set of results (e.g., using `LIKE`) and perform the "OR" logic by filtering the data in Python. - - Failure to follow this will result in a `query_error: UNEXPECTED_INPUT` with the message `"Error in query: unexpected input OR."` +3. **Referenced Field Rule (CRITICAL):** You MUST verify that any field used in the `WHERE` clause is also present in the `SELECT` clause (except for core date segments). Failure to do so results in `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. -11. **Metadata Query Pitfall (CRITICAL):** If you receive `query_error: UNEXPECTED_FROM_CLAUSE` with message `"The FROM clause cannot be used in queries to any service except GoogleAdsService."`, it means you included a `FROM` clause in a `GoogleAdsFieldService` request (e.g., `SearchGoogleAdsFields`). You **MUST** remove the `FROM` clause and any resource name following it, and filter instead using `WHERE name = 'resource_name'` or `WHERE name LIKE 'resource_name.%'`. +4. **Prioritize Validator Errors:** If the user provides an error message from a GAQL query validator, treat it as the source of truth and immediately re-evaluate your validation. -12. **Metadata Query Syntax Restriction (CRITICAL):** The `GoogleAdsFieldService` (e.g., `SearchGoogleAdsFields`) uses a highly restricted query language. You **MUST NOT** use parentheses `()` or complex boolean logic (like combining `AND` and `OR`) in the `WHERE` clause. Queries MUST be flat and simple. If you need complex filtering for metadata, you **MUST** retrieve a broader set of results and perform the filtering in your Python code. +5. **Core Date Segment Requirement (CRITICAL):** If a core date segment (`segments.date`, etc.) is present in the `SELECT` or `WHERE` clause, you MUST include a **finite** date range filter in the `WHERE` clause using `DURING` (with valid constants) or `BETWEEN`. Single-sided operators (`>=`, `<=`) are strictly prohibited. -12. **Unrecognized Field Pitfall (CRITICAL):** If you receive `query_error: UNRECOGNIZED_FIELD`, it means one or more fields in your `SELECT` or `WHERE` clause do not exist in the confirmed API version. You **MUST NOT** guess or try alternative names based on intuition. You **MUST** immediately query the `GoogleAdsFieldService` to find the exact valid field names for that resource in the confirmed version before attempting the query again. - - **Note for google_ads_field:** If you are querying the `google_ads_field` resource using `GoogleAdsService.search` (though `GoogleAdsFieldService` is preferred), you **MUST** prefix all fields with `google_ads_field.` (e.g., `SELECT google_ads_field.name`). Failure to do so results in `UNRECOGNIZED_FIELD` errors for basic fields like `name` or `selectable`. +6. **Policy-Summary Field Rules:** You **MUST NOT** select sub-fields of `ad_group_ad.policy_summary` (e.g., `approval_status`). The **ONLY** valid way to retrieve policy info is to select `ad_group_ad.policy_summary.policy_topic_entries` and iterate through the results in code. -13. **Mandatory Schema Discovery:** Before querying any resource for the first time in a session, you **MUST** execute a schema-discovery query against `GoogleAdsFieldService` to list its valid fields. Relying on memory for field names is strictly prohibited and leads to avoidable `UNRECOGNIZED_FIELD` errors. +7. **METADATA SERVICE CONSTRAINTS (CRITICAL):** When querying the `GoogleAdsFieldService` (e.g., `SearchGoogleAdsFields` or the `google_ads_field` resource): + - **NO FROM CLAUSE:** You MUST NOT include a `FROM` clause. Use `WHERE name = 'resource_name'` instead. + - **FLAT SYNTAX:** You MUST NOT use parentheses `()` or complex boolean logic. + - **PREFIXING:** When using `GoogleAdsService.search` for metadata, all fields MUST be prefixed with `google_ads_field.` (e.g., `google_ads_field.name`). -14. **Enum Value Verification (CRITICAL):** If you receive `query_error: BAD_ENUM_CONSTANT`, it means the enum value used in your `WHERE` clause is invalid for that field. You **MUST** query the `GoogleAdsFieldService` for the field's `enum_values` attribute to retrieve the exact valid string constants for the confirmed API version. +8. **Inter-Field Mutual Compatibility (CRITICAL):** For every field in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. Verify this via `GoogleAdsFieldService` for EVERY query. -15. **Finite Date Range for Change Status (CRITICAL):** When querying the `change_status` resource, you **MUST** include a finite date range filter on `change_status.last_change_date_time` using both a start and an end boundary (e.g., `BETWEEN 'YYYY-MM-DD HH:MM:SS' AND 'YYYY-MM-DD HH:MM:SS'`). Providing only a start boundary (e.g., `>=`) results in a `CHANGE_DATE_RANGE_INFINITE` error. +9. **PROHIBITED 'OR' OPERATOR (CRITICAL):** GAQL does NOT support the `OR` operator in the `WHERE` clause for any service. You MUST use the `IN` operator (if for the same field) or execute multiple separate queries and combine results in code. Failure results in `unexpected input OR`. -16. **Limit Requirement for Change Status (CRITICAL):** When querying the `change_status` resource, you **MUST** specify a `LIMIT` clause in your query. The limit must be less than or equal to 10,000. Failure to specify a limit results in a `LIMIT_NOT_SPECIFIED` error. +10. **Unrecognized Field Pitfall (CRITICAL):** If you receive `UNRECOGNIZED_FIELD`, do not guess alternative names. Immediately query `GoogleAdsFieldService` to find the valid field names for the confirmed version. -17. **Single Day Filter for Click View (CRITICAL):** When querying the `click_view` resource, you **MUST** include a filter that limits the results to a single day (e.g., `WHERE segments.date = 'YYYY-MM-DD'`). Failure to do so results in an `EXPECTED_FILTER_ON_A_SINGLE_DAY` error. +11. **Enum Value Verification (CRITICAL):** If you receive `BAD_ENUM_CONSTANT`, you MUST query the field's `enum_values` attribute in `GoogleAdsFieldService` to retrieve the valid string constants for the confirmed version. -18. **Field Name Verification (CRITICAL):** To prevent `UNRECOGNIZED_FIELD` errors, you **MUST** verify the exact name of every field used in a `SELECT` or `WHERE` clause by querying the `GoogleAdsFieldService` before presenting or executing the query. This is especially mandatory when querying a resource for the first time in a session or when attempting to 'join' related resources. Guessing field names based on resource names is strictly prohibited. +12. **Finite Date Range for Change Status (CRITICAL):** Queries for the `change_status` resource MUST include a finite date range filter on `change_status.last_change_date_time` using `BETWEEN` with both start and end boundaries. -19. **Metadata Resource Pitfall (CRITICAL):** The `google_ads_field` resource can ONLY be queried using the `GoogleAdsFieldService`. You MUST NOT attempt to query it using `GoogleAdsService.search` or `search_stream`. +13. **Limit Requirement for Change Status (CRITICAL):** Queries for `change_status` MUST specify a `LIMIT` clause (maximum 10,000). -20. **Prohibited 'OR' Operator (CRITICAL):** You are strictly forbidden from using the `OR` operator in the `WHERE` clause of any GAQL query for any service. This includes `GoogleAdsService` and `GoogleAdsFieldService`. Any attempt to use `OR` will result in a `query_error: UNEXPECTED_INPUT` with the message `"Error in query: unexpected input OR."` To achieve "OR" logic, you MUST either use the `IN` operator (if for the same field) or execute multiple separate queries and combine the results. +14. **Single Day Filter for Click View (CRITICAL):** Queries for the `click_view` resource MUST include a filter limiting results to a single day (`WHERE segments.date = 'YYYY-MM-DD'`). -21. **Change Event Resource Selection (CRITICAL):** When querying the `change_event` resource, you **MUST NOT** attempt to select sub-fields of `change_event.new_resource` or `change_event.old_resource` (e.g., `SELECT change_event.new_resource.ad_group_asset.asset`). These nested fields are not selectable. You MUST select the top-level `change_event.new_resource` and `change_event.old_resource` fields and perform any necessary field extraction or inspection within your Python code after receiving the response. +15. **Change Event Resource Selection (CRITICAL):** You **MUST NOT** select sub-fields of `change_event.new_resource` or `change_event.old_resource`. Select the top-level fields and perform extraction in code. #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: From 33f7b02d374779832361bb8b84ba90dfc04fa026 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 15:25:19 +0000 Subject: [PATCH 48/81] Call Simply query validation dry run before presenting queries in response. Act upon error messages and propose a fixed query. --- GEMINI.md | 34 +++++++++++++---- api_examples/gaql_validator.py | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 api_examples/gaql_validator.py diff --git a/GEMINI.md b/GEMINI.md index 7619ab2..79f1ca0 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -118,10 +118,11 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 6. **Policy-Summary Field Rules:** You **MUST NOT** select sub-fields of `ad_group_ad.policy_summary` (e.g., `approval_status`). The **ONLY** valid way to retrieve policy info is to select `ad_group_ad.policy_summary.policy_topic_entries` and iterate through the results in code. -7. **METADATA SERVICE CONSTRAINTS (CRITICAL):** When querying the `GoogleAdsFieldService` (e.g., `SearchGoogleAdsFields` or the `google_ads_field` resource): - - **NO FROM CLAUSE:** You MUST NOT include a `FROM` clause. Use `WHERE name = 'resource_name'` instead. - - **FLAT SYNTAX:** You MUST NOT use parentheses `()` or complex boolean logic. - - **PREFIXING:** When using `GoogleAdsService.search` for metadata, all fields MUST be prefixed with `google_ads_field.` (e.g., `google_ads_field.name`). +7. **METADATA DISCOVERY & SERVICE CONSTRAINTS (CRITICAL):** + - **Preferred Service:** You MUST use `GoogleAdsFieldService.search_google_ads_fields` for all resource schema and metadata discovery. + - **Query Syntax for Field Service:** When calling `search_google_ads_fields`, the GAQL query **MUST NOT** include a `FROM` clause, and fields **MUST NOT** be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). + - **GoogleAdsService Fallback:** If you MUST use `GoogleAdsService.search` for the `google_ads_field` resource, the query **MUST** include a `FROM google_ads_field` clause and all fields **MUST** be prefixed with `google_ads_field.` (e.g., `google_ads_field.name`). Note that this service may have limited support for metadata fields compared to the specialized field service. + - **FLAT SYNTAX:** For both services, metadata queries MUST NOT use parentheses `()` or complex boolean logic. 8. **Inter-Field Mutual Compatibility (CRITICAL):** For every field in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. Verify this via `GoogleAdsFieldService` for EVERY query. @@ -139,14 +140,31 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 15. **Change Event Resource Selection (CRITICAL):** You **MUST NOT** select sub-fields of `change_event.new_resource` or `change_event.old_resource`. Select the top-level fields and perform extraction in code. +16. **Repeated Field Selection Constraint (CRITICAL):** You MUST NOT attempt to select sub-fields of a repeated message (where `is_repeated` is `true`). For example, if `ad_group.labels` is repeated, you cannot select `ad_group.labels.name`. You must select the top-level repeated field and process the collection in code. + +17. **`ORDER BY` Visibility and Sortability Rule (CRITICAL):** Any field used in the `ORDER BY` clause MUST be present in the `SELECT` clause, unless the field belongs directly to the primary resource specified in the `FROM` clause. Additionally, you MUST verify that any field in the `ORDER BY` clause has `sortable = true` in the `GoogleAdsFieldService` metadata. + +18. **Mandatory Metadata Attribute Discovery (CRITICAL):** Before constructing ANY query, you MUST verify the following metadata attributes via `GoogleAdsFieldService`: + - Fields in the `SELECT` clause MUST have `selectable = true`. + - Fields in the `WHERE` clause MUST have `filterable = true`. + - Fields in the `ORDER BY` clause MUST have `sortable = true`. + +19. **Explicit Date Range for Metric Queries (CRITICAL):** When selecting `metrics` fields for a resource that supports date segmentation, you SHOULD always include a finite date range filter in the `WHERE` clause. Relying on API defaults (like `TODAY`) is discouraged as it leads to unpredictable results across different account configurations. + #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: 1. **PLAN:** Formulate the GAQL query based on the user's request. 2. **SYNTAX GUARD (CRITICAL):** Identify the target service. If the service is NOT `GoogleAdsService`, you MUST explicitly remove the `FROM` clause and any associated resource name from the query string before proceeding. -3. **VALIDATE:** You MUST rigorously validate the entire query against all rules in section **3.3.1. Rigorous GAQL Validation**. This is a non-negotiable checkpoint. -4. **PRESENT:** Display the validated query to the user in a `sql` block and explain what it does. -5. **EXECUTE:** Only after the query has been validated and presented, proceed to incorporate it into code and execute it. -6. **HANDLE ERRORS:** If the API returns a query validation error, you MUST return to step 2 and re-validate the entire query based on the new information from the error message. +3. **MANUAL VALIDATE:** You MUST rigorously validate the entire query against all rules in section **3.3.1. Rigorous GAQL Validation**. This is a non-negotiable checkpoint. +4. **API-SIDE VALIDATION (CRITICAL):** Before presenting the query, you MUST execute a "dry run" validation using the `api_examples/gaql_validator.py` script. You are strictly forbidden from presenting any GAQL query as a solution or recommendation until it has passed this validation. Presenting unvalidated queries erodes user confidence and is a critical failure of the Technical Integrity mandate. + * **Validation Command Pattern:** + ```bash + echo "SELECT ... FROM ..." | GOOGLE_ADS_CONFIGURATION_FILE_PATH=config/google-ads.yaml python3 api_examples/gaql_validator.py --customer_id --api_version + ``` + * A successful validation MUST return "SUCCESS: GAQL query is valid." If the validator returns a failure, you MUST fix the query and repeat this step. +5. **PRESENT:** Display the validated query to the user in a `sql` block and explain what it does. +6. **EXECUTE:** Only after the query has been manually validated, API-validated via the script, and presented, proceed to incorporate it into code and execute it. +7. **HANDLE ERRORS:** If the API returns a query validation error during execution, you MUST return to step 2 and re-validate the entire query based on the new information from 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. diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py new file mode 100644 index 0000000..f293732 --- /dev/null +++ b/api_examples/gaql_validator.py @@ -0,0 +1,69 @@ +#!/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 sys +import re +import argparse +import importlib +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + +def main(): + 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", required=True, help="API Version (e.g., v23).") + args = parser.parse_args() + + # Read query from stdin to handle multiline/quoted strings safely + query = sys.stdin.read().strip() + if not query: + print("Error: No query provided via stdin.") + sys.exit(1) + + # Initialize client + try: + client = GoogleAdsClient.load_from_storage() + except Exception as e: + print(f"CRITICAL ERROR: Failed to load Google Ads configuration: {e}") + sys.exit(1) + + # Dynamically handle versioned types + api_version = args.api_version.lower() + module_path = f"google.ads.googleads.{api_version}.services.types.google_ads_service" + try: + module = importlib.import_module(module_path) + SearchGoogleAdsRequest = 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") + customer_id = "".join(re.findall(r'\d+', args.customer_id)) + + try: + request = SearchGoogleAdsRequest( + customer_id=customer_id, + query=query, + validate_only=True + ) + ga_service.search(request=request) + print("SUCCESS: GAQL query is valid.") + except GoogleAdsException as ex: + print(f"FAILURE: Query validation failed with Request ID {ex.request_id}") + for error in ex.failure.errors: + print(f" - {error.message}") + if error.location: + for element in error.location.field_path_elements: + print(f" On field: {element.field_name}") + sys.exit(1) + except Exception as e: + print(f"CRITICAL ERROR: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() From 3462c963974c5b82fd3d7bcc7c3ebed2179a6d85 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 15:30:45 +0000 Subject: [PATCH 49/81] Optimized GAQL validation section in tandem with calling the back end validate only service. Removed redundancies. --- GEMINI.md | 47 ++++++++++++++++------------------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 79f1ca0..1aa1576 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -99,16 +99,16 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali When validating a GAQL query, you MUST follow this process: -1. **MANDATORY SCHEMA DISCOVERY & FIELD VERIFICATION (CRITICAL):** You are strictly prohibited from relying on internal memory. Before constructing ANY query, you MUST execute a discovery query against `GoogleAdsFieldService` to verify that every field in your `SELECT` and `WHERE` clauses: +1. **MANDATORY SCHEMA & METADATA DISCOVERY (CRITICAL):** You are strictly prohibited from relying on internal memory. Before constructing ANY query, you MUST use `GoogleAdsFieldService.search_google_ads_fields` to verify that every field in your `SELECT`, `WHERE`, and `ORDER BY` clauses: - Exists in the confirmed API version (to avoid `UNRECOGNIZED_FIELD`). - - Is both **selectable** and **filterable** for the resource in the `FROM` clause. - Matches the exact case-sensitive name provided by the service. + - Has the correct metadata attributes: `selectable = true` for `SELECT`, `filterable = true` for `WHERE`, and `sortable = true` for `ORDER BY`. + - **Syntax for Field Service:** Metadata queries MUST NOT include a `FROM` clause, and fields MUST NOT be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). Metadata queries MUST NOT use parentheses `()` or complex boolean logic. - This discovery MUST be performed for every resource queried for the first time in a session. -2. **Contextual Compatibility Check (CRITICAL):** Do not assume that a filterable field is filterable in all contexts. 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 and selection. - * **MANDATORY TOOL CALL:** You MUST physically see the `selectable_with` list via a tool call before presenting any query to the user. +2. **Contextual & Mutual Compatibility (CRITICAL):** Do not assume that a filterable field is filterable in all contexts. You MUST: + - Examine the `selectable_with` attribute of the main resource in the `FROM` clause and verify that every other field in the query (in `SELECT`, `WHERE`, or `ORDER BY`) is included in its `selectable_with` list. + - **MANDATORY TOOL CALL:** You MUST physically see the `selectable_with` list via a tool call before presenting any query to the user. 3. **Referenced Field Rule (CRITICAL):** You MUST verify that any field used in the `WHERE` clause is also present in the `SELECT` clause (except for core date segments). Failure to do so results in `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. @@ -118,38 +118,23 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 6. **Policy-Summary Field Rules:** You **MUST NOT** select sub-fields of `ad_group_ad.policy_summary` (e.g., `approval_status`). The **ONLY** valid way to retrieve policy info is to select `ad_group_ad.policy_summary.policy_topic_entries` and iterate through the results in code. -7. **METADATA DISCOVERY & SERVICE CONSTRAINTS (CRITICAL):** - - **Preferred Service:** You MUST use `GoogleAdsFieldService.search_google_ads_fields` for all resource schema and metadata discovery. - - **Query Syntax for Field Service:** When calling `search_google_ads_fields`, the GAQL query **MUST NOT** include a `FROM` clause, and fields **MUST NOT** be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). - - **GoogleAdsService Fallback:** If you MUST use `GoogleAdsService.search` for the `google_ads_field` resource, the query **MUST** include a `FROM google_ads_field` clause and all fields **MUST** be prefixed with `google_ads_field.` (e.g., `google_ads_field.name`). Note that this service may have limited support for metadata fields compared to the specialized field service. - - **FLAT SYNTAX:** For both services, metadata queries MUST NOT use parentheses `()` or complex boolean logic. +7. **PROHIBITED 'OR' OPERATOR (CRITICAL):** GAQL does NOT support the `OR` operator in the `WHERE` clause for any service. You MUST use the `IN` operator (if for the same field) or execute multiple separate queries and combine results in code. Failure results in `unexpected input OR`. -8. **Inter-Field Mutual Compatibility (CRITICAL):** For every field in the `SELECT` and `WHERE` clauses, you MUST verify that every other field in the query is included in its `selectable_with` list. Verify this via `GoogleAdsFieldService` for EVERY query. +8. **Enum Value Verification (CRITICAL):** If you receive `BAD_ENUM_CONSTANT`, you MUST query the field's `enum_values` attribute in `GoogleAdsFieldService` to retrieve the valid string constants for the confirmed version. -9. **PROHIBITED 'OR' OPERATOR (CRITICAL):** GAQL does NOT support the `OR` operator in the `WHERE` clause for any service. You MUST use the `IN` operator (if for the same field) or execute multiple separate queries and combine results in code. Failure results in `unexpected input OR`. +9. **Change Status Constraints (CRITICAL):** Queries for the `change_status` resource MUST: + - Include a finite date range filter on `change_status.last_change_date_time` using `BETWEEN` with both start and end boundaries. + - Specify a `LIMIT` clause (maximum 10,000). -10. **Unrecognized Field Pitfall (CRITICAL):** If you receive `UNRECOGNIZED_FIELD`, do not guess alternative names. Immediately query `GoogleAdsFieldService` to find the valid field names for the confirmed version. +10. **Single Day Filter for Click View (CRITICAL):** Queries for the `click_view` resource MUST include a filter limiting results to a single day (`WHERE segments.date = 'YYYY-MM-DD'`). -11. **Enum Value Verification (CRITICAL):** If you receive `BAD_ENUM_CONSTANT`, you MUST query the field's `enum_values` attribute in `GoogleAdsFieldService` to retrieve the valid string constants for the confirmed version. +11. **Change Event Resource Selection (CRITICAL):** You **MUST NOT** select sub-fields of `change_event.new_resource` or `change_event.old_resource`. Select the top-level fields and perform extraction in code. -12. **Finite Date Range for Change Status (CRITICAL):** Queries for the `change_status` resource MUST include a finite date range filter on `change_status.last_change_date_time` using `BETWEEN` with both start and end boundaries. +12. **Repeated Field Selection Constraint (CRITICAL):** You MUST NOT attempt to select sub-fields of a repeated message (where `is_repeated` is `true`). For example, if `ad_group.labels` is repeated, you cannot select `ad_group.labels.name`. You must select the top-level repeated field and process the collection in code. -13. **Limit Requirement for Change Status (CRITICAL):** Queries for `change_status` MUST specify a `LIMIT` clause (maximum 10,000). +13. **Explicit Date Range for Metric Queries (CRITICAL):** When selecting `metrics` fields for a resource that supports date segmentation, you SHOULD always include a finite date range filter in the `WHERE` clause. Relying on API defaults (like `TODAY`) is discouraged. -14. **Single Day Filter for Click View (CRITICAL):** Queries for the `click_view` resource MUST include a filter limiting results to a single day (`WHERE segments.date = 'YYYY-MM-DD'`). - -15. **Change Event Resource Selection (CRITICAL):** You **MUST NOT** select sub-fields of `change_event.new_resource` or `change_event.old_resource`. Select the top-level fields and perform extraction in code. - -16. **Repeated Field Selection Constraint (CRITICAL):** You MUST NOT attempt to select sub-fields of a repeated message (where `is_repeated` is `true`). For example, if `ad_group.labels` is repeated, you cannot select `ad_group.labels.name`. You must select the top-level repeated field and process the collection in code. - -17. **`ORDER BY` Visibility and Sortability Rule (CRITICAL):** Any field used in the `ORDER BY` clause MUST be present in the `SELECT` clause, unless the field belongs directly to the primary resource specified in the `FROM` clause. Additionally, you MUST verify that any field in the `ORDER BY` clause has `sortable = true` in the `GoogleAdsFieldService` metadata. - -18. **Mandatory Metadata Attribute Discovery (CRITICAL):** Before constructing ANY query, you MUST verify the following metadata attributes via `GoogleAdsFieldService`: - - Fields in the `SELECT` clause MUST have `selectable = true`. - - Fields in the `WHERE` clause MUST have `filterable = true`. - - Fields in the `ORDER BY` clause MUST have `sortable = true`. - -19. **Explicit Date Range for Metric Queries (CRITICAL):** When selecting `metrics` fields for a resource that supports date segmentation, you SHOULD always include a finite date range filter in the `WHERE` clause. Relying on API defaults (like `TODAY`) is discouraged as it leads to unpredictable results across different account configurations. +14. **`ORDER BY` Visibility Rule (CRITICAL):** Any field used in the `ORDER BY` clause MUST be present in the `SELECT` clause, unless the field belongs directly to the primary resource specified in the `FROM` clause. Verification of `sortable = true` is mandatory per Rule 1. #### 3.3.2. MANDATORY GAQL Query Workflow Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: From 597c6c2c29a3b7011215c5e901c1048c836a2387 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 16:20:34 +0000 Subject: [PATCH 50/81] Logic to deal with configuration if no google-ads.yaml in the home directory, but there is a configuration file for PHP, Java, or Ruby. --- .gemini/hooks/custom_config.py | 150 ++++++++++++++++++++++++++------- GEMINI.md | 4 + 2 files changed, 122 insertions(+), 32 deletions(-) diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index 3d27a83..0848a9e 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -3,6 +3,7 @@ import subprocess import json import sys +import re def get_version(ext_version_script): """Retrieves the extension version.""" @@ -16,25 +17,100 @@ def get_version(ext_version_script): return result.stdout.strip() except Exception as e: print(f"Error getting extension version: {e}", file=sys.stderr) - return None + 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*['\"](.*?)['\"]", + } + 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*$", + } + 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", + } + 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: + 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") + 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): - if is_python: - print(f"Error: {home_config} does not exist. Please create it in your home directory.", file=sys.stderr) - sys.exit(1) - else: - print(f"Warning: {home_config} does not exist for {lang_name}. Skipping.", file=sys.stderr) - return False + return False try: shutil.copy2(home_config, target_config) with open(target_config, "a", encoding="utf-8") as f: - # Python/YAML uses :, INI/Ruby often can use = in these formats 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) @@ -43,48 +119,58 @@ def configure_language(lang_name, home_config, target_config, version, is_python def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.abspath(os.path.join(script_dir, "../..")) - settings_path = os.path.join(project_root, ".gemini/settings.json") 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) - if not version: - sys.exit(1) - # 1. Configure Python (Always) - python_home = os.path.join(os.path.expanduser("~"), "google-ads.yaml") + 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") - configure_language("Python", python_home, python_target, version, is_python=True) - # 2. Configure other languages (Conditional) + # 1. Try Python YAML first + if configure_language("Python", python_home, python_target, version, is_python=True): + print("Configured Python using ~/google-ads.yaml") + 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}") + found_fallback = True + break + + if not found_fallback: + print("Warning: No Google Ads configuration found in home directory.", file=sys.stderr) + + # 3. Configure other languages if requested by workspace context + 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 as e: - print(f"Error reading settings.json: {e}", file=sys.stderr) + except: include_dirs = [] languages = [ - { - "id": "google-ads-php", - "name": "PHP", - "filename": "google_ads_php.ini", - "home": os.path.join(os.path.expanduser("~"), "google_ads_php.ini") - }, - { - "id": "google-ads-ruby", - "name": "Ruby", - "filename": "google_ads_config.rb", - "home": os.path.join(os.path.expanduser("~"), "google_ads_config.rb") - } + {"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")}, ] for lang in languages: - enabled = any(lang["id"] in d for d in include_dirs) - if enabled: + 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) diff --git a/GEMINI.md b/GEMINI.md index 1aa1576..a518f6b 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -167,6 +167,10 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit - Pass `customer_id` as a command-line argument. - Use type hints, annotations, or other static typing features if the language supports them. +#### 3.4.4. Internal Utility Preference (CRITICAL) +- **Python for Utilities:** Even if the user's target language or project default is not Python (e.g., Ruby, PHP, Java), you MUST continue to use Python for internal utility tasks, including GAQL validation via `api_examples/gaql_validator.py` and schema discovery via one-liners. +- **Language Respected in Output:** All user-facing code generation, examples, and explanations MUST strictly adhere to the user's preferred or project-inferred language, regardless of the internal use of Python for validation. + #### 3.4.1. Configuration Loading - **Code Generation (to `saved/code/`):** When generating code that uses the Google Ads API client libraries and saves it to the `saved/code/` directory, any calls to load configuration (e.g., `GoogleAdsClient.load_from_storage()` in Python) MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for the configuration file in their home directory (or other default locations as per the client library's behavior). - **CRITICAL Execution within Gemini CLI:** When executing code within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to point to the correct configuration file in the `config/` directory for the preferred language: From 64ab4d6560627976d6e13b2a0f722f19c8ba4256 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 12:18:21 -0500 Subject: [PATCH 51/81] Modifed init hook to deal with absence of google-ads.yaml --- .gemini/hooks/custom_config.py | 10 +- ...d_campaign_with_date_times.cpython-314.pyc | Bin 0 -> 5723 bytes .../ai_max_reports.cpython-314.pyc | Bin 0 -> 8284 bytes .../capture_gclids.cpython-314.pyc | Bin 0 -> 4457 bytes ...sions_troubleshooting_data.cpython-314.pyc | Bin 0 -> 10006 bytes .../conversion_reports.cpython-314.pyc | Bin 0 -> 21719 bytes ...create_campaign_experiment.cpython-314.pyc | Bin 0 -> 9272 bytes .../disapproved_ads_reports.cpython-314.pyc | Bin 0 -> 11641 bytes .../gaql_validator.cpython-314.pyc | Bin 0 -> 4058 bytes ...t_campaign_bid_simulations.cpython-314.pyc | Bin 0 -> 4538 bytes .../get_campaign_shared_sets.cpython-314.pyc | Bin 0 -> 3764 bytes .../get_change_history.cpython-314.pyc | Bin 0 -> 5773 bytes ..._conversion_upload_summary.cpython-314.pyc | Bin 0 -> 9512 bytes .../get_geo_targets.cpython-314.pyc | Bin 0 -> 5201 bytes .../list_accessible_users.cpython-314.pyc | Bin 0 -> 2563 bytes .../list_pmax_campaigns.cpython-314.pyc | Bin 0 -> 4102 bytes ...eport_downloader_optimized.cpython-314.pyc | Bin 0 -> 8726 bytes ...tomatically_created_assets.cpython-314.pyc | Bin 0 -> 5374 bytes ...et_campaign_with_user_list.cpython-314.pyc | Bin 0 -> 4943 bytes api_examples/gaql_validator.py | 38 ++--- ...i_max_reports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 15018 bytes ...apture_gclids.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8687 bytes ...shooting_data.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 9082 bytes ...rsion_reports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 17636 bytes ...gn_experiment.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8427 bytes ...d_ads_reports.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 17314 bytes ...aql_validator.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 6594 bytes ...d_simulations.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7892 bytes ...n_shared_sets.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 6985 bytes ...hange_history.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7101 bytes ...pload_summary.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 5877 bytes ...t_geo_targets.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8929 bytes ...essible_users.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 5383 bytes ...max_campaigns.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 6740 bytes ...der_optimized.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 8900 bytes ...reated_assets.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 10053 bytes ...ith_user_list.cpython-314-pytest-8.4.2.pyc | Bin 0 -> 7780 bytes api_examples/tests/test_gaql_validator.py | 133 ++++++++++++++++++ 38 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 api_examples/__pycache__/add_campaign_with_date_times.cpython-314.pyc create mode 100644 api_examples/__pycache__/ai_max_reports.cpython-314.pyc create mode 100644 api_examples/__pycache__/capture_gclids.cpython-314.pyc create mode 100644 api_examples/__pycache__/collect_conversions_troubleshooting_data.cpython-314.pyc create mode 100644 api_examples/__pycache__/conversion_reports.cpython-314.pyc create mode 100644 api_examples/__pycache__/create_campaign_experiment.cpython-314.pyc create mode 100644 api_examples/__pycache__/disapproved_ads_reports.cpython-314.pyc create mode 100644 api_examples/__pycache__/gaql_validator.cpython-314.pyc create mode 100644 api_examples/__pycache__/get_campaign_bid_simulations.cpython-314.pyc create mode 100644 api_examples/__pycache__/get_campaign_shared_sets.cpython-314.pyc create mode 100644 api_examples/__pycache__/get_change_history.cpython-314.pyc create mode 100644 api_examples/__pycache__/get_conversion_upload_summary.cpython-314.pyc create mode 100644 api_examples/__pycache__/get_geo_targets.cpython-314.pyc create mode 100644 api_examples/__pycache__/list_accessible_users.cpython-314.pyc create mode 100644 api_examples/__pycache__/list_pmax_campaigns.cpython-314.pyc create mode 100644 api_examples/__pycache__/parallel_report_downloader_optimized.cpython-314.pyc create mode 100644 api_examples/__pycache__/remove_automatically_created_assets.cpython-314.pyc create mode 100644 api_examples/__pycache__/target_campaign_with_user_list.cpython-314.pyc 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_collect_conversions_troubleshooting_data.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_gaql_validator.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_conversion_upload_summary.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 create mode 100644 api_examples/tests/test_gaql_validator.py diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index 0848a9e..530410a 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -110,7 +110,11 @@ def configure_language(lang_name, home_config, target_config, version, is_python 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") + f.write(f"\ngaada{sep} \"{version}\"\n") + + if is_python: + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_config}\"", file=sys.stdout) + return True except Exception as e: print(f"Error configuring {lang_name}: {e}", file=sys.stderr) @@ -148,11 +152,13 @@ def main(): 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("Warning: No Google Ads configuration found in home directory.", file=sys.stderr) + 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 settings_path = os.path.join(project_root, ".gemini/settings.json") diff --git a/api_examples/__pycache__/add_campaign_with_date_times.cpython-314.pyc b/api_examples/__pycache__/add_campaign_with_date_times.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ed1a256ec5e446911ad20a124b0e50d931cc7bf GIT binary patch literal 5723 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 R`wl%J4}UX^$bH%_{s-flYNP-F literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/ai_max_reports.cpython-314.pyc b/api_examples/__pycache__/ai_max_reports.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e896d1abc53401527935ab9eb9e0cca460fadc95 GIT binary patch literal 8284 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 Qo_XMT;o%7)bTDcBFXeosYXATM literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/capture_gclids.cpython-314.pyc b/api_examples/__pycache__/capture_gclids.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0c2190977b5baf0d1a1ac657f1cc0b192ea66d5 GIT binary patch literal 4457 zcma)9O>o=R5q=;+5d0GWS8mDVwAs+p)sLt`+%Dw&OCeB4;vUFenL;7?S`C zfR4@SBs%nv3HN;UXlunt{rMrN- zR@|3pot_{%_loBdqcanTqhpBk#84%od-E*EEWUzJ@plM~p*)=%uorOXB;vdrdy29o zx<60lh8(TUft8Q*_pE~XU~bgbIg|@+ zRi-#<9W>U%xz=4h-o1yX_wXK$$#@QYA=pMU|I|hNlG5q7f}9 z@-0!-ASk{l=pwI6RncHn5w`8r0IgJXF$$(v#e#hY39xN zv~=x`5#)J6mKB|Z$E$s?I9v^+k^8)f{)1+znaAYyFgV(A6FQf&=rhk+^rcmkKJxe; zdqDpL=AlBjN(qk$`arhnCm421P9SHWjj6U?T<_l0Xs{qBw(sG3x3T8AF)jDb=?DDv z>Ziu6F*)x(XE0o&INCp?MMJ^1BVet>%G_kai%U{yYLipT;5T#{~yvIP@qzkf+BX<0vp$Ew6EUXn4?hb{>fsUa3|4J_lDB3N1OP}YVuAu# z*}!D7v$?e2h%NUfTS>Sss<)(qXoMm3!c9lJ*K(;qK(A|=q*QX(8<}g$9@khfZ8XSV zT`s0^t|AH=tgVV9Bt;g}rR4h7r(cy%oi*GqUq5GzTg&fTRpZ2;*F{ZV1+?<27Jn@n zH3B73=e2IAEE(u`0E0;DWDIMfpccx!vroPoj6uudZQY8#Kg+TUZcxtKy5bM_NXa+6bmUN^4fIpSguMycJza>=a;HpqpRZ%Y7fe?zKnH|9D zUY<3B5o$G2E*jyQpprYoZwOKaK1If%x_J?KxDbV#dp4jY11bZHnZC?jT5AtGUit1+ zJ1LR<*o5A4!?R>Gt%T$s{@q3J-26Z!69C>HB?WHZQhErg!LAn+~+-*ft$&(eZ6M z-kiwn(DUthV)Nn-J=&hkwkEG{PhM|FVh~AUl|+(Ynsj`ZK^}k8KlKGY{q^o)6dc|~ zE{Kf{HT!d&G0avXgYKXxwU{!5U8x54r}NgvM} z?pq7T$!+g`#TzNBFn%OB!OTEW)C#I(HYN>Ms?Z3fQZ}?er?A=>C75GD6o$s;JGhf{ z8#E~;gC*4_sbbNf%VMPlj!F%0DrG`a*F&#q`VJ(4rQR5VC3_d%X+#>oCQJVxRKiEJ zvQn=U@y`IeHlQLc)FLHe;#e|mf~M8GU})%O@4%C)R#U|yE|;^_YF5*Z{vu_vv&S$% zO&ZTG%r88hnkRqw>4og^h3tuwsrkk1{QPXX(ceWG{JNx<0ex|zF_gfhC;`h&8Njz* z*FZj%F!5B93za(L*$iz%sZ^BLCAow}RaI0i+ZgnnC!966Dr%Zg5@E6{k!}QizMsl? zLlP^+EKVj2FV7d10-QT5s3pJys6q0>#u&9)urz#@(iTfNC~D~ZtS0XJEvyh!($1K* zNNyGZI9Q>deB}5IRjKmOH>wbm5$U-mm_wS%KKQuRtH9;VSPaIB$p{L?A`c;&I+Xx` z;Wt%82pCD;`{BtY-d5wLWf!{5M%9eapb0htAQSBM+W5y<(Hq`w&J32cqb49fWu}a; z33{0qD5KgcgIap)Z;j! zPJW5Pe@Fg*qEr7s*{>MrE<39fqJwWWcHF~&9EWCGvBl40i+>v2iCqLdc@!FKg{D3a zO|_%TyKYw>vw5MzB2O1e+HA1R#@eAsyMM49n(G9R=g=-u6|GS0jwJwYX>8(;MlgwY+Zi)vN@jK4o?5u=kfU; zpND;oeBFW8I2hkWeljc6?wk0)_509=q4(eHASx4SM+WY(ciDTPyP@~Ot;p1NWU3jS zZceX2!a`T5UANokhy4y6y7%hcS6hQKJA*TCoqrJQZ${@nI{C@zk57M;`Qt!y;Q5{4 z>2^4}^~Tm4@4V5T%Y2glINh4Nx-)mR8J&3$9e)2>^XTc;(M#J$FE!^@nj@FLh+cW( zMn_+y9(&QyEP%Q%Xu;@K;GMvKJA)AIw;G1i`*b|H>_?yZy%!Sh&-$01y)f?nYj}wT K^0#ph$o>a=s7Dn5 literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/collect_conversions_troubleshooting_data.cpython-314.pyc b/api_examples/__pycache__/collect_conversions_troubleshooting_data.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e2c82bf68b1b003c53594aeb747ba8533642c02 GIT binary patch literal 10006 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 literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/conversion_reports.cpython-314.pyc b/api_examples/__pycache__/conversion_reports.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..46df173d7e5405d319517ce4bf6a2cbeabc16934 GIT binary patch literal 21719 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 ZX@2C`>(*=5-&BE69!(l3V>RiY{|65;b>08~ literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/create_campaign_experiment.cpython-314.pyc b/api_examples/__pycache__/create_campaign_experiment.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6345b9e66f46fe0e10c83349b136b198ee054fbd GIT binary patch literal 9272 zcmcgxTWlLwdOjqFjez7eJr5FLb2Gs^gm}f zGn7QfT@(lbo;h>Qf6n>mT)zMN|1;`#*%AB(XMUC$IfBr?;}`Wu*E3I#nh+`@7K!L4 zVhtA!qJcb(qLDmJq6wbHi`0TyG>5Q^>7r%9Dq2aNx=1hBL|X{4<^U4ytOZgBYlYOw z(vZ5=+^p^74TREfBh-!7%&}fo0sYyBSUc-DVNgm$@0uajuh*(MkaDsvP0F|Ciw&!F z`jd6Do;5$~Wm{O^3A0ijLeG$GW&K39?SSm4-k+MAtY^gbF0^LvLu=l(_EiF`vszI43p^`gaIF)*u6->SJFdtzrcT5Lpyp&_nUas4K3t~i z8Jj*_2R(I6m?!J=46_o9y?j_IcJ6SQ7tJRS61$0>!L{z#`NJ~Md*_6yq4)0Cg~L+n zl5N0@bfd*_grK)3amvqdg33zaT*i<=r%gR5V+ePb9xZQX1ctkv+$v-_CdG3}krS9ClY)X| zW+Mkx1&+^baXFD$=kr@omFAP{q9zcr+m|=F1BDFqsq~drTfLDKp#OTW5$1ee++;*( z*wBA6m$n});UezqkWOC37dN1(#6ekD~`<1Xk_PmnMZ)CZdv@n~^fTmF7(~9P9 zr?`Tc$>*Td3(ds*8^!g+CYOcp5>oY@OlHvnDZEopKZi^it=4xILMw(zqcYD0)2h~D z#jr9kN!81#)%xkg(s4>ZA#oJ5Dl&~JtF2JMylPWxSYzA-`_9z3@8af|L#@}JaT80a z*bzbvJv5vL)-r&?)}#$S1Fz6l66S7;6F2i|n8GBipt{)EER*B#Y$o|T3`}1}%)lO4 z&-3*KB6|bYd4}H*rtA#-Qe>K^m}CwX1jLisOo>Y~+MFxP0Cj?x%%!-9(x@ni`7Mr5 zWYW;G-UpMamogVGC}Mb{B-D*UWbvL*3qXt!^Gs3@@~I5$Rp!l1eIupvIg!t2VYh5C z`E@cPb6|%>>~hPTwx6$Y{H;uiD}}G6Ho0^$o5^i7?ncrv&k6Y=pW>KYa*Jb1rbr|L z+sLIBCvb78U+3T7ny%V_-w#c2zgeUYJpHTae+Yi^lcL;KDE1%A+ z-${sgt?(h#W=3{waAHDGzCkt@_)JceTh-wk27!?+%6iETb+TbvW$(d>mhHM`ayL2U z+Gv`lcVr50aJ~(+z&2UP zLQ+P1KI9vFFu&b$u6*h7z^NMTmU_mYnv8=MiDLGg$lUwhx^(R74i(#_Q72Okj#h%B z(%9MU;5mtEe@xjV=Sv^|`Nyk|S|$734mDq++N;z^g&O&!Q=&$8s0&!c9{GxjK85j6 zstyx4k1!w*IQcc4z%l|L&;W(hlcWI%jB#VEv3*^rU}BA{b(ldYhNt?Rrmld1kz~mi-1dP9U`O&$B92 z0#Lr$2omZ6{$9}0&T?Xr&($#)qoO*z0#7Pp@7EDPuT=-At8l)O*cz7lXH^p)2SW%Y z%rYm4j4sLyl$?L2AM`aIZ@q^`-UAQWjt?s7NGJ^{)01wTm?r2M_Q^EHEb&f(14trM zDpSQGV38T8lQM7fO{qg$uK-VD?C14B*Ao8_%}$(XxERr z`#(7Q{#j}C+;-QDM0M0!j#OL5DlKE>OHVqc{%P{lsb5e1X8Mze)PAvi>FukvK+pXj z-TP5BaJ&*YUJYER1TK^>?NI(&#}IzD)jE1VaK7)XcATnooT_%5t#q6v1#cgMhXqK>D1zW@KMxblAOX?`f2>A|3vgy*jib~> z3syiZYfft$x5hLq(|c=5t;{i(E}`aDd@2Q0ktu{$PUJLd$5JqwHp0ybc+=66YC4WZ z@9DW;Nyq7?Uuk2_ahv{?E2kPXvOrCvMz-f_MDNq6nYFU25(WD+bk_PPI99rHn)O*( z%xN?hT7u(q@E2=9YPBxbt>*M++>yfwM1FC5tWVAB&$xrNspPoej60jT-}@~?!SxI& zL!CvfzSLZ7M9t~X1NwU!MsmhI`?ZLB zz!7jI{|XjVH)I-J&Vf6ivdcyU$NckqF>BbNILJk(2y<7N-v5*I;{kL`R=QS!S+|_v z%jiPz;eUd$eb)R93*Z6)uNAN(t$qyHV~=Wj>n@1gKBsvlGyIljqnAse*}7K)#mv#G zd}bq)OJ*6heR#gqwX7{3k+}i{rEbu}jTR9q)zP|^i1phyroeJPEQ0+9?*eIk<25beLPFM6POnc5dXt0^YjPK-2{5#og`~Kt7~~LcH6}_ z`O9+&cJA8M>+I~D+@@Og1Xct4V1&W6AL0-RP+#D~cnnM*2$7K8`liD5a%(1+Pigv->3R|4vl^!UIemqI|+}5vI(VZLT=wbvg)Yu6L%?AO+?p_KQ5xPUbS4-dO|+E3vz*wpbjxsK(22|xoprW5)6z*qu3)j+rs z2umZUw*%7><=?fS*5Li>bVrpQs?b9p{>3&uw`Ve%Ezm_rKc+Y>6Ci%6*4bSK?<`QG+*PWrLbd($2Rl^% zuKl~l36gOFeWaJjvwL~cGf?e`R(hh+3-jAOuSlSe+kZFzlf2Y>dfPo+ex=rV1P>h? z*rD2=(C)j=pE#@ZScM*|(q}6486Z(+=ML2eDtm{jp6QBb`q7Hyncns+5mwdNUk!#U z!SI8=FN0&?bJn^Bs$HX%uF(fGUv@=+{rv}aLXSz4^VP}a%H*9ZC3>?6aMG-h+=zC8;v+a+g2iY`i#I$x_f zh9zoP;cFUSf_22U8{Vm>n1THt{8!U67=g~!urJq)tC?4h=p2SMt!k{LnfH3Y+coDj zj~FD=x ze>S^%P-6N(qBWc9i+x|?bO_GJyQ?1 z!^K2%xn>s`pbOZO0^OjCdzo%_xv`%+Ekf*pgxoij=PE`|@{6exq!z&QQ0U!Uu$;Bv zm19Y+A@uGk{nrwX?cq=>cI7uRw>TV4JxCXrqqtTBWgOlNe{ZzxyMln(6kh|UPyz_o<4ZhP-;~;$sGvPGO5jE zZi5pdrM~%OCJVw4D^^0N2b&F-T8C9KL+B1BnZqUbFa)(BcuRr>@NOKAl-g`_JP*-u zLVdPyoP2|0hX3fa*{SDKlX92aIIrNdf@`tRoloOGTQ85Ty5@}`-n7jUOF->&Bi6?%72@n;tx>c?A=3lv;Q&e zs=0wnT$Z9syU1|Gz|@%jU%39(_1<4T$bG_odgE6&K3)6O+HVroGp|<8yedtz61%)> zGPvS~J(J012X)&!?!Wdoue~$(*x4faM;^TN(V2&59z;L!NxtbF=b4(vfA_7sZ@v3g zZ6x|p<_+oMH)R*9bKr5E=VI6rOr!V`4^v>(CCuk8!KuL?b(sd zd6#~d{%Oly$GeXI*=+-b|0po92EPsU&zjI@Cdd2{(`W9Plk<$}UmY_Zczn*7p-iR| zuswm=CS=pCiDTG{xTQoDOD7cPfPotyHQ=ZdQ{wog83;Z$R1?&}_bd(|$|*iWZtqIQ z@l?q*KHhMJu?)@+V54&D#K7&}zRRSrNu~%vfd@M~d*ShFJA4h?H9Du9Jcf&_RExy`NcE@P}YV9ZmX6gZ%_uGPzW? z2;WA=S27E}T1AmQhb!tpa0zUie5)Ke!icz=iNzeA4yL??carhbpczO@7lFMK=M zX&C%=>b${l^xI3usKLLRHXzFRPHD#!#LmiaHE{Baz{y8#I{|PZ{7+nMRoCDbuECmr z2`IbUQl78lP@n?71nsQRftt%(YiX;wMs^*@+_8r&06%VmRhmq~4);i5jxfE9?xoC&ZX-GN=X~R0( zw0p{n{2Q?$Eo0B!7uL zUSuL&`YTM2eMImHb)$?{q|{%mj~I-Ys06Ud6zy+RwbXcsz0ZO@_X6nLQAS4BB!VpUHb)SpVzMNcHWyB@~fC3IKD-Ey&R zAGz3au_rZC03u$pIDNkHUh&d&d%|K~grF1-<5;IByXIYo*A!b^0TCfTB5gW+E@t@dAb7*C3lta6el=du}Dj7izcvg(jTWm(FgOsujZNjM4#Uj##T%x9&gxFX{y zyVTssvMi0Hvx#_mWPwU(WHGxWj!2gmM;64TR3#h_8ln5EoR)fQ8%bF@NjvGJ zf2a7P)^?I&LeBg@os&|EDD%o9Y@gI6F$4QWTPQp(6kf^laen-4l*cs{cDu)<1^I}Z zhfrB$NBB8tjLQwFu_J>X^Ov!INQ7x4eK~4P^b8N{ZAUYW)Zl|ISgir&C_B$5<4U~J zci62mmsOItFDvuIhe8(BnGr9iQyEcZ#Y`fbOl1~k^YsGN0+bsC1__LX9I6dgXhyXr zOu)97VXQ+<7XJTKKD z4X2gIlxz%$q)sTK%Q+5-74kSp+`iS+TEnfc-ybcxj{e$p^pne*jV(9d`>XeEIoBHp zR^4T`zQpb=vU}G~Zm>Nx*H&cP)-G^}{8#tyq`YcFkQ)zV(L5w>-eU^Audf`jTDr-WKibtI^Il<+g9dJ@aB z0!l)PrIM;AyR77vl^6<2@{@F?-)#m3 zKF!_}pbM`RFIihwynP4WrMM%yWb=J}bw0<7y*(e*)KjY>;bFQc>foX~B>>~h>DZ~Tp;uD zbb1#|izlS4tW_ld&Bap-8OUXl+6RPwpq5Z=Kjmwrsh=bKScV5kqNL*KR2~3e6?%g@ z#3Bj!lHn)Jq^7hFjMht~oS#0S5t>xk3w`b28wj7T->r7vtyBna3(r&;+{q7g4L+nzqu?*SHH3xnC_f7l z&3h`@=@WVTSP~{W?}W!3$R=MmM$LzKnm{QQl+Zkd~Xclu8235cxUr ziV|v2Jz#^xWNngFP8Q=*ViCkAiSZ@XsjmsuV-kRBi6>Rx&Jn7tu>e#Xt^m~n-_f8> zvqP?`1B4-FlB%5+!vnaAsV-4xOIBS9FcwRrp<~CStkzY@Pqlp`o61mR5(-NDKzLMw z5~>u{y%0BsBMo4ILmvzan^?0Y>|c*7iAB||jSkBsG!~^1j9$VvoH3jj9gU}wg<)~# zVacl3$ig^e)n|+&rm^mVTDqjbN}x%7n5t8}q>+F&*U%U4D^t+GSToKJGG1kVNN+(i ziaGuq_T>uM3X=MkQlPsS=q~iUx*ix^d27q&<|dhyiLDmmYbkNPMXvY$V1esh=Vn$W zAMjl(Q=5V2ReQO$^M}Jf7+!s=+}cxWeW}>`(&|h(&~~%qM#t)#<@SM6`)IL!bal4e z)>mp9DYlK2+71@m4z8Z2^uxv0VWZW9whk!W+rE0L+}^o5`=EWW)P8)U{rGiP+23CB z_Z0m-x6a>*-HvVezgG6QmHgdBfA_8FJ7;d6DfJvG_8i*qA1?O{mwHAwdPc9GDtC{R zx(^q-50|=+ZFC=7bCz5BN-e|1mf^b#pSQgF$Vz&SGZ6P4dgvq_W6YM1aE;f_{Kc6E z!F}amXF1q(bK=GXKBjI=-8_BcbUD~n3LYp150rwV#o%Z;*m86F#`N0KdT_`{r`Lmn zMmn<|9Nr4J0`*U<9?ti;m+T#QXay-hKF$z(eVJ|eFN@3N2En@mU+&@TI*dc0hCd7l zg%FZG5+JH=Z9faIny{;5$YhWSPC&BwiDD^|)iwaaG;J4*V+yyzECsJp4F!T4>9JR~xE^3t7Ocs4uF*Hr4^W zTDw&Zlv@jznR*J;LVYH@S_942!muX1T8pz6!K+-<0?4uk7KYLn99Pu)!pPMA9AsL% zejAzAt~VjmToA0TTCxb5q;C~r?Y1FyJx|CsS{0m84)z2~cZRFBRG{Ad;*tSs&x=b3 zsJ%k%5mvA}&~Q(osYnTEd{3dNND6#Jl{7{1|58Emk914MYIuy2VEMI?3q}^aS%B*O zfa;wlRPX2}QKvz&07f^osMrBQ9m@28*((L~0{lXOco;PLwlQG(6jOgKdV}J;oD&nN z`BY*X3Y&dTe_FI}f+rf4D}rYe0+{eY^RaHPtWtS z*^D^EQ)d(Pyd}V4n?<*q)CW2$9t62n5b`dY!GtOQf7$?M^TCiKHE=3pdST4E3RW>5Ulwu6MrSqecko39%;TMS3|eBBUVDVjb31+F2US`Lu(&?H(9kZFL- zP74AG{X=}jWJN#;^kBv_RnT_8Orn7Ww45Iml#q2>*WY9?FY62zvDTM zG=vf2*(xM1fqR5V=vR}_mo0q_qgOE+#RwgN(qW8_VDt(`M=^R0BXp@~Rzjo6N*Kq| zZj4^X2yF!E4UCRqbR45GjCPs?(wkUz0;36xCNVmR(G*705UDI!5;1i3%IInWmn!&F ztAd8=@!Y2x(Z@hQF+)JoEObDD*CC6EgK*zw zI?5gV55>O`*UsIVxo`Oi`(ySe+>g1t--gO#1Lc8NO9PX|fyvUqOmSeQ(0>YwPcyyc z-q4-%x6j}Gv-RHB3q5ZjZBMyp;Lh~z>AUImo}-2C*P!h6zH;CGJE_~L`@Z$QV};)1 zTONDko6HldGf@Bdb;Ig-0xn>-VUukvdz-=V;Cz&IFg#rDCvC*nw8?S>|B+9QellF} zO?}QzgQ?BlyFBNEMOvqclYSn|G~p%*|mTIl{n zKA&s3dw40j&X$ zneEXW#4BVkiCP4P9M93Jfu~ zR)J`HLClB}@Xil(0rOov;GZrT&UVvcSEvREN3d?j-4r{rK8BdY(kej zTx!|zx_*tQzakKjPdI-F;GsS+JY6-6CNUZ2BY0vB9YX`{9AXDx%zLQ;qnj7`#&>uyS@F;yip8)|zEXoRhIR$6?Ae;VtQ$If+hgZR|43=r<0#M%5PtR&nnM8Uy zsb43U&!*Ga%gBQ$Nm)rgl5ccPU`lJdgo|%MgvOTsaNg^3oyowR0x=C!F`IW%!Xtbr zKwYz{I~q?fi?mR~p@`_<>dIAFwTV|!ii8>*UC@+aJa)Fzm#Q|n0U%Put2&YMWl2;W zv`SW;Itwa`*IV$ykJ>;vpm%3tVp?2+ogu4ktq^_J@ZH@9Z_-?Y5lnSsG>&h#h*zX{ zF;Ty}6V(FeUU+)|VJyY58g#Yc4wVv6LBGE5!oCG~%6Hm>Wlz&u?}n%Iiv|EoGuJ(v z-tJqIpX~kIJ5lCZ0AF!EP_x;@7xvAqH@#Evzk^xCTC>0U)~}Wd?=KXBi;t}q06h-E z4VV2*x8DEKY5~tHOSt+%Fm!kDZhN7Abi;dS(_43K>f@>FZ~x`Y?>9Z|kBQqJ1pHI> zb>6bv;cjzl2+KzNCWQF?I?exFsOv5CPH$NmZiabmwSxPWB~5U5;l_KV#=eclzSW6M zZvfxb`=jA|!*@GBu@vf$Zg^iS*9EVAbnT;$KPvAZ`BC^@xU~P&#{N@(3j<>frH<{az^h#VCXEp`OVu>o2?PkXG4P45u#^%ePO8a0CMDzS^0A=1UmvRC!=i zB%~De4CF1ti99zvY&w0M%kM>Xt(_8rRVnW{f`m(2Hr$e65$P&OwJnP29CRdMM<(ql z39yu;vk>Lq#~z=hNBcn+!FGoI2#Ja@DG%`#Wkc%>t+MTk357#8X&zq$UrM|MvzUb= zO<7vR*II_N$dl>BH2*!rBcgdza32}|lo~gMfNMi%!PzOgMc^)&!Gx}TD;$~>)=;Hc zTms#X`VXklPot~3kVRu!XER0TVEYS@A4hdpQSh~1@qBNPYQ1#e6_{`5CAf4)ZxGIg zEVHwrcGVe+C9{cGOl9NJLM|@B86n~*=^YfI1Lg@7rG(!1?YnsDUhTpixO3#Nmc*2% zy)=zin?n1{(z`g+V5d5i8o^Z6;KId80A_q}pKBSlT!eC@IyBByZ#e80KLIVP5_%srwCa!~Yj#ut)~~ogDcOtN)*EM}Oyli+#WA!Ye;dq855g z@`y(EKLPA-bYc%j6?s(vGsftQ2_3q_gihUQ zLRM!1I>uZV+`7A)@;b*n7dV~kM!cJ6ccB8KH+<;jT_>+2B!adM^kKtpbgZfdxR@UE z7x$En^Qo(s@YRw;=5aJ}o$$Wy8 zig?c2A}d9#OvAbOD=U|~*?MeNmPq(UJfX*ofql4VURNgBQ_>oYi~%&GdqT~xwO4No6vQ*SzDNfN)n8pS8W}YGTk657 z8cW)aS8WBM-QZ8&ae}dyR5rt=VfW6#J9(CO@$M5;K8$YkANV}L$L-+rSbMgA?TD(b zj4Ms;-Z4}L*3G}|wG-NUYtor^bRBMXhRM*(YO?c6^<}*28y zv$6DP8>_<*8Wj;z8@FlLHQAUxTj#K`9U2U(Sq zEP*^w1KTWVnGB@Q+4MSm(~|K{gc?v68lSe{PtE}Uke}unWB_0?^ItNq_5YD^Reg|q z^^A+RT`upcRz4^v+e=sys)bL4ftf?vs-Fj|y@@5@{VQD1wN$smv+GX*pja0yS{&QOF>^tYE8*szjNSMM+GS zV|lXb8=47lKQ*~UyGInVzx>#nkOWI zrj~S0TpJ!9nV1;d-hM#aVs1#5509qCE?whC2CHpORSx(JPg}kUV)(|)Aekuxw!@PAaY>h~xevH%3BZ1$$VJ0J zVDq$G6oo;(yK%}fkJn;FAf4F=o`U<0?7fZr|AgwzbrtfXnwY_6c>%xpE9Os8g5 zL7&M=1&R7iGrZPf5jw+o&~WF>md$h*pL_^hLuvJAI1A+#GZ zym`Ss1qspxhjhnqnMN5NTPr2>!l1#f4kO=R(nvoww$N`~iT)Y5_5I}bOn+XQm5Xw} zMsihB0&WYv+zC;dlL`uS$q7NzWK9=}dOwY_bVs1=sn(wtZWXfC^ixPuoj2H7K`xS0 zU_uCf+Rq{0chS11@fQml;nuZq-?!nu%JIvq;VX;obuO^RwJ&q+kFIC0_ zbI0mUb9HJx7+VYC1v6G9V|L`Jzc!O)K1mdgQfep^L#NFfWhd=dgx=~Zt zTBK(=(z6oTcXxa}8h?85X=i2NdL{C~-SJI_%N5-Sw^=87+P)G#zUbcA-S@P)a(HZI z_r(g=wdqBXmbGxta=53mcVH!a;_k)uNc7?U2m3$Y^Z3B84?M~IZsLpfmBg8qwzDgd zA&|*NT+#0%ed{ef>ycgSZCzU)w#mEb0YME-Yh3Fx*ZO3Cg=;kp1&b5kamjyfy@Nun zTgU+i^M@@M4rgGqHrETcD=)13+2*1a?^q}VbTX1Z@6^TmMU$kyC zeiDA-tOWM1a{Fo)n_Q;R|I+B6nm@M2MoytWCHIV;L|=vb&L2Zxo$`$ycYf{OJ9^0Z z^&uCygkixsHk literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/get_campaign_bid_simulations.cpython-314.pyc b/api_examples/__pycache__/get_campaign_bid_simulations.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c2df90eb6adce299831d88d7a14cdb193f6e08d4 GIT binary patch literal 4538 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 literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/get_change_history.cpython-314.pyc b/api_examples/__pycache__/get_change_history.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd5c3c8bb22203ec73cc74059a60a7e3b93af4f9 GIT binary patch literal 5773 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 literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/get_geo_targets.cpython-314.pyc b/api_examples/__pycache__/get_geo_targets.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a553386e58cf8b275dc86bb3e3c5085e8a6d0cfe GIT binary patch literal 5201 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 literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/parallel_report_downloader_optimized.cpython-314.pyc b/api_examples/__pycache__/parallel_report_downloader_optimized.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0cce0ece5fc5e1f1a04fc3aa4c458ddb9132063 GIT binary patch literal 8726 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 literal 0 HcmV?d00001 diff --git a/api_examples/__pycache__/target_campaign_with_user_list.cpython-314.pyc b/api_examples/__pycache__/target_campaign_with_user_list.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8035a56c9d2fee77aca710cca77637e52931a464 GIT binary patch literal 4943 zcmb_fO>7&-6`mz`$tAh`kxc#nt$u7-l1avv9NVgFMU|~cu`6x1vRb63w+nJ5EkKd% z?9x9vxGLI1kc(pkfpcsQMdDL>DtZiRBxllAOM2^~0g9sNjfT`Ta4&tc%jMD%)gQD2 zaNe8u-n@A;kMq6RiBQmwppDFZpsuwb^d~Z~8(U-c-f|4XrGJ4?FG|uvtG$3j2a)I%*>jX7 z(fvtEXm_+W2Ub4OU$^3toX}(I94ZDxE*TPoVn__1V=VaqI>I(0wwSi9|AFl>F>2bj z9k3m6*0#B+8ly*gP|`bylKx~w7_zkv1?h-0vN@2Ruz810(H0MbjOa-) z!)VO=S7K{;Qf4SdAXU)x+!n;FreRP!3Nqm8J0T+vR`deSE|0meIp3}681~^=&*3du z*GSw$#_dEw!--5TC1(;F=AILq;5&hDZzeXBEj6nqG@PobVscuG$$2%FR&FVoTwcL3 zS<~R)masBcPl0q>)iY~Fum~8$d%bae@CaO z=nK+4=X$DcLdRnkotd@hifGcW7~cy9^m{N5rD{Mxctk)N&Ys@;A@n34f}JT-ZFOS( zubHR8g5>W&6YGEYJhR3KjVshF^PVGo*RQMKJ>V3E-)o(!{R7rK*}B&wJkjk7bKuF} z2-Dz|w{;F}R+0c96@>D>hG+w;zGh_#*5Dd<4pvRd)&a174WZ_C!bvACn}b-Oz~}u9 zu>@EB!nmz>=mE%v(=s7@!=g9pC9zovJOtgLJUh#hJc=gQ0Nr7^2d>v#I!(~p#w0$4 zRb9b=lmxadOaOHqgAalE9Ny5T{X8@a>!$hDO@+6Bmj^JeiQ|@u1A~^Gjf?!!Wgb9| zzrCrZHqABCXwd=Uw=$x;uGH)Ns zcNKg~O({nAA-m8aAe8=<@JIF@{v(q&Zc=a zo5~c@ie?_udM=a6-Bz<3yn=BKYtt{=d{+o(&D~NoP2NyovTHQ{lJogivi!QLWZ>wQ z$2yGwDc7(q^E6m8(C_9ID0oOSwY;VvFEb& zL+Wj!tN#Iv*27w(=detIo#%5|O)*@`9Xv+XY;6)XDLE&gk!uMJ2ZB|ny+-7EqBe7x@L-W zJ7|Z;%0pA7p{e58g{MPrmWQsFhOSrGV|#Abum>!5>4B;r#iz=#*-~t_!ghfo?)i%+ z`jhPYKX~d%R2jr{lxe<1^SktTm2GFD;50Z|=4MOWY^8U!(tEzxa{*dJhQsig`~2VRvxw{8 zM}8*yoDEh&13!0t68t#$(T}T$N`QyfhwKCPVemolgK#-AQi_Ze!^exqmxza})IRZ0 zWl?+Q!*?FMQ*Jx4+jipJE6=%>Vs!k|v!7jjbn(-~uUm_)GrQcyN;rD|-u-*;-K&fz zK8rt!m&dQ|j$bQAPdtxyeY9MhxLBT8EKMvH$CrxTZ~ZQMbM#N8Zx6jXjp*uM8a437 z9dL-4JWFsF>But#lMif6{`_M@hF6l(xfC293tudG8G{eK=?494(S zK^dH!PD>D@sl()E8-7zogaAjRKY&A+e2wDcq$3Xz6DKfvXy6Ibkp~2Z4^4#0H#AO= z4xTayUmzX%hQ^c7Jwli~JT35GXSQ>AxZhnT%bgdVbYA%U_-^N7k#74c*j5gXJPD3eqO1FE zSID!o05^^al!A*9bxm@(Kem7pVP0{aINJ) zZz<6GG|<0uxk|S%(PywK*H_^pWv;ix^}fpVmAJmIeT>ilVjlL@{Y`b5vJvLl{|RB9 s{dWlS?B5~`;P8FHnN00s9g(3IjmbnWHeeN&9b1aZA`xub@6QHzu+yDRo literal 0 HcmV?d00001 diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py index f293732..8a70fa1 100644 --- a/api_examples/gaql_validator.py +++ b/api_examples/gaql_validator.py @@ -13,27 +13,31 @@ from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -def main(): - 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", required=True, help="API Version (e.g., v23).") - args = parser.parse_args() +def main(client=None, customer_id=None, api_version=None, query=None): + 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", required=True, help="API Version (e.g., v23).") + args = parser.parse_args() - # Read query from stdin to handle multiline/quoted strings safely - query = sys.stdin.read().strip() - if not query: - print("Error: No query provided via stdin.") - sys.exit(1) + 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() - # Initialize client - try: - client = GoogleAdsClient.load_from_storage() - except Exception as e: - print(f"CRITICAL ERROR: Failed to load Google Ads configuration: {e}") + # Initialize client + try: + client = GoogleAdsClient.load_from_storage() + 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 - api_version = args.api_version.lower() + api_version = api_version.lower() module_path = f"google.ads.googleads.{api_version}.services.types.google_ads_service" try: module = importlib.import_module(module_path) @@ -43,7 +47,7 @@ def main(): sys.exit(1) ga_service = client.get_service("GoogleAdsService") - customer_id = "".join(re.findall(r'\d+', args.customer_id)) + customer_id = "".join(re.findall(r'\d+', str(customer_id))) try: request = SearchGoogleAdsRequest( 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..9e9961c3ffc427a67e5a6f0cd9000cea55bc8a53 GIT binary patch literal 15018 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 NeDPxwWo{uO|3BrT+UNiP literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dcb0bbc69422bae7a08acf09dacc062a50eea7c2 GIT binary patch literal 8687 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_collect_conversions_troubleshooting_data.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_collect_conversions_troubleshooting_data.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51ee6e98c01f693730e96b17d9a0243b7b3132a6 GIT binary patch literal 9082 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd85b1f8a088add2d58ce38f7136a0f5075c60d1 GIT binary patch literal 8427 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc4496878384aa6b610e01aec93b0e35352f2ff8 GIT binary patch literal 17314 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_gaql_validator.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_gaql_validator.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a392b6227d17cc8cb5ee61f7760320406adf5769 GIT binary patch literal 6594 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 smE-o_n89{>OV 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..846d42338b7323254cbed51882d317d1ea4bf791 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..637f40b78b1dfa209390fd1ad22e8da169f75720 GIT binary patch literal 7101 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 JWbLFP{|DY-+6Djs literal 0 HcmV?d00001 diff --git a/api_examples/tests/__pycache__/test_get_conversion_upload_summary.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_conversion_upload_summary.cpython-314-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b03fb943e1ff4c4d0f26d1c4bdf8ed79c0d5f26 GIT binary patch literal 5877 zcma)AO>7&-6`tkqiXz2Owj|T~A*~-fYqOg)j+6P!~FY1il9eZL9q9v@ud+KJ7Fp(!1Ix_>RAZP-ZVeeF&td8s&`o0b~vl zfs+|x|FQOe(Z;2iLvLG@vPycfkp7wON=vz1jMMoG1S@Jbd4AIqk0;;06ja~_E$-=@BC7;guDqnEJM5#rE9lX2m{I}@Sq1A55OJ*a5ob3lfJQxt zxC95}Zovt;M{q&z72JeRA<`yzhEU9_4_uTr@{&wW7u2gV)st%xd+A@t-r>?Q*5ltgGe?gnI-#<)o*e73H+7rw{6HCrTPlrLZQMjMxk%6LY;D(4DC8IMbvrf5V`Ndowy{HByI=435F zz%Gnjq?)HK`w&aaKt#>3ka%%JcWW}aRMdx`ZcA|{)elN*0%X_GcJJ_?6P4Z*TdoSf zZ#y!2`z>1Qt4$xTPA`V6gaBl)A6@%yu6?jZmxA{LXU9^gU9hFli_>`d zOye$cyRTW%(Sqj`P|B%VmlrOLv(;6zFX);Nx#WPrM8TdcsTn!iB7% zF-o=fmtxnVs*o>Vw6m4uW z;(OH4HCm7JCpPB=Su2!qT8=JBdHF>2*)(Fou2GNar_5-nUsunb{KziV>sqv0)lzIf zi@JIM*Hso(P??5DiIg3 z3IW|HIn^LJ)g(Ekdknav(V%fKY9u4DhA18pDGY}FW2dN3d~bwp>JfucwO>QvyN(|8 zL_d6M%fH<<@ZnFleB0r{+r3*IwL{`oM};4*jl{P)s(gQ$@2~KKwZr3-J6fBVqUCt) zV2qYSwZs`(9;rnKY1vsaLjU>vEM1`M#UY*_5&`3EnQVqq+q4@pK#FnSV``-(^8@Shf>WgC){$-0? zy(h{Qe%+$a-CeKnmn`zk7eFq4!}|>gcWZAoJYEiuSHrXA@N6}Fwj4ftm-~``c{k9Z z_Q`GZ{F!gx2z`rDfZmi|bG8hpEim*NXO9hIMVkf!a(QSs&w>{&=wpEzTMpob z_4;51jG4|L$);)7KVr z8K=S`GK^CeXw zl&c5KTAYFs<7*AO&JLwud~0zPC2~dgfFgi;%rk#(#>8jWxbI z%o(=v0?t7bJuvOJkUeyA9QSwtaojuTm%gu1+gB*~4;1?v^?ZYzH~6>t8^O1O?{n`@ z-JJPg=H|%{PTraNc;=&-kLNy``-|gq?`Pi6gP#Sz;O|c)?;gKDbn*V4OBIy*Cpz&p zn)w>de&;#Iaar!pYt@ls<&k6GAx1uguYoTA)@H>SuDSd-7Jsq$tMmVHBG-PV?*9R| Ck4l>W 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..e7ba8e6a9d6ee71087f5d10c824a35316a3441b3 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..a40e2d7b10012e7a35033d47dde4d124a463bdb3 GIT binary patch literal 5383 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 PKbZXS^jCmtX3PB_6R)pF 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..2fda46a6ac1de7cf6aa543b87a83979f37cf8a6f 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..97ea7783ec50b596c4bca557ae85df933fe41987 GIT binary patch literal 7780 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 literal 0 HcmV?d00001 diff --git a/api_examples/tests/test_gaql_validator.py b/api_examples/tests/test_gaql_validator.py new file mode 100644 index 0000000..f1981a3 --- /dev/null +++ b/api_examples/tests/test_gaql_validator.py @@ -0,0 +1,133 @@ +# 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, patch +from io import StringIO + +# Ensure project root is in path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from google.ads.googleads.errors import GoogleAdsException +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): + # Setup mocks + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_request_class = MagicMock() + setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) + + # Execute + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query=self.test_query + ) + + # Verify + self.mock_ga_service.search.assert_called_once() + output = self.captured_output.getvalue() + self.assertIn("SUCCESS: GAQL query is valid.", output) + + @patch("importlib.import_module") + def test_main_validation_failure(self, mock_import): + # Setup mocks + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_request_class = MagicMock() + setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) + + # Setup GoogleAdsException + error = MagicMock() + error.message = "Invalid query" + error.location.field_path_elements = [MagicMock(field_name="query")] + + self.mock_ga_service.search.side_effect = GoogleAdsException( + error=MagicMock(), + call=MagicMock(), + failure=MagicMock(errors=[error]), + request_id="test-id" + ) + + # Execute + with self.assertRaises(SystemExit) as cm: + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query=self.test_query + ) + + # Verify + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn("FAILURE: Query validation failed with Request ID test-id", output) + self.assertIn("- Invalid query", output) + + def test_main_no_query(self): + # Execute + with self.assertRaises(SystemExit) as cm: + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query="" + ) + + # Verify + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn("Error: No query provided.", output) + + @patch("importlib.import_module") + def test_main_import_error(self, mock_import): + # Setup mocks + mock_import.side_effect = ImportError() + + # Execute + with self.assertRaises(SystemExit) as cm: + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query=self.test_query + ) + + # Verify + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn(f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {self.api_version.lower()}.", output) + +if __name__ == "__main__": + unittest.main() From 1a66fb45e4d641bbeeea1bdea798215f3ff28cbe Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 12:18:21 -0500 Subject: [PATCH 52/81] Modifed init hook to deal with absence of google-ads.yaml --- .gemini/hooks/custom_config.py | 10 +- api_examples/gaql_validator.py | 38 ++++--- api_examples/tests/test_gaql_validator.py | 133 ++++++++++++++++++++++ 3 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 api_examples/tests/test_gaql_validator.py diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index 0848a9e..530410a 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -110,7 +110,11 @@ def configure_language(lang_name, home_config, target_config, version, is_python 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") + f.write(f"\ngaada{sep} \"{version}\"\n") + + if is_python: + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_config}\"", file=sys.stdout) + return True except Exception as e: print(f"Error configuring {lang_name}: {e}", file=sys.stderr) @@ -148,11 +152,13 @@ def main(): 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("Warning: No Google Ads configuration found in home directory.", file=sys.stderr) + 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 settings_path = os.path.join(project_root, ".gemini/settings.json") diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py index f293732..8a70fa1 100644 --- a/api_examples/gaql_validator.py +++ b/api_examples/gaql_validator.py @@ -13,27 +13,31 @@ from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -def main(): - 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", required=True, help="API Version (e.g., v23).") - args = parser.parse_args() +def main(client=None, customer_id=None, api_version=None, query=None): + 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", required=True, help="API Version (e.g., v23).") + args = parser.parse_args() - # Read query from stdin to handle multiline/quoted strings safely - query = sys.stdin.read().strip() - if not query: - print("Error: No query provided via stdin.") - sys.exit(1) + 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() - # Initialize client - try: - client = GoogleAdsClient.load_from_storage() - except Exception as e: - print(f"CRITICAL ERROR: Failed to load Google Ads configuration: {e}") + # Initialize client + try: + client = GoogleAdsClient.load_from_storage() + 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 - api_version = args.api_version.lower() + api_version = api_version.lower() module_path = f"google.ads.googleads.{api_version}.services.types.google_ads_service" try: module = importlib.import_module(module_path) @@ -43,7 +47,7 @@ def main(): sys.exit(1) ga_service = client.get_service("GoogleAdsService") - customer_id = "".join(re.findall(r'\d+', args.customer_id)) + customer_id = "".join(re.findall(r'\d+', str(customer_id))) try: request = SearchGoogleAdsRequest( diff --git a/api_examples/tests/test_gaql_validator.py b/api_examples/tests/test_gaql_validator.py new file mode 100644 index 0000000..f1981a3 --- /dev/null +++ b/api_examples/tests/test_gaql_validator.py @@ -0,0 +1,133 @@ +# 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, patch +from io import StringIO + +# Ensure project root is in path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from google.ads.googleads.errors import GoogleAdsException +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): + # Setup mocks + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_request_class = MagicMock() + setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) + + # Execute + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query=self.test_query + ) + + # Verify + self.mock_ga_service.search.assert_called_once() + output = self.captured_output.getvalue() + self.assertIn("SUCCESS: GAQL query is valid.", output) + + @patch("importlib.import_module") + def test_main_validation_failure(self, mock_import): + # Setup mocks + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_request_class = MagicMock() + setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) + + # Setup GoogleAdsException + error = MagicMock() + error.message = "Invalid query" + error.location.field_path_elements = [MagicMock(field_name="query")] + + self.mock_ga_service.search.side_effect = GoogleAdsException( + error=MagicMock(), + call=MagicMock(), + failure=MagicMock(errors=[error]), + request_id="test-id" + ) + + # Execute + with self.assertRaises(SystemExit) as cm: + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query=self.test_query + ) + + # Verify + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn("FAILURE: Query validation failed with Request ID test-id", output) + self.assertIn("- Invalid query", output) + + def test_main_no_query(self): + # Execute + with self.assertRaises(SystemExit) as cm: + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query="" + ) + + # Verify + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn("Error: No query provided.", output) + + @patch("importlib.import_module") + def test_main_import_error(self, mock_import): + # Setup mocks + mock_import.side_effect = ImportError() + + # Execute + with self.assertRaises(SystemExit) as cm: + main( + client=self.mock_client, + customer_id=self.customer_id, + api_version=self.api_version, + query=self.test_query + ) + + # Verify + self.assertEqual(cm.exception.code, 1) + output = self.captured_output.getvalue() + self.assertIn(f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {self.api_version.lower()}.", output) + +if __name__ == "__main__": + unittest.main() From cb23e49999598b5b6747819910555ef5904e5599 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 14:18:09 -0500 Subject: [PATCH 53/81] Add support for ads.properties. --- .gemini/hooks/custom_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index 530410a..c2e3479 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -173,6 +173,7 @@ def main(): 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")}, ] for lang in languages: From 993dfee3c7c0d10f9ce5151e442710e57f67a865 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 19:19:41 +0000 Subject: [PATCH 54/81] Cleanup __pycache__ --- .gemini/settings.json | 10 ++++++---- ...add_campaign_with_date_times.cpython-314.pyc | Bin 5723 -> 0 bytes .../__pycache__/ai_max_reports.cpython-314.pyc | Bin 8284 -> 0 bytes .../__pycache__/capture_gclids.cpython-314.pyc | Bin 4457 -> 0 bytes ...ersions_troubleshooting_data.cpython-314.pyc | Bin 10006 -> 0 bytes .../conversion_reports.cpython-314.pyc | Bin 21719 -> 0 bytes .../create_campaign_experiment.cpython-314.pyc | Bin 9272 -> 0 bytes .../disapproved_ads_reports.cpython-314.pyc | Bin 11641 -> 0 bytes .../__pycache__/gaql_validator.cpython-314.pyc | Bin 4058 -> 0 bytes ...get_campaign_bid_simulations.cpython-314.pyc | Bin 4538 -> 0 bytes .../get_campaign_shared_sets.cpython-314.pyc | Bin 3764 -> 0 bytes .../get_change_history.cpython-314.pyc | Bin 5773 -> 0 bytes ...et_conversion_upload_summary.cpython-314.pyc | Bin 9512 -> 0 bytes .../__pycache__/get_geo_targets.cpython-314.pyc | Bin 5201 -> 0 bytes .../list_accessible_users.cpython-314.pyc | Bin 2563 -> 0 bytes .../list_pmax_campaigns.cpython-314.pyc | Bin 4102 -> 0 bytes ..._report_downloader_optimized.cpython-314.pyc | Bin 8726 -> 0 bytes ...automatically_created_assets.cpython-314.pyc | Bin 5374 -> 0 bytes ...rget_campaign_with_user_list.cpython-314.pyc | Bin 4943 -> 0 bytes ..._ai_max_reports.cpython-314-pytest-8.4.2.pyc | Bin 15018 -> 0 bytes ..._capture_gclids.cpython-314-pytest-8.4.2.pyc | Bin 8687 -> 0 bytes ...leshooting_data.cpython-314-pytest-8.4.2.pyc | Bin 9082 -> 0 bytes ...version_reports.cpython-314-pytest-8.4.2.pyc | Bin 17636 -> 0 bytes ...aign_experiment.cpython-314-pytest-8.4.2.pyc | Bin 8427 -> 0 bytes ...ved_ads_reports.cpython-314-pytest-8.4.2.pyc | Bin 17314 -> 0 bytes ..._gaql_validator.cpython-314-pytest-8.4.2.pyc | Bin 6594 -> 0 bytes ...bid_simulations.cpython-314-pytest-8.4.2.pyc | Bin 7892 -> 0 bytes ...ign_shared_sets.cpython-314-pytest-8.4.2.pyc | Bin 6985 -> 0 bytes ..._change_history.cpython-314-pytest-8.4.2.pyc | Bin 7101 -> 0 bytes ..._upload_summary.cpython-314-pytest-8.4.2.pyc | Bin 5877 -> 0 bytes ...get_geo_targets.cpython-314-pytest-8.4.2.pyc | Bin 8929 -> 0 bytes ...ccessible_users.cpython-314-pytest-8.4.2.pyc | Bin 5383 -> 0 bytes ..._pmax_campaigns.cpython-314-pytest-8.4.2.pyc | Bin 6740 -> 0 bytes ...oader_optimized.cpython-314-pytest-8.4.2.pyc | Bin 8900 -> 0 bytes ..._created_assets.cpython-314-pytest-8.4.2.pyc | Bin 10053 -> 0 bytes ..._with_user_list.cpython-314-pytest-8.4.2.pyc | Bin 7780 -> 0 bytes customer_id.txt | 2 +- 37 files changed, 7 insertions(+), 5 deletions(-) delete mode 100644 api_examples/__pycache__/add_campaign_with_date_times.cpython-314.pyc delete mode 100644 api_examples/__pycache__/ai_max_reports.cpython-314.pyc delete mode 100644 api_examples/__pycache__/capture_gclids.cpython-314.pyc delete mode 100644 api_examples/__pycache__/collect_conversions_troubleshooting_data.cpython-314.pyc delete mode 100644 api_examples/__pycache__/conversion_reports.cpython-314.pyc delete mode 100644 api_examples/__pycache__/create_campaign_experiment.cpython-314.pyc delete mode 100644 api_examples/__pycache__/disapproved_ads_reports.cpython-314.pyc delete mode 100644 api_examples/__pycache__/gaql_validator.cpython-314.pyc delete mode 100644 api_examples/__pycache__/get_campaign_bid_simulations.cpython-314.pyc delete mode 100644 api_examples/__pycache__/get_campaign_shared_sets.cpython-314.pyc delete mode 100644 api_examples/__pycache__/get_change_history.cpython-314.pyc delete mode 100644 api_examples/__pycache__/get_conversion_upload_summary.cpython-314.pyc delete mode 100644 api_examples/__pycache__/get_geo_targets.cpython-314.pyc delete mode 100644 api_examples/__pycache__/list_accessible_users.cpython-314.pyc delete mode 100644 api_examples/__pycache__/list_pmax_campaigns.cpython-314.pyc delete mode 100644 api_examples/__pycache__/parallel_report_downloader_optimized.cpython-314.pyc delete mode 100644 api_examples/__pycache__/remove_automatically_created_assets.cpython-314.pyc delete mode 100644 api_examples/__pycache__/target_campaign_with_user_list.cpython-314.pyc 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_collect_conversions_troubleshooting_data.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_gaql_validator.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_conversion_upload_summary.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 diff --git a/.gemini/settings.json b/.gemini/settings.json index 82189b2..44b5370 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -17,9 +17,11 @@ }, "context": { "includeDirectories": [ - "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/api_examples", - "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/saved/code", - "/usr/local/google/home/rwh/gemini/src/google-ads-api-developer-assistant/client_libs/google-ads-python" + "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/api_examples", + "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/saved/code", + "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/client_libs/google-ads-python", + "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/client_libs/google-ads-php", + "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/client_libs/google-ads-ruby" ] }, "tools": { @@ -51,4 +53,4 @@ } ] } -} \ No newline at end of file +} diff --git a/api_examples/__pycache__/add_campaign_with_date_times.cpython-314.pyc b/api_examples/__pycache__/add_campaign_with_date_times.cpython-314.pyc deleted file mode 100644 index 1ed1a256ec5e446911ad20a124b0e50d931cc7bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5723 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 R`wl%J4}UX^$bH%_{s-flYNP-F diff --git a/api_examples/__pycache__/ai_max_reports.cpython-314.pyc b/api_examples/__pycache__/ai_max_reports.cpython-314.pyc deleted file mode 100644 index e896d1abc53401527935ab9eb9e0cca460fadc95..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8284 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 Qo_XMT;o%7)bTDcBFXeosYXATM diff --git a/api_examples/__pycache__/capture_gclids.cpython-314.pyc b/api_examples/__pycache__/capture_gclids.cpython-314.pyc deleted file mode 100644 index e0c2190977b5baf0d1a1ac657f1cc0b192ea66d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4457 zcma)9O>o=R5q=;+5d0GWS8mDVwAs+p)sLt`+%Dw&OCeB4;vUFenL;7?S`C zfR4@SBs%nv3HN;UXlunt{rMrN- zR@|3pot_{%_loBdqcanTqhpBk#84%od-E*EEWUzJ@plM~p*)=%uorOXB;vdrdy29o zx<60lh8(TUft8Q*_pE~XU~bgbIg|@+ zRi-#<9W>U%xz=4h-o1yX_wXK$$#@QYA=pMU|I|hNlG5q7f}9 z@-0!-ASk{l=pwI6RncHn5w`8r0IgJXF$$(v#e#hY39xN zv~=x`5#)J6mKB|Z$E$s?I9v^+k^8)f{)1+znaAYyFgV(A6FQf&=rhk+^rcmkKJxe; zdqDpL=AlBjN(qk$`arhnCm421P9SHWjj6U?T<_l0Xs{qBw(sG3x3T8AF)jDb=?DDv z>Ziu6F*)x(XE0o&INCp?MMJ^1BVet>%G_kai%U{yYLipT;5T#{~yvIP@qzkf+BX<0vp$Ew6EUXn4?hb{>fsUa3|4J_lDB3N1OP}YVuAu# z*}!D7v$?e2h%NUfTS>Sss<)(qXoMm3!c9lJ*K(;qK(A|=q*QX(8<}g$9@khfZ8XSV zT`s0^t|AH=tgVV9Bt;g}rR4h7r(cy%oi*GqUq5GzTg&fTRpZ2;*F{ZV1+?<27Jn@n zH3B73=e2IAEE(u`0E0;DWDIMfpccx!vroPoj6uudZQY8#Kg+TUZcxtKy5bM_NXa+6bmUN^4fIpSguMycJza>=a;HpqpRZ%Y7fe?zKnH|9D zUY<3B5o$G2E*jyQpprYoZwOKaK1If%x_J?KxDbV#dp4jY11bZHnZC?jT5AtGUit1+ zJ1LR<*o5A4!?R>Gt%T$s{@q3J-26Z!69C>HB?WHZQhErg!LAn+~+-*ft$&(eZ6M z-kiwn(DUthV)Nn-J=&hkwkEG{PhM|FVh~AUl|+(Ynsj`ZK^}k8KlKGY{q^o)6dc|~ zE{Kf{HT!d&G0avXgYKXxwU{!5U8x54r}NgvM} z?pq7T$!+g`#TzNBFn%OB!OTEW)C#I(HYN>Ms?Z3fQZ}?er?A=>C75GD6o$s;JGhf{ z8#E~;gC*4_sbbNf%VMPlj!F%0DrG`a*F&#q`VJ(4rQR5VC3_d%X+#>oCQJVxRKiEJ zvQn=U@y`IeHlQLc)FLHe;#e|mf~M8GU})%O@4%C)R#U|yE|;^_YF5*Z{vu_vv&S$% zO&ZTG%r88hnkRqw>4og^h3tuwsrkk1{QPXX(ceWG{JNx<0ex|zF_gfhC;`h&8Njz* z*FZj%F!5B93za(L*$iz%sZ^BLCAow}RaI0i+ZgnnC!966Dr%Zg5@E6{k!}QizMsl? zLlP^+EKVj2FV7d10-QT5s3pJys6q0>#u&9)urz#@(iTfNC~D~ZtS0XJEvyh!($1K* zNNyGZI9Q>deB}5IRjKmOH>wbm5$U-mm_wS%KKQuRtH9;VSPaIB$p{L?A`c;&I+Xx` z;Wt%82pCD;`{BtY-d5wLWf!{5M%9eapb0htAQSBM+W5y<(Hq`w&J32cqb49fWu}a; z33{0qD5KgcgIap)Z;j! zPJW5Pe@Fg*qEr7s*{>MrE<39fqJwWWcHF~&9EWCGvBl40i+>v2iCqLdc@!FKg{D3a zO|_%TyKYw>vw5MzB2O1e+HA1R#@eAsyMM49n(G9R=g=-u6|GS0jwJwYX>8(;MlgwY+Zi)vN@jK4o?5u=kfU; zpND;oeBFW8I2hkWeljc6?wk0)_509=q4(eHASx4SM+WY(ciDTPyP@~Ot;p1NWU3jS zZceX2!a`T5UANokhy4y6y7%hcS6hQKJA*TCoqrJQZ${@nI{C@zk57M;`Qt!y;Q5{4 z>2^4}^~Tm4@4V5T%Y2glINh4Nx-)mR8J&3$9e)2>^XTc;(M#J$FE!^@nj@FLh+cW( zMn_+y9(&QyEP%Q%Xu;@K;GMvKJA)AIw;G1i`*b|H>_?yZy%!Sh&-$01y)f?nYj}wT K^0#ph$o>a=s7Dn5 diff --git a/api_examples/__pycache__/collect_conversions_troubleshooting_data.cpython-314.pyc b/api_examples/__pycache__/collect_conversions_troubleshooting_data.cpython-314.pyc deleted file mode 100644 index 0e2c82bf68b1b003c53594aeb747ba8533642c02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10006 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 diff --git a/api_examples/__pycache__/conversion_reports.cpython-314.pyc b/api_examples/__pycache__/conversion_reports.cpython-314.pyc deleted file mode 100644 index 46df173d7e5405d319517ce4bf6a2cbeabc16934..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21719 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 ZX@2C`>(*=5-&BE69!(l3V>RiY{|65;b>08~ diff --git a/api_examples/__pycache__/create_campaign_experiment.cpython-314.pyc b/api_examples/__pycache__/create_campaign_experiment.cpython-314.pyc deleted file mode 100644 index 6345b9e66f46fe0e10c83349b136b198ee054fbd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9272 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 diff --git a/api_examples/__pycache__/get_campaign_bid_simulations.cpython-314.pyc b/api_examples/__pycache__/get_campaign_bid_simulations.cpython-314.pyc deleted file mode 100644 index c2df90eb6adce299831d88d7a14cdb193f6e08d4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4538 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 diff --git a/api_examples/__pycache__/get_change_history.cpython-314.pyc b/api_examples/__pycache__/get_change_history.cpython-314.pyc deleted file mode 100644 index fd5c3c8bb22203ec73cc74059a60a7e3b93af4f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5773 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 diff --git a/api_examples/__pycache__/get_geo_targets.cpython-314.pyc b/api_examples/__pycache__/get_geo_targets.cpython-314.pyc deleted file mode 100644 index a553386e58cf8b275dc86bb3e3c5085e8a6d0cfe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5201 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 diff --git a/api_examples/__pycache__/parallel_report_downloader_optimized.cpython-314.pyc b/api_examples/__pycache__/parallel_report_downloader_optimized.cpython-314.pyc deleted file mode 100644 index c0cce0ece5fc5e1f1a04fc3aa4c458ddb9132063..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8726 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 diff --git a/api_examples/__pycache__/target_campaign_with_user_list.cpython-314.pyc b/api_examples/__pycache__/target_campaign_with_user_list.cpython-314.pyc deleted file mode 100644 index 8035a56c9d2fee77aca710cca77637e52931a464..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4943 zcmb_fO>7&-6`mz`$tAh`kxc#nt$u7-l1avv9NVgFMU|~cu`6x1vRb63w+nJ5EkKd% z?9x9vxGLI1kc(pkfpcsQMdDL>DtZiRBxllAOM2^~0g9sNjfT`Ta4&tc%jMD%)gQD2 zaNe8u-n@A;kMq6RiBQmwppDFZpsuwb^d~Z~8(U-c-f|4XrGJ4?FG|uvtG$3j2a)I%*>jX7 z(fvtEXm_+W2Ub4OU$^3toX}(I94ZDxE*TPoVn__1V=VaqI>I(0wwSi9|AFl>F>2bj z9k3m6*0#B+8ly*gP|`bylKx~w7_zkv1?h-0vN@2Ruz810(H0MbjOa-) z!)VO=S7K{;Qf4SdAXU)x+!n;FreRP!3Nqm8J0T+vR`deSE|0meIp3}681~^=&*3du z*GSw$#_dEw!--5TC1(;F=AILq;5&hDZzeXBEj6nqG@PobVscuG$$2%FR&FVoTwcL3 zS<~R)masBcPl0q>)iY~Fum~8$d%bae@CaO z=nK+4=X$DcLdRnkotd@hifGcW7~cy9^m{N5rD{Mxctk)N&Ys@;A@n34f}JT-ZFOS( zubHR8g5>W&6YGEYJhR3KjVshF^PVGo*RQMKJ>V3E-)o(!{R7rK*}B&wJkjk7bKuF} z2-Dz|w{;F}R+0c96@>D>hG+w;zGh_#*5Dd<4pvRd)&a174WZ_C!bvACn}b-Oz~}u9 zu>@EB!nmz>=mE%v(=s7@!=g9pC9zovJOtgLJUh#hJc=gQ0Nr7^2d>v#I!(~p#w0$4 zRb9b=lmxadOaOHqgAalE9Ny5T{X8@a>!$hDO@+6Bmj^JeiQ|@u1A~^Gjf?!!Wgb9| zzrCrZHqABCXwd=Uw=$x;uGH)Ns zcNKg~O({nAA-m8aAe8=<@JIF@{v(q&Zc=a zo5~c@ie?_udM=a6-Bz<3yn=BKYtt{=d{+o(&D~NoP2NyovTHQ{lJogivi!QLWZ>wQ z$2yGwDc7(q^E6m8(C_9ID0oOSwY;VvFEb& zL+Wj!tN#Iv*27w(=detIo#%5|O)*@`9Xv+XY;6)XDLE&gk!uMJ2ZB|ny+-7EqBe7x@L-W zJ7|Z;%0pA7p{e58g{MPrmWQsFhOSrGV|#Abum>!5>4B;r#iz=#*-~t_!ghfo?)i%+ z`jhPYKX~d%R2jr{lxe<1^SktTm2GFD;50Z|=4MOWY^8U!(tEzxa{*dJhQsig`~2VRvxw{8 zM}8*yoDEh&13!0t68t#$(T}T$N`QyfhwKCPVemolgK#-AQi_Ze!^exqmxza})IRZ0 zWl?+Q!*?FMQ*Jx4+jipJE6=%>Vs!k|v!7jjbn(-~uUm_)GrQcyN;rD|-u-*;-K&fz zK8rt!m&dQ|j$bQAPdtxyeY9MhxLBT8EKMvH$CrxTZ~ZQMbM#N8Zx6jXjp*uM8a437 z9dL-4JWFsF>But#lMif6{`_M@hF6l(xfC293tudG8G{eK=?494(S zK^dH!PD>D@sl()E8-7zogaAjRKY&A+e2wDcq$3Xz6DKfvXy6Ibkp~2Z4^4#0H#AO= z4xTayUmzX%hQ^c7Jwli~JT35GXSQ>AxZhnT%bgdVbYA%U_-^N7k#74c*j5gXJPD3eqO1FE zSID!o05^^al!A*9bxm@(Kem7pVP0{aINJ) zZz<6GG|<0uxk|S%(PywK*H_^pWv;ix^}fpVmAJmIeT>ilVjlL@{Y`b5vJvLl{|RB9 s{dWlS?B5~`;P8FHnN00s9g(3IjmbnWHeeN&9b1aZA`xub@6QHzu+yDRo 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 9e9961c3ffc427a67e5a6f0cd9000cea55bc8a53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15018 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 NeDPxwWo{uO|3BrT+UNiP diff --git a/api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index dcb0bbc69422bae7a08acf09dacc062a50eea7c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8687 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 diff --git a/api_examples/tests/__pycache__/test_collect_conversions_troubleshooting_data.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_collect_conversions_troubleshooting_data.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 51ee6e98c01f693730e96b17d9a0243b7b3132a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9082 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 diff --git a/api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index bd85b1f8a088add2d58ce38f7136a0f5075c60d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8427 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 diff --git a/api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index bc4496878384aa6b610e01aec93b0e35352f2ff8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17314 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 diff --git a/api_examples/tests/__pycache__/test_gaql_validator.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_gaql_validator.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index a392b6227d17cc8cb5ee61f7760320406adf5769..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6594 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 smE-o_n89{>OV 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 846d42338b7323254cbed51882d317d1ea4bf791..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 637f40b78b1dfa209390fd1ad22e8da169f75720..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7101 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 JWbLFP{|DY-+6Djs diff --git a/api_examples/tests/__pycache__/test_get_conversion_upload_summary.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_get_conversion_upload_summary.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 8b03fb943e1ff4c4d0f26d1c4bdf8ed79c0d5f26..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5877 zcma)AO>7&-6`tkqiXz2Owj|T~A*~-fYqOg)j+6P!~FY1il9eZL9q9v@ud+KJ7Fp(!1Ix_>RAZP-ZVeeF&td8s&`o0b~vl zfs+|x|FQOe(Z;2iLvLG@vPycfkp7wON=vz1jMMoG1S@Jbd4AIqk0;;06ja~_E$-=@BC7;guDqnEJM5#rE9lX2m{I}@Sq1A55OJ*a5ob3lfJQxt zxC95}Zovt;M{q&z72JeRA<`yzhEU9_4_uTr@{&wW7u2gV)st%xd+A@t-r>?Q*5ltgGe?gnI-#<)o*e73H+7rw{6HCrTPlrLZQMjMxk%6LY;D(4DC8IMbvrf5V`Ndowy{HByI=435F zz%Gnjq?)HK`w&aaKt#>3ka%%JcWW}aRMdx`ZcA|{)elN*0%X_GcJJ_?6P4Z*TdoSf zZ#y!2`z>1Qt4$xTPA`V6gaBl)A6@%yu6?jZmxA{LXU9^gU9hFli_>`d zOye$cyRTW%(Sqj`P|B%VmlrOLv(;6zFX);Nx#WPrM8TdcsTn!iB7% zF-o=fmtxnVs*o>Vw6m4uW z;(OH4HCm7JCpPB=Su2!qT8=JBdHF>2*)(Fou2GNar_5-nUsunb{KziV>sqv0)lzIf zi@JIM*Hso(P??5DiIg3 z3IW|HIn^LJ)g(Ekdknav(V%fKY9u4DhA18pDGY}FW2dN3d~bwp>JfucwO>QvyN(|8 zL_d6M%fH<<@ZnFleB0r{+r3*IwL{`oM};4*jl{P)s(gQ$@2~KKwZr3-J6fBVqUCt) zV2qYSwZs`(9;rnKY1vsaLjU>vEM1`M#UY*_5&`3EnQVqq+q4@pK#FnSV``-(^8@Shf>WgC){$-0? zy(h{Qe%+$a-CeKnmn`zk7eFq4!}|>gcWZAoJYEiuSHrXA@N6}Fwj4ftm-~``c{k9Z z_Q`GZ{F!gx2z`rDfZmi|bG8hpEim*NXO9hIMVkf!a(QSs&w>{&=wpEzTMpob z_4;51jG4|L$);)7KVr z8K=S`GK^CeXw zl&c5KTAYFs<7*AO&JLwud~0zPC2~dgfFgi;%rk#(#>8jWxbI z%o(=v0?t7bJuvOJkUeyA9QSwtaojuTm%gu1+gB*~4;1?v^?ZYzH~6>t8^O1O?{n`@ z-JJPg=H|%{PTraNc;=&-kLNy``-|gq?`Pi6gP#Sz;O|c)?;gKDbn*V4OBIy*Cpz&p zn)w>de&;#Iaar!pYt@ls<&k6GAx1uguYoTA)@H>SuDSd-7Jsq$tMmVHBG-PV?*9R| Ck4l>W 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 e7ba8e6a9d6ee71087f5d10c824a35316a3441b3..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 a40e2d7b10012e7a35033d47dde4d124a463bdb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5383 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 PKbZXS^jCmtX3PB_6R)pF 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 2fda46a6ac1de7cf6aa543b87a83979f37cf8a6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8900 zcmbtZTWlLwdOpM9Wq1)q>P|_P#9s=k$A_6l< zREGZAX4!(RpUm1-N59_VRGqZPrMecKv4A1O`i-?(b)&XK7F`W_+YJuu_bHs67Kc%L z3TMdZw|*C!qOdl61Y6Xm*9?l$uCC9o`eA+ST=Q%|4a^4BAf?;qT4r0-R$6z=wavDv zZL{raJ8e7XI%Yf7PFi=(b@fWg`~HNq`Jyut8HK;FO>U(l>0B15%_j9+CM{*vFspTnE0QXr5K&HKS0quPvK6g;S%SBTvXsxss;J(Or9?_f zY4+7bI$NiI_UbbB{s}4x(7;8|K7pvVr2&=gAURlBCsFN!4N8Z=Lg^IjP`U&Ml-!~l zcGYQoP1CJScYLRB3>HOW+!8YfwkYH=FV zf!?a35E3UbCzW4U0M*#k8hVVYX;iCOhd~n2W-56bwR@2$^s3Iq;T_cHi z?1eNdLYa@i5)=W7+hnI_;5Vb?p6PXand{o=AO7^Ou-3aBJyD6ymZG!k_6pZk;<_r_ zK#3bDbK~1RLsj0PPm4Nld#KVLF13gMf&E9%9!Mp1`iq~6>hB5q!UbfYPt4R*gF=Ya zYw58-KogkxFe@XHYaS7R0>6kT%3VN^5w-QNBKIHzr%DMKPn8yVu{|;PM^I1`6tFGr zU3Zs0S;7^`VD;)(gqlxRGrA*<2+ui-2 zh3~hN4@Lj2J6Z{yE`?5iQQQiBf8D>$`Sdfm(O2e9Sgqc#xagD0iOZ$R`7=<$M#`CI zmY6ux=)C8NrMyu~$gL%r)JgRjA?=Q{*XBB+<(GWK9`{H)vfKiSHQWg+w=wtCN zF?Dhe7#!Ab+)*d@1@|V_Z%Hs}21L;L9W{KZ@$o8rfLIk%=n=EJSjwa_?YBAP7? zo@X`zUam`)ltM;TWK>D5NdYSZUMirHlvtHfA!YoUGAgf((TO|^h1RU2+MF_q6g=A7v6m1rF`neu!ZTIu* zT!jmjxDdJ=2gj^d-`DMZcivg|Z1)Xr{Ak@%=DHu8yzs?w^hSD1TyKRtTH=nDxv8&* zPHp=F6<=@3*IQGGZ2LlcZpW|(!PmR-+OD6to2nq%b8ofGMJbG;sEuo7?vM$pzi8dK z_G#$e*)lgsX*joyaG4u0`Sn*U?XdeBrrKJq|Se|99hX+$o`0MQf(=Wa-BRgoAV{Y}XvLOS2;)X|82>EyP8d z$KsJEVBW=8JM{z*k=0>>)2-vEr4ek551>XrDO<6y{Is7LS;5xqc#!gGmont$}?mIm@ zZ3n(?JFv}(F6-)QdCa=o{kv_XEBv^}KkeBM zm9IYL$?!}KMw+MzU^(zqu-qIxt)pBOJ=->911~9h{;0m& z9pSO~tlAP=^Nu=$0PpZxd{%AAUNd66YIJmKp_>yENT>PX zAv$X4IFCMeZ5a;xikC)4M!*tLZ=Q$EHsg@`ZFw^R#%*$pKMzTa@cwVXgN$#3LT-&8 zpXN`Qsq!afH~vP?l`V_#iHjHa&$Qw9dqH8}b=mkiaM}9Qo{;vE3$2&-9T#c}Tf+0< z)q^lNtyTYldJ=zuP0W<(fmtwLzIwtyXx)_F&?0HR z8nKG}mmpVjR+GxzW`0U1K%{luxcP8fbxdv#4%Q~FhN(Y-mmuOQ#Gz?O7@M#Gvn2K_dMs5&DjZRh~r%RF3n;fL0j_rC#a0ufn+BxbGLr!!zaJ>CM3FFFNp9FMJh*>;w+ooBI6x{eds+TY(vU zF0b@Ycs991WiGP&J%)t(HVPkq09m)*gPUCFYtFa6Q07Kxux2t zjWVxjEXj@1!lvoN{E?Yhw#Eo-tx3iE!?3;Ry$Y8f)j(igeh0XV{`njadD{d4EM!W+VRE&g1)4fP~nR8dI9ziPwJb%yS`j9wTm2B4zcgyMfcgbjOSb2^Sb3NVg=N1W5~%A=NEhv(6$ zeuwAbd4@Q>o8D_%j<^2Z;o0UE_gI_L`OtZuad`GzP~BsRGtTUCNO<_BtueR9F;>I; z4F_$ymvkz)Zxq19@Ij5cUPuEhW);nMCVxvbTOl!j3pFO92ZeAS9g(FqISn@uZj*%B zJ)P!(!BoK#vVP%Y%PE>218U6)!r_KOb0w}Tlwf~1m(FUgR9dD9H<-;b1h25STp^oQ zQHs`N)FNmu^l%ox4z1g;AsdKGIl6jx|8tTpD)Z$I_>(D9d! zh4jlAn!Chvsd3j}-scjqQu@g=K>Ib(_gueUJEbe=Y561QLJd)V4#h6ZFwCF(2*dmx z`CHF_5cdPZe#rfV`>^>Z%^x!#Pu!jQWa{qgpS*r=;#X6@och(NU!J;e`+iHEQOyd7Vn*T_S{(*GG9`7u`g_dl_2a@iS;4#5A2MR8wD%nQkj+2lJ=U@pm`keQE7LB94}Hanl@ zhf~tndr5v#7P1+r4d}HaX@SqkP-S082$>40DDjItABDjZAQy8W6D*OfS36`|Bgw*C znuu&?t&loc8>CLw4ylWEK+3RANZqVU_RJ93%Q9Ug;#Rt+c}ZsZh3rj!I45Tp60(p? zq|-|yBA<}?)UYJ+vXs9}Xf5%c z5Ytv1i#{_XuAK?33f@qw_b{sCJ|nN?GA=DQtDlc}tyX8e<0jJ2S~R(Y`Fgd!Fm|Js zH{Nj*NiSQiU+YO_684F;X|ld(nXwqM1=O3hzQ#LKrR7;W%q5_g98l8N#X6x>qqn)B zq_1?gN2@QJRlQaRqaM`q#(TC`%V+cztI~r~DlPKK<&ngq!13=T78cXI6cwpBafxzy z;*zRBaS19>Q7TyD0-sJzFD>$8ncPB!sB1#={0}g8g{U7=MSae0Fbjm;GUv!RwP{wg zxmfF*YnL{LwaqcRw7FUPoO_ox59^ro7;W@md07`6FCWXuemGuLtQ(G(M;YJC@ruG3 zySx}ZozBA1lU|Ra)~Mu<;=Me1DLyt58;^~RDz3|u=O?GGOe)O8*zo9u*yNbv9UZ$g z!p7p$v8hR==Hl?==XL95fv$5#!)pv1r1mNy$72 zRV$g*4=GD#EIv>(M{L@WxM7(^dh{{AhqYT3OV4sgPgsz`nXDX6gl{I&LMmLju82)> z-AG6YSr!%MJijz1idj){&GRx=0S&vvr>`rvcladKa%UhXi35~X1M^gs4&2Bt z@B`wl8w2zFf{+mgBr#cP>QAJkevr-n6n~RXXBT;~ALLJv=yv~t(lWLiPJ6jn;D_))t%**BTK#yb{Yrfq zqon1auZgaXIy8~#B9AST5vO7Y-iacrL=p9xi0Vv4EheJ!dTbHVzK{?|4v45$6^7&R z6JYn$UyR)Yc~l~Q!BOtg~wzv62Pfu5(_*`)x&WM*;Fo#c^}8Un@gliEdh?ZE{Kwx7BYM$ z%W)#^2IYrh$1Y}E5fK%{PDqpnjsrOcxaRPz2-k8T!Z1b+$0gGsUX%umrU6(Rm?Q(VAC;NONR!GsR78q*0kkKu z0IeuxkQQD7MCxHrq&bNfZwg6XX;420BufbM8NIp&&pK=*kk)#7u|_XQLW<}3>(}|D ztT4HZAfrM78xFou9E%A#c|&p07cVLf*f&vDn$^kSazGFO2{{>{kmCUhm8wcTsdzP3 za6$?S+Vr4MNBIHkkg_>3$y2dYJO(`xmA4W|CKlzK$fvk$PF~E(icMOQ6sIJopd7K% zqegc@RkLALI2;t1l-|9cwlEAPeGLgHt(NZJ4Xn4ETCuM)!HqMquf3RiZDW7*v$rv~ z@5#{*fWvpl|HiQZdk$gksuI-l|Dw-9@i3b$e$vnSCS2zR4H+2_4 zhqa>Ogxc*-7a%5Uspw^*bi0UwqMBhwF|=n_qnz68iS(tC+y+KUGEu6C*hDx&L_CZM zj#%1c#a9wpDkvpTrMoSnYor|C(_Zg54KwhqgfqWQir%KxmeF3dqt);=%<(Gj z)(PnujoskQkuD;(Lqo(N4#Nvow|YQ8H=l(H@g&yTK_@MWVaQfslguZ?(d?~^(!VDT zYTf6M4!Hk#U>bt=uim?Q*LK%aWPBg`KJXQoP@V~W!?f(=5TZI2#oKoL)54D|s6&=lR#D1;Uh&&x-SfUgqQJr|7<>?LCTw#OK0=j<1) zQ8HuA=zC~B6X>BEq^uQ6x-D`gZ0fNEw@m?xWwU6}T46NZ`_byCwWQOps5RjPP(TA= z+XJ;IbK9Pw(i%FD-+Sa;ymK27D8ko!o}Xs?(=qiOH)xtkhUu7A)~5OYlvbBPL(9$X z*K#vfGe2Cj6|d?p2~aZad6gV0^OP5Duws+(W^C~T+K9&c&y(f~KADeY;zh%}Om=?{ zv`nNU-fOHu%K?3po)z@brrY+!2ewr}QP&`NZV^AWJrH}a8+2I*0G0Cxk7l4q13!2N zGy_E%7>5{vOXT0pfjP9?qXwE%;ltqyn=D)k4un(syOI6 zMU)cp3{pYzbfOBQeiq9B2%-O@_^Fi$d{~6p@rtihqzqNu`otBdYS>ArI1Ys2QBAm3 zv_m-zC2~?OndBwudM>S5y~^+&v1)e`2^da#3YY@;73}=PyW-#I?ES3ep?}4{&a`b> z$+G2~wZOcVXI?8XLY@)UnVVpc1ws$%3!z9p6j=@SKI;2CRX9GLKR&*C>>L;in>OP1 z7npr{W?z9B%rk>vHf}K_*a>ciueh&!#kbCM6uWu~T}Sg>N3j$x_8h`@_mjggjA~1E z=9$iQCi;}|6@AqOUu)jix=W@z@9T!952rqu+VVQxp3UlQ;}@8TJTswx{UB9nkLKH> zTQ)0ncGAwq0$&i~K5PA+rp5X&F6OLti(prVcE#!ay6(Yjp=~JNHnbKx_W8T(%q!5f zP~VrY?|U@-ul3OtH<$qGN{YUQg0DUAYcEy>U&6F4hpl3S2cb_pKJIwbUFbcV?>)QD zjOYX(gbJble5k(=I+70^`F!-tsV}D1nb9XT4TYNh`I`NOnrOZzx>hr|;zT>d|ABwC z@r|zKu8!izmdqM5KvSKM~ z7&xpK|7n*n)L_t!1LM25Oaa_XZYoR$(|~ro&!DU2DnJjz5!)WtR}OlxCbK^NJ@lz6 z@56e#NSO2yJ>Cj(*h&Pr-fJTr^xuV*S8Z)Xus}@7&yKx$dF;|OH^#D4?6RK*OT#IM zl?rJ|@lZui{eFPlQUCzMNKmR%!vTnO9G~VFAT%ODaMiT{aFCei6$ZnprJE=x#N@If z;2@<}LT-pUMC^)#HYs+*I>lAON5y0G5#zu$@l8l3mq%F@^uvgT;n--nXB)wuFjC-C zY6lEu<)m;=#Tt8-yVONe8l~BWdny**6J4&K%7p0(Pld~vPznkm$ikGfDPFvcv%?du zy2jI(!+qao9f;`6Dn1p6*@PhRz}-tr5=45(-VuOtmH##1wShj^FKcMHIi)Xg3ETZMbSFT^%YT(}3@)S+#4Z zK#IPc$Xy&aRgU4swnK>D%^MBvAHBWeDfSF~82BIn__}2!OP2H2Vr}b2P4h=B8})5Z z>)VRq0Ra7t!>7JVuOGg&Iyeo$xUYH3;b>^ww7G({TQ+w?)n+{jMz&h2mMjp-sWb?3 zo8dp*AOJ9}sxA22^Zxc;B$H*EW3B#p?P(bvR!g-X(J|Uwsgo?g#D#0POu$ zFKnc$Z?jfM<_EQ(wtn1N2=(Sey^n^$fGF;ci$xz>^wk>3t77G=f0zL{)uHpyqwC)= z$6j>8Ffh#@BnTFfuS3Ju@fOEFw|K|<+!fJ!ggOeSXki1OLQ#G88u^&T$N+sbl?L~J zB^aarph?8u7{!R7umo--NnF40)Tk0NtNXSu0T$~lng;X%Dkd-pwfe23)|1Y!-Y97~ z=xgSCTgpHLf^2Y43!KA;f`|zuzGXGLRWDVV4OlkYpd+aNyNiM6Ffl@1|{en(=+RkqxV_b z;&8M)tFk&;pD__f%<^o;a?o-7#k8f~F|)1d1ZctKkf*kX5f^T2w5L0+vVBEas>s zAW;iNG4G*XYl-OND}&|t*L(S!3wm4?1`UQ0IPU5`(2eZ^dMaPld)CwHjpgg&-$4V| z1w{HKB%3yi#qy$^SS)`-e(w1<;(kJGcbK0scLF~P{L1pHBll0-J8}Pwdv82E^4k-? zIq}=mzd8N6^-I?mt}g>$1ioU{24jCbxz;tkR(E-w%=|kEd`G&zBTfH7PJKsC{Fjqh y&RVvd?^rEEk4_f)PUrhhZxJkQ!o`T)x3au$3l;6YI~V`@;xDGYw-LK~Y4E=z_a@;0 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 97ea7783ec50b596c4bca557ae85df933fe41987..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7780 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 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 From d5eb124ef5cafa8cc8021e0abb33b4d984b608d2 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Thu, 19 Feb 2026 15:39:32 -0500 Subject: [PATCH 55/81] Added support for service account. --- SERVICE_ACCOUNT.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 SERVICE_ACCOUNT.md 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. From 1d9bf1863c1d20073b40ef8586ecb462105fc29c Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 21:13:55 +0000 Subject: [PATCH 56/81] Additional rules to context for new errors . --- GEMINI.md | 5 +++-- conversions/GEMINI.md | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index a518f6b..9636b89 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -32,7 +32,7 @@ This document outlines mandatory operational guidelines, constraints, and best p 1. **SEARCH (VERBATIM):** You **MUST** use the `google_web_search` tool with the following query string **VERBATIM**. **DO NOT** modify, rephrase, or substitute this query. - **Query:** `google ads api release notes` 2. **FETCH:** From the search results, identify the official "Release Notes" page on `developers.google.com` and fetch its content using the `web_fetch` tool. -3. **EXTRACT:** From the fetched content, identify the most recently announced stable version (e.g., "vXX is now available"). +3. **EXTRACT:** From the fetched content, identify the most recently announced MAJOR stable version (e.g., "vXX is now available"). 4. **CONFIRM:** You must state the version you found and the source URL, then ask for confirmation. For example: "Based on the release notes at [URL], the latest stable Google Ads API version appears to be vXX. Is it OK to proceed?". 5. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. 6. **REJECT/RETRY:** If the user rejects the version, repeat step 1. @@ -103,7 +103,7 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali - Exists in the confirmed API version (to avoid `UNRECOGNIZED_FIELD`). - Matches the exact case-sensitive name provided by the service. - Has the correct metadata attributes: `selectable = true` for `SELECT`, `filterable = true` for `WHERE`, and `sortable = true` for `ORDER BY`. - - **Syntax for Field Service:** Metadata queries MUST NOT include a `FROM` clause, and fields MUST NOT be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). Metadata queries MUST NOT use parentheses `()` or complex boolean logic. + - **Syntax for Field Service:** Metadata queries MUST NOT include a `FROM` clause, and fields MUST NOT be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). Metadata queries MUST NOT use parentheses `()` or complex boolean logic. Failure to follow this syntax results in `query_error: UNRECOGNIZED_FIELD` with a message identifying the prefixed fields as unrecognized. - This discovery MUST be performed for every resource queried for the first time in a session. 2. **Contextual & Mutual Compatibility (CRITICAL):** Do not assume that a filterable field is filterable in all contexts. You MUST: @@ -215,6 +215,7 @@ Before generating or executing ANY GAQL query, you MUST follow this workflow wit #### 3.4.3. Python One-Liner Constraints (CRITICAL) - When executing Python code via `run_shell_command` using the `-c` flag, you MUST keep the script extremely simple. +- **CONFIGURATION PATH MANDATE:** You MUST explicitly set the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable within the shell command before the `python3 -c` call to ensure it uses the correct configuration file (e.g., `GOOGLE_ADS_CONFIGURATION_FILE_PATH=config/google-ads.yaml python3 -c "..."`). You MUST NOT allow `load_from_storage()` to default to the `$HOME` directory. - You MUST NOT use `for` loops, `if` statements, or complex multi-line logic in a one-liner. - You MUST NOT use `f-strings` in a one-liner that contain nested quotes that could break the shell command's quoting. - For any operation requiring iteration, conditional logic, or complex setup, you MUST write the code to a temporary file and execute the file. diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index e4dc3c2..ce3043c 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -306,6 +306,7 @@ This document provides a technical reference for troubleshooting conversion-rela 3. **Metadata Query Syntax (CRITICAL):** When querying metadata resources (like `google_ads_field`) via services like `GoogleAdsFieldService`, you **MUST NOT** include a `FROM` clause in your GAQL query. Including a `FROM` clause will result in a `query_error: UNEXPECTED_FROM_CLAUSE`. Filter by `name` or other attributes in the `WHERE` clause instead. - **CORRECT:** `SELECT name, selectable WHERE name = 'campaign.id'` - **INCORRECT:** `SELECT name, selectable FROM google_ads_field WHERE name = 'campaign.id'` +4. **Referenced Conversion Action Rule (CRITICAL):** If you use `segments.conversion_action` in the `WHERE` clause to filter metrics, you MUST also include `segments.conversion_action` in the `SELECT` clause. Failure to do so will result in the error: `The following field must be present in SELECT clause: 'segments.conversion_action'`. ### 4. Troubleshooting Workflow From be4125f30df356934b97ac608e136ac86627d513 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 16:18:30 -0500 Subject: [PATCH 57/81] Removed startup debug messages. --- .gemini/hooks/custom_config.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index c2e3479..3d0e15a 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -28,6 +28,8 @@ def parse_ruby_config(path): "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: @@ -49,6 +51,8 @@ def parse_ini_config(path): "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: @@ -70,6 +74,8 @@ def parse_properties_config(path): "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: @@ -86,12 +92,20 @@ def parse_properties_config(path): 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") - 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 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") @@ -112,9 +126,6 @@ def configure_language(lang_name, home_config, target_config, version, is_python sep = ":" if is_python else "=" f.write(f"\ngaada{sep} \"{version}\"\n") - if is_python: - print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{target_config}\"", file=sys.stdout) - return True except Exception as e: print(f"Error configuring {lang_name}: {e}", file=sys.stderr) @@ -135,7 +146,7 @@ def main(): # 1. Try Python YAML first if configure_language("Python", python_home, python_target, version, is_python=True): - print("Configured Python using ~/google-ads.yaml") + print("Configured Python") else: # 2. Try fallbacks fallbacks = [ @@ -167,7 +178,7 @@ def main(): with open(settings_path, "r") as f: settings = json.load(f) include_dirs = settings.get("context", {}).get("includeDirectories", []) - except: + except Exception: include_dirs = [] languages = [ From e5d852d0e048535fc869d3d84aab9c24a09d9f29 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 16:49:35 -0500 Subject: [PATCH 58/81] Removed matcher from hooks. --- .gemini/settings.json | 27 +++++++++------------------ README.md | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 44b5370..e9c38d2 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -3,7 +3,8 @@ "accessibility": { "disableLoadingPhrases": true, "enableLoadingPhrases": false - } + }, + "loadingPhrases": "off" }, "general": { "checkpointing": { @@ -30,27 +31,17 @@ "hooks": { "SessionStart": [ { - "matcher": "startup", - "hooks": [ - { - "name": "init", - "type": "command", - "command": "python3 .gemini/hooks/custom_config.py" - } - ] + "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" - } - ] + "name": "cleanup", + "type": "command", + "command": "python3 .gemini/hooks/cleanup_config.py" } ] } -} +} \ No newline at end of file diff --git a/README.md b/README.md index 2888dc5..bdc5ec9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,40 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the * **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 @@ -45,7 +79,7 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the 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 From a17ea98956e20560dd2ed985012a5c36a7210a6c Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 16:54:27 -0500 Subject: [PATCH 59/81] Debugging hooks --- .gemini/hooks/custom_config.py | 12 ++++++------ .gemini/settings.json | 27 ++++++++++++++++++--------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py index 3d0e15a..d40b813 100644 --- a/.gemini/hooks/custom_config.py +++ b/.gemini/hooks/custom_config.py @@ -172,6 +172,12 @@ def main(): 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: @@ -181,12 +187,6 @@ def main(): except Exception: include_dirs = [] - 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")}, - ] - for lang in languages: if any(lang["id"] in d for d in include_dirs): target = os.path.join(config_dir, lang["filename"]) diff --git a/.gemini/settings.json b/.gemini/settings.json index e9c38d2..44b5370 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -3,8 +3,7 @@ "accessibility": { "disableLoadingPhrases": true, "enableLoadingPhrases": false - }, - "loadingPhrases": "off" + } }, "general": { "checkpointing": { @@ -31,17 +30,27 @@ "hooks": { "SessionStart": [ { - "name": "init", - "type": "command", - "command": "python3 .gemini/hooks/custom_config.py" + "matcher": "startup", + "hooks": [ + { + "name": "init", + "type": "command", + "command": "python3 .gemini/hooks/custom_config.py" + } + ] } ], "SessionEnd": [ { - "name": "cleanup", - "type": "command", - "command": "python3 .gemini/hooks/cleanup_config.py" + "matcher": "exit", + "hooks": [ + { + "name": "cleanup", + "type": "command", + "command": "python3 .gemini/hooks/cleanup_config.py" + } + ] } ] } -} \ No newline at end of file +} From 0bda05bb1d0d1e7a8f4021fa877f9c74340f9f2a Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 17:03:13 -0500 Subject: [PATCH 60/81] Restore hook structure --- .gemini/settings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gemini/settings.json b/.gemini/settings.json index 44b5370..dc11f1e 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -17,11 +17,11 @@ }, "context": { "includeDirectories": [ - "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/api_examples", - "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/saved/code", - "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/client_libs/google-ads-python", - "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/client_libs/google-ads-php", - "/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/client_libs/google-ads-ruby" + "/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": { From 997fcb22bbb74234e1ccbd43f1591d635f7bff22 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 17:34:01 -0500 Subject: [PATCH 61/81] Deduplication logic in cleanup because of system bug --- .gemini/hooks/cleanup_config.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py index 458db45..5228b61 100644 --- a/.gemini/hooks/cleanup_config.py +++ b/.gemini/hooks/cleanup_config.py @@ -2,8 +2,22 @@ import shutil import sys import datetime +import time def cleanup(): + # Deduplication Guard: Prevent running twice in quick succession (e.g. < 5s) + lock_file = os.path.join(os.path.expanduser("~"), ".gaada_cleanup.lock") + if os.path.exists(lock_file): + if time.time() - os.path.getmtime(lock_file) < 5: + return + + # Update/Create lock file + try: + with open(lock_file, "w") as f: + f.write(str(os.getpid())) + except Exception: + pass + # Determine paths script_dir = os.path.dirname(os.path.abspath(__file__)) # .gemini/hooks/ -> project root is 2 levels up From 2419e89df38072f45a456740e18fa9e9f16da743 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 17:37:31 -0500 Subject: [PATCH 62/81] Atomic locking in cleanup_config.py --- .gemini/hooks/cleanup_config.py | 51 +++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py index 5228b61..81233f7 100644 --- a/.gemini/hooks/cleanup_config.py +++ b/.gemini/hooks/cleanup_config.py @@ -5,24 +5,51 @@ import time def cleanup(): - # Deduplication Guard: Prevent running twice in quick succession (e.g. < 5s) + # 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") + log_file = os.path.join(project_root, ".gemini/hooks/execution.log") lock_file = os.path.join(os.path.expanduser("~"), ".gaada_cleanup.lock") - if os.path.exists(lock_file): - if time.time() - os.path.getmtime(lock_file) < 5: - return - # Update/Create lock file + pid = os.getpid() + ppid = os.getppid() + now = datetime.datetime.now().isoformat() + + # Diagnostic Logging try: - with open(lock_file, "w") as f: - f.write(str(os.getpid())) + with open(log_file, "a") as f: + f.write(f"[{now}] PID: {pid}, PPID: {ppid} - Triggered cleanup\n") except Exception: pass - # 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") + # Atomic Deduplication Guard + try: + # os.O_EXCL fails if the file already exists (atomic operation) + fd = os.open(lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + with os.fdopen(fd, 'w') as f: + f.write(str(pid)) + except FileExistsError: + # Check if the lock is stale (> 5 seconds) + try: + mtime = os.path.getmtime(lock_file) + if time.time() - mtime < 5: + with open(log_file, "a") as f: + f.write(f"[{now}] PID: {pid} - Exiting (Lock exists and is fresh)\n") + return + else: + # Stale lock, try to take it by deleting and recreating + os.unlink(lock_file) + # Recurse once to try again after clearing stale lock + return cleanup() + except Exception: + return + except Exception as e: + with open(log_file, "a") as f: + f.write(f"[{now}] PID: {pid} - Lock error: {e}\n") + # Proceed if lock check fails (fallback) + pass if not os.path.exists(config_dir): print(f"Config directory {config_dir} does not exist. Nothing to clean.", file=sys.stderr) From a23d104ce6be3970f3a328913762472ccd3f2b05 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Fri, 20 Feb 2026 17:39:41 -0500 Subject: [PATCH 63/81] Removed locking since it does not work --- .gemini/hooks/cleanup_config.py | 41 --------------------------------- 1 file changed, 41 deletions(-) diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py index 81233f7..458db45 100644 --- a/.gemini/hooks/cleanup_config.py +++ b/.gemini/hooks/cleanup_config.py @@ -2,7 +2,6 @@ import shutil import sys import datetime -import time def cleanup(): # Determine paths @@ -10,46 +9,6 @@ def cleanup(): # .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") - log_file = os.path.join(project_root, ".gemini/hooks/execution.log") - lock_file = os.path.join(os.path.expanduser("~"), ".gaada_cleanup.lock") - - pid = os.getpid() - ppid = os.getppid() - now = datetime.datetime.now().isoformat() - - # Diagnostic Logging - try: - with open(log_file, "a") as f: - f.write(f"[{now}] PID: {pid}, PPID: {ppid} - Triggered cleanup\n") - except Exception: - pass - - # Atomic Deduplication Guard - try: - # os.O_EXCL fails if the file already exists (atomic operation) - fd = os.open(lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - with os.fdopen(fd, 'w') as f: - f.write(str(pid)) - except FileExistsError: - # Check if the lock is stale (> 5 seconds) - try: - mtime = os.path.getmtime(lock_file) - if time.time() - mtime < 5: - with open(log_file, "a") as f: - f.write(f"[{now}] PID: {pid} - Exiting (Lock exists and is fresh)\n") - return - else: - # Stale lock, try to take it by deleting and recreating - os.unlink(lock_file) - # Recurse once to try again after clearing stale lock - return cleanup() - except Exception: - return - except Exception as e: - with open(log_file, "a") as f: - f.write(f"[{now}] PID: {pid} - Lock error: {e}\n") - # Proceed if lock check fails (fallback) - pass if not os.path.exists(config_dir): print(f"Config directory {config_dir} does not exist. Nothing to clean.", file=sys.stderr) From 8c7488db34c540799418a8bd10b53105f7e5b92f Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Mon, 23 Feb 2026 08:47:45 -0500 Subject: [PATCH 64/81] Added to quirks that the exit hook executes twice. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bdc5ec9..83f7a30 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ 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. 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 will execute `cleanup_config.py` twice to remove the temporary configuration files. This is a known problem that does not affect performance. + ## Maintenance We will periodically release updates to both this extension and the client libraries. From 290f0a49e996a1f07ad8b43f555026f8923b2ce1 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 08:46:41 -0500 Subject: [PATCH 65/81] README refinements. --- README.md | 2 +- README_BEFORE_INSTALLATION.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 83f7a30..4c67f1d 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ 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. 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 will execute `cleanup_config.py` twice to remove the temporary configuration files. This is a known problem that does not affect performance. +* 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 diff --git a/README_BEFORE_INSTALLATION.md b/README_BEFORE_INSTALLATION.md index 2e8b15b..80d12d4 100644 --- a/README_BEFORE_INSTALLATION.md +++ b/README_BEFORE_INSTALLATION.md @@ -4,5 +4,5 @@ v2.0.0 is a major release of the Google Ads API Developer Assistant with breakin * 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 again. (See README.md for installation instructions.) +* 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 From 7de2366df1ace8ac3eba0f2767da3faa6e76cede Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 13:54:12 +0000 Subject: [PATCH 66/81] Added rules to GEMINI.md --- GEMINI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GEMINI.md b/GEMINI.md index 9636b89..18d480a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -103,7 +103,7 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali - Exists in the confirmed API version (to avoid `UNRECOGNIZED_FIELD`). - Matches the exact case-sensitive name provided by the service. - Has the correct metadata attributes: `selectable = true` for `SELECT`, `filterable = true` for `WHERE`, and `sortable = true` for `ORDER BY`. - - **Syntax for Field Service:** Metadata queries MUST NOT include a `FROM` clause, and fields MUST NOT be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). Metadata queries MUST NOT use parentheses `()` or complex boolean logic. Failure to follow this syntax results in `query_error: UNRECOGNIZED_FIELD` with a message identifying the prefixed fields as unrecognized. + - **Syntax for Field Service:** Metadata queries MUST NOT include a `FROM` clause, and fields MUST NOT be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). Metadata queries MUST NOT use parentheses `()` or complex boolean logic. Commonly used valid fields include `name`, `category`, `selectable`, `filterable`, `sortable`, `type_url`, and `enum_values`. You **MUST NOT** include `description`, as it is not a valid field for this service. Failure to follow this syntax results in `query_error: UNRECOGNIZED_FIELD` with a message identifying the unrecognized or prefixed fields. - This discovery MUST be performed for every resource queried for the first time in a session. 2. **Contextual & Mutual Compatibility (CRITICAL):** Do not assume that a filterable field is filterable in all contexts. You MUST: From f26ea4cf538c7b49959bdf87a25e9c41241ff948 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 14:41:46 +0000 Subject: [PATCH 67/81] conversions/GEMINI.md optimized for AI machine comprehension --- conversions/GEMINI.md | 388 +++++++----------------------------------- 1 file changed, 62 insertions(+), 326 deletions(-) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index ce3043c..28439d2 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -1,341 +1,77 @@ # Google Ads API Conversion Troubleshooting -This document provides a technical reference for troubleshooting conversion-related issues in the Google Ads API, based on the official documentation. +## Metadata +- **Version:** 2.0 +- **Target API:** Google Ads API (v23) +- **Role:** Technical Reference for AI Assistant +- **Optimized for:** Machine Comprehension --- -### 1. Core Concepts +### 1. Core Directives [MANDATORY] * **API Response != Attribution**: A successful API import response (no errors) means the data was received, but it does **not** guarantee the conversion will be attributed to an ad. -* **Offline Diagnostics**: Always prioritize offline diagnostics for import health. The Google Ads UI is not organized by import date, which can make it difficult to diagnose recent issues. +* **Offline Diagnostics Priority**: Always prioritize offline diagnostics for import health. The Google Ads UI is not organized by import date, which can make it difficult to diagnose recent issues. +* **Mandatory Diagnostic Workflow**: For ALL conversion-related troubleshooting, the AI MUST execute the workflow defined in Section 4. -### 2. Common Error Codes +### 2. Common Error Codes & Resolution Strategies #### 2.1. Enhanced Conversions for Leads * `NO_CONVERSION_ACTION_FOUND`: The conversion action is disabled or inaccessible. - * **Disabled**: The action's status is set to REMOVED or HIDDEN. This usually happens if someone "deleted" the action in the Google Ads UI, making it inactive for new uploads. - * **Inaccessible**: The API cannot "see" the action from the specific customer_id you are using. This occurs if you have a typo in the ID, or if the action belongs to a different account (like a Manager/MCC account) and hasn't been shared with the client account performing the upload. - -* `INVALID_CONVERSION_ACTION_TYPE`: Must use `UPLOAD_CLICKS` for enhanced conversions for leads. - * **Context**: This error happens when you try to upload Enhanced Conversions for Leads to a conversion action that was created as a standard "Webpage" conversion (the kind that uses a tag on your site) instead of an "Import" conversion. - * **Fix Step 1 (Create Correct Action Type)**: You cannot "convert" an existing Webpage action into an Upload action. You must: - * Go to the Google Ads UI. - * Create a New Conversion Action. - * Select Import as the source. - * Select Manual import using API or uploads and specifically choose Track conversions from clicks. - * This ensures the type is set to `UPLOAD_CLICKS`. - * **Fix Step 2 (Update API Code)**: Once the new action is created, grab its ID and update your script: - * Ensure you are using the `UploadClickConversions` method. - * Ensure the `conversion_action` resource name in your code points to the new ID. - * Verify that you are including the required user identifiers (like hashed email or phone number) in the `UserIdentifier` field of the request. - * **Summary**: The API is telling you, "You're trying to upload lead data to a 'Tag' action, but I only accept that data for an 'Import' action." - -* `CUSTOMER_NOT_ENABLED_ENHANCED_CONVERSIONS_FOR_LEADS`: The setting is disabled in the account. To address this error, you must enable the setting in the Google Ads UI and accept the required terms. This cannot be done entirely via the API because it requires a manual legal agreement. - * **Step 1: Accept Customer Data Terms (Manual)** - 1. Log in to your Google Ads account. - 2. Click the Goals icon. - 3. Click the Conversions dropdown and select Settings. - 4. Expand the Customer data terms section. - 5. Read and Accept the terms if you haven't already. This is the most common reason for this error. - * **Step 2: Enable the Setting in the UI** - 1. While still in Goals > Conversions > Settings, look for the Enhanced conversions for leads section. - 2. Check the box to Turn on enhanced conversions for leads. - 3. Choose your method (usually "Google Ads API" or "Global site tag"). - 4. Click Save. - * **Step 3: Verify via API (Autonomous Action by Gemini)** - * If you want to check if the setting is correctly enabled using code, Gemini can execute this query against the `customer` resource for you: - ```sql - SELECT - customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled, - customer.conversion_tracking_setting.accepted_customer_data_terms - FROM customer - ``` - * **Note**: After enabling the setting, it can sometimes take a few minutes for the API to recognize the change. If you still see the error immediately after saving, wait about 15–30 minutes and try your upload again. - -* `DUPLICATE_ORDER_ID`: Multiple conversions sent with the same Order ID in the same batch. - * **1. The "Quick Fix": De-duplicate your Batch** - Before you call `UploadClickConversions`, add a step in your code to filter your list. If you have a list of conversions, use a set or a dictionary in Python to ensure you only keep one entry per unique `order_id`. - ```python - # Simple Python de-duplication example - unique_conversions = {} - for conv in my_conversions: - unique_conversions[conv.order_id] = conv - - # Now only upload the values of the dictionary - final_batch = list(unique_conversions.values()) - ``` - * **2. The "Logic Fix": Why are there duplicates?** - Check your data source (database or CRM) to see why two records have the same ID. - * *User Refresh*: Did the user refresh their "Thank You" page? If so, your system might be recording the same sale twice. - * *Loop Error*: Check your code for nested loops that might be appending the same conversion to your upload list multiple times. - * **3. The "Batching" Rule** - Google allows you to send up to 2,000 conversions in a single batch. If you have a very large number of conversions, make sure you aren't accidentally including the same records when you move from "Batch 1" to "Batch 2." - * **4. Important Distinction** - * *Same Batch*: This error only triggers if the duplicates are in the same request. - * *Different Batches*: If you send an Order ID now, and then send it again tomorrow in a different request, you might get a different error (`CONVERSION_ALREADY_EXISTS`) or Google might just silently ignore the second one depending on your settings. - * **Summary**: Clean your list before you send it! Google expects every `order_id` in a single API request to be unique. - -* `CLICK_NOT_FOUND`: No click matched the provided user identifiers. (Treat as a warning unless frequent). - * **Context**: The `CLICK_NOT_FOUND` error means Google couldn't find an ad click that matches the user information (GCLID, Email, or Phone) you provided. If this happens occasionally, it's often just a "ghost" click or an invalid identifier, but if it happens frequently, here is how to fix it: - * **1. Respect the "24-Hour Rule"** - * Google needs time to process ad clicks before they can be matched to a conversion. - * *The Fix*: Wait at least 24 hours after the click happens before you try to upload the conversion. If you try to upload a conversion 5 minutes after the click, Google will often return `CLICK_NOT_FOUND`. - * **2. Check Data Normalization (CRITICAL)** - * If you are using Enhanced Conversions (email or phone), the data must be perfectly clean before it is hashed. - * *The Fix*: - 1. Trim: Remove all leading/trailing spaces. - 2. Lowercase: Convert all characters to lowercase. - 3. Hash: Use SHA-256 hashing. - * Example: `User@Example.com` must become `user@example.com` before it is hashed. - * **3. Verify GCLID Ownership (Autonomous Action by Gemini)** - * The GCLID you are uploading must belong to the exact account you are sending the data to. - * *The Fix*: Gemini can automatically query the `click_view` resource to verify if the GCLID is valid for your `customer_id`: - ```sql - SELECT click_view.gclid - FROM click_view - WHERE click_view.gclid = 'YOUR_GCLID_HERE' - ``` - If this query returns no results, that GCLID doesn't exist in your account. - * **4. Lookback Window** - * The click might be too old. - * *The Fix*: Check the `click_through_lookback_window_days` for the conversion action. If the click happened 31 days ago but your window is 30 days, Google will treat it as if the click was never found. - * **5. Proper Identifier Usage** - * *The Fix*: Do not mix identifiers. If you have a GCLID, use it. If you are using Enhanced Conversions, provide the `UserIdentifier`. Do not try to "invent" IDs or send dummy data, as this will trigger the error. - * **Summary**: If this is happening to 100% of your uploads, it is almost certainly a hashing/normalization issue or you are uploading too quickly after the click. If it's only happening to 1-2%, it is usually normal behavior (e.g., bot clicks or invalid IDs) and can be ignored. - + * **Root Cause (Disabled)**: Status is REMOVED or HIDDEN. + * **Root Cause (Inaccessible)**: Typo in `customer_id` or action belongs to a different account (e.g., MCC) and isn't shared. +* `INVALID_CONVERSION_ACTION_TYPE`: Must use `UPLOAD_CLICKS`. + * **Pitfall**: Happens when uploading to a "Tag" action. MUST create an "Import" action via UI. +* `CUSTOMER_NOT_ENABLED_ENHANCED_CONVERSIONS_FOR_LEADS`: Setting disabled in UI. + * **Mandatory Verification**: Query `customer` resource for `enhanced_conversions_for_leads_enabled` and `accepted_customer_data_terms`. +* `DUPLICATE_ORDER_ID`: Multiple conversions with same Order ID in one batch. + * **Resolution**: De-duplicate the batch in code before calling `UploadClickConversions`. +* `CLICK_NOT_FOUND`: No click matched user identifiers. + * **Critical Verification**: Wait 24 hours (Processing Time). Check hashing/normalization (Trim, Lowercase, SHA-256). Verify GCLID ownership via `click_view`. #### 2.2. Enhanced Conversions for Web -* `CONVERSION_NOT_FOUND`: Could not find a conversion matching the supplied action and ID/Order ID pair. - * **Context**: The `CONVERSION_NOT_FOUND` error occurs when you try to adjust a conversion that Google has no record of. Think of it like trying to "Edit" a document that was never saved. To fix this, check these four areas: - * **1. Timing: The "Processing Gap"** - * Google needs time to "finalize" a conversion before you can enhance or adjust it. - * *The Fix*: Wait at least 24 hours after the original conversion was uploaded (or tracked via the tag) before sending an adjustment or enhancement. If you send them too close together, the adjustment will arrive before the original conversion is "found." - * **2. Matching Identifiers (Order ID vs. GCLID)** - * You are likely using the wrong "Key" to find the conversion. - * *The Fix*: Ensure your `order_id` (also called Transaction ID) and the `conversion_action` ID match exactly what was sent in the original upload. - * *Example*: If the original was sent with `order_id`: "SALE123", you cannot adjust it using `order_id`: "sale123" (it is case-sensitive). - * **3. Account Ownership** - * The adjustment must be sent to the same account that received the original conversion. - * *The Fix*: If your original conversion was tracked in Account A, but you are sending the adjustment to Account B (even if they are in the same MCC), Google won't find it. - * **4. Conversion Action Consistency** - * The adjustment must point to the exact same conversion action. - * *The Fix*: If you tracked a sale under the action "Website Purchase," you cannot send an enhancement to "Lead Form Submit" and expect it to find the original sale. - * **Summary Checklist**: - 1. Did I wait 24 hours? - 2. Is the `order_id` exactly the same (case-sensitive)? - 3. Am I using the correct `conversion_action` ID? - 4. Am I in the correct Google Ads account? - -* `CUSTOMER_NOT_ACCEPTED_CUSTOMER_DATA_TERMS`: Customer data terms must be accepted in the UI. - * **Context**: To address this error, you must accept the required terms in the Google Ads UI. This cannot be done entirely via the API because it requires a manual legal agreement. - * **Why this happens**: This error triggers when you try to use Enhanced Conversions (sending hashed emails or phone numbers). Because you are sending "First-Party Data," Google requires you to legally agree to their data privacy and security policies before they will process the information. - * **Step 1: The Fix (Manual Action)**: - 1. Log in to your Google Ads account. - 2. Click the Goals icon (trophy icon) in the left-hand navigation menu. - 3. Navigate to Conversions > Settings. - 4. Find and expand the section labeled Customer data terms. - 5. Click the Review and Accept button. - * Note: You must have "Admin" or "Standard" access level to see and accept these terms. - 6. Click Save at the bottom of the page. - * **Step 2: How to verify it's fixed (Autonomous Action by Gemini)**: - * You can run a quick check via the API to confirm the terms are now "Accepted": - ```sql - SELECT - customer.conversion_tracking_setting.accepted_customer_data_terms - FROM customer - ``` - -* `CONVERSION_ALREADY_ENHANCED`: An adjustment for this Order ID and action has already been processed. - * **Context**: Google allows you to enhance a conversion (adding email, phone, or address) only once. If you try to update a record that has already been successfully enhanced, you will receive this error. - * **1. Check for Duplicate Submissions (The "Only One" Rule)** - * *The Fix*: Check your system to see if you are accidentally sending the same enhancement twice. This often happens if your CRM "updates" a record and your script thinks that means it needs to send the data to Google again. - * **2. Implement Status Tracking** - * Your internal database needs to know which conversions have already been successfully enhanced. - * *The Fix*: In your database, add a column named `google_ads_enhanced_at`. Once you get a SUCCESS response from the API, update this column. Before sending new data, filter your query to: `WHERE google_ads_enhanced_at IS NULL`. - * **3. Handle "Partial Success" Gracefully** - * If you send a batch of 10 enhancements and 5 of them were already enhanced, the API might return this error for those specific 5 while processing the others. - * *The Fix*: Inspect the `GoogleAdsException` to see exactly which indices failed. You can safely ignore the `CONVERSION_ALREADY_ENHANCED` errors—it just means the data is already there! - * **4. Wait for the Next Sale for New Information** - * If you have new information for a customer who already bought something once, you cannot "add" that info to their old sale if it was already enhanced. - * *The Fix*: Wait until that customer makes a new purchase with a new Order ID. You can then enhance that new transaction with the updated information. - * **Summary**: This error is usually a sign that your automation is too aggressive. It's trying to update records that Google already has all the information for. Simply filtering your data to only send "First-Time Enhancements" will solve the problem. - - -* `CONVERSION_ACTION_NOT_ELIGIBLE_FOR_ENHANCEMENT`: This action type cannot be enhanced. - * **Context**: This error means you are trying to use Enhanced Conversions (uploading hashed user data like emails) on a conversion action that doesn't support it. Only certain types of actions allow for this kind of "data boost." - * **1. Check the "Source" of the Action** - * You can only use Enhanced Conversions on actions where Google can actually "see" the customer on your website. - * *The Fix*: Ensure the conversion action was created as a Website conversion. - * Eligible: WEBPAGE (tracked via the Google Tag). - * Not Eligible: IMPORT (standard offline conversions using GCLID only), PHONE_CALL, or STORE_VISIT. - * **2. Verify the "Enhanced Conversions" Toggle in the UI** - * Even if it's a Website action, you must explicitly "opt-in" for each specific action. - * *The Fix*: - 1. Go to the Google Ads UI > Goals > Conversions. - 2. Click on the specific Conversion Action name. - 3. Expand the Enhanced conversions section. - 4. Check the box to Turn on enhanced conversions. - 5. Select your method (API) and Save. - * **3. API Query Check (Autonomous Action by Gemini)** - * You can check which of your actions are actually eligible using the API. Look for the type and status: - ```sql - SELECT - conversion_action.name, - conversion_action.type, - conversion_action.status - FROM conversion_action - WHERE conversion_action.type != 'WEBPAGE' - ``` - * If you are trying to enhance any of the results from this query, they will fail with this error. - * **4. The "Import" Confusion** - * If you are doing Offline Conversions (uploading sales from your CRM), you don't "Enhance" them. You just "Upload" them. - * *The Fix*: If you are using `UploadClickConversions`, just send the data. You only use the Enhancement or Adjustment methods if you are trying to add data to a conversion that was originally tracked by a website tag. - * **Summary**: To fix this, make sure you are only "enhancing" Webpage-based conversion actions and that you have turned on the toggle in the settings for that specific action. - - -#### 2.3. General Issues -* `TOO_RECENT_CONVERSION_ACTION`: Wait at least 6 hours after creating a new conversion action before uploading conversions to it. - * **Context**: The `TOO_RECENT_CONVERSION_ACTION` error is a simple matter of timing. When you create a new conversion action in Google Ads, the system needs time to propagate that new ID across all its servers. - * **The Fix**: Wait 6 to 24 hours. There is no way to bypass this with code or API settings. Google’s infrastructure requires a few hours to "wake up" and recognize that the new conversion action is ready to accept data. - * **Best Practices**: - 1. **Plan Ahead**: If you are launching a new campaign on Monday, create the conversion action on Friday or Saturday. - 2. **Test Later**: Don't try to run a "Test Upload" 5 minutes after clicking "Save" in the UI. - 3. **Error Handling**: If your script gets this error, you can build a simple "Retry" logic: - ```python - if "TOO_RECENT_CONVERSION_ACTION" in error_message: - print("Conversion action is too new. Sleeping for 6 hours...") - # Add logic to move this record to a queue for later - ``` - * **Summary**: If you see this error, don't panic and don't change your code. Your code is likely correct; you just need to give the Google Ads system a few more hours to "settle in." - -* `EXPIRED_EVENT`: The click occurred before the conversion action's `click_through_lookback_window_days`. - * **Context**: To fix the `EXPIRED_EVENT` error, you need to align your upload data with the "Lookback Window" set for your conversion action. This error means the ad click happened so long ago that Google has already "closed the book" on it. - * **1. Increase the Lookback Window (The Easiest Fix)** - * If you are regularly uploading conversions that happen 45 days after the click, but your window is set to 30 days, they will all fail. - * *The Action*: Go to the Google Ads UI > Goals > Conversions. Click the action and edit the Click-through conversion window. - * *Maximum*: You can set this up to 90 days. If your window is already at 90 days and you're still getting this error, it means your sales cycle is too long for Google Ads to track. - * **2. Filter your Data Source (The Technical Fix)** - * Your script should automatically skip records that are too old to be accepted. This saves you from getting "failed" alerts and keeps your reports clean. - * *The Action*: In your SQL query or Python script, calculate the "age" of the click and only include it if it's within the window. - ```python - from datetime import datetime, timedelta - - # If your window is 90 days - lookback_limit = datetime.now() - timedelta(days=90) - - # Only process if click_date is newer than the limit - if click_date > lookback_limit: - upload_conversion(click_id) - else: - print(f"Skipping {click_id}: Click is too old.") - ``` - * **3. Check for Timezone Discrepancies** - * Sometimes a click might be right on the edge of the window. - * *The Action*: Ensure you are using the correct timezone offset in your upload. If you send the time without an offset (e.g., -05:00), Google might assume it's UTC, which could push a "Day 90" click into "Day 91." - * **4. Verify the "Creation" of the GCLID** - * Sometimes the GCLID itself is just old data from your CRM that got "stuck." - * *The Action*: Check your CRM to see if you are accidentally re-processing old leads from months ago. - * **Summary**: - * *Quick Fix*: Set the window to 90 days in the UI. - * *Long-term Fix*: Update your code to skip clicks that are older than your lookback window. - -* `CONVERSION_PRECEDES_EVENT`: The conversion timestamp is before the click timestamp. - * **Context**: The `CONVERSION_PRECEDES_EVENT` error is a logical impossibility. Google is saying: "You're telling me this person bought the item at 1:00 PM, but they didn't even click the ad until 1:30 PM." Here is how to fix this data integrity issue: - * **1. Audit your Timestamp Logic (The "Why")** - * This almost always comes down to how you are pulling dates from your database. - * *The Problem*: You might be comparing the Lead Creation Date (Conversion) with the Click Date, but your Lead Creation Date is being recorded in a different timezone than the Click Date. - * *The Fix*: Ensure all timestamps are converted to the same timezone (preferably UTC) before you compare them in your script. - * **2. Check for System Clock Desync** - * If your web server (which records the click) and your CRM server (which records the sale) have clocks that are even 5 minutes apart, you can trigger this error for fast conversions. - * *The Fix*: Use a standard time synchronization service (like NTP) for all your servers. - * **3. Implement a "Safety Guard" in Code** - * Add a simple check to your script to ensure the conversion happened after the click. If it didn't, don't even try to upload it. - ```python - # Simple Python check - if conversion_time <= click_time: - print(f"ERROR: Conversion ({conversion_time}) happened before click ({click_time}). Skipping.") - # You might want to log this to investigate why the data is corrupt - continue - ``` - * **4. Human Error in Manual Uploads** - * If you are manually entering dates into a CSV or spreadsheet: - * *The Fix*: Double-check for typos (e.g., entering 10:00 instead of 22:00 for a late-night conversion). - * **Summary**: To fix this, you must ensure your conversion date is always later than your click date. Usually, this means fixing a timezone mismatch or a clock synchronization issue between your website and your CRM. - -* `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: The same conversion is repeated in a single batch. - * **Context**: The `DUPLICATE_CLICK_CONVERSION_IN_REQUEST` error is very similar to the "Duplicate Order ID" error, but it refers to the Click ID (GCLID) itself. It means you have included the exact same GCLID and Conversion Action combination more than once in the same API request. Here is how to fix it: - * **1. De-duplicate your Batch (The Code Fix)** - * Before sending your list of conversions to the API, you must ensure that each (GCLID, ConversionAction) pair is unique within that specific batch. - ```python - # Use a set to track unique combinations of (GCLID, ActionID) - unique_batch = [] - seen_combinations = set() - - for conversion in my_list: - combo = (conversion.gclid, conversion.conversion_action) - if combo not in seen_combinations: - unique_batch.append(conversion) - seen_combinations.add(combo) - - # Now send 'unique_batch' to the API - ``` - * **2. Identify the "Loop Hole" in your Code** - * This error usually happens because of a logic bug in how you are building your upload list: - * *Duplicate Database Rows*: Your SQL query might be returning two rows for the same click if it’s joined with multiple products or sessions. - * *Nested Loops*: You might be accidentally appending the same conversion object to your list inside a loop. - * **3. Difference from DUPLICATE_ORDER_ID** - * `DUPLICATE_ORDER_ID`: You sent two different clicks but gave them the same transaction ID. - * `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: You sent the exact same click twice. Even if they have different Order IDs, Google won't allow the same click to convert twice in a single request. - * **4. Handling Partial Failures** - * If you are using `partial_failure = True` in your API request, the rest of your conversions will still process, but you will see this error for the duplicate entries. You can safely ignore these errors if you know your data source occasionally has duplicates, but it's better to clean the data first to save on API overhead. - * **Summary**: To fix this, filter your list of conversions to ensure that no GCLID appears more than once for the same conversion action in a single API call. - -### 3. Verification - -1. **GCLID Ownership**: Query the `click_view` resource to verify if a GCLID belongs to the specific customer account. -2. **Customer Terms**: Check `customer.conversion_tracking_setting.accepted_customer_data_terms` via the `customer` resource. -3. **Data Normalization**: Ensure email addresses, phone numbers, and names are correctly normalized (trimmed, lowercased) and hashed (SHA-256) before sending. -4. **Consent**: Verify that `ClickConversion.consent` is properly set in the upload if required by regional policies. -5. **Logical Time Verification**: Before uploading any conversion, you MUST verify that the `conversion_date_time` is logically valid: - * **Normalization**: Ensure both click and conversion timestamps are in the same timezone (preferably UTC) before comparing. - * **No Pre-Click Conversions**: The conversion timestamp MUST be strictly after the click timestamp to avoid `CONVERSION_PRECEDES_EVENT`. - * **Lookback Window**: The click MUST have occurred within the `click_through_lookback_window_days` defined for the conversion action to avoid `EXPIRED_EVENT`. - -### 3.1 Rigorous GAQL Validation for Conversions -1. **No OR Operator (CRITICAL)**: GAQL does not support the `OR` operator in the `WHERE` clause. You **MUST** perform multiple separate queries or filter results in code to achieve "OR" logic. -2. **Conversion Metric Incompatibility (CRITICAL):** The `metrics.conversions` field is incompatible with the `conversion_action` resource in the `FROM` clause. To retrieve conversion metrics segmented by conversion action, you MUST use a compatible resource such as `customer`, `campaign`, or `ad_group` in the `FROM` clause and include `segments.conversion_action` in the `SELECT` clause. Any attempt to use `FROM conversion_action` with `metrics.conversions` will result in a `PROHIBITED_METRIC_IN_SELECT_OR_WHERE_CLAUSE` error. -3. **Metadata Query Syntax (CRITICAL):** When querying metadata resources (like `google_ads_field`) via services like `GoogleAdsFieldService`, you **MUST NOT** include a `FROM` clause in your GAQL query. Including a `FROM` clause will result in a `query_error: UNEXPECTED_FROM_CLAUSE`. Filter by `name` or other attributes in the `WHERE` clause instead. - - **CORRECT:** `SELECT name, selectable WHERE name = 'campaign.id'` - - **INCORRECT:** `SELECT name, selectable FROM google_ads_field WHERE name = 'campaign.id'` -4. **Referenced Conversion Action Rule (CRITICAL):** If you use `segments.conversion_action` in the `WHERE` clause to filter metrics, you MUST also include `segments.conversion_action` in the `SELECT` clause. Failure to do so will result in the error: `The following field must be present in SELECT clause: 'segments.conversion_action'`. - -### 4. Troubleshooting Workflow - -1. **MANDATORY FIRST STEP: Diagnostic Summaries**: Before investigating specific errors or identifiers, you **MUST** execute queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. These resources provide the most accurate view of recent import health and systemic failures. - - **Attribute Pitfall (CRITICAL)**: When processing the `daily_summaries` list (which contains `OfflineConversionSummary` objects), the fields are `successful_count` and `failed_count`. You **MUST NOT** use `success_count` or `total_count`, as these will trigger an `AttributeError`. Calculate the total as the sum of successful and failed counts if needed. -2. **Check API Error Details**: Inspect the `GoogleAdsException` for specific `ErrorCode` and `message`. -3. **Verify Timestamps**: Ensure `conversion_date_time` is in `yyyy-mm-dd hh:mm:ss+|-hh:mm` format and falls within the lookback window. -4. **Validate Identifiers**: For `CLICK_NOT_FOUND`, ensure you are not mixing `gclid` with `gbraid` or `wbraid` inappropriately. Use only one per conversion. -5. **Wait for Processing**: Conversions can take up to 3 hours to appear in reporting after a successful upload. -6. **Check Conversion Settings**: Ensure the conversion action's `status` is `ENABLED` and it is configured for the correct `type`. - -#### 4.1. General 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. - -### 6. Structured Diagnostic Reporting -When providing a final diagnostic summary to the user, you MUST follow this structured format to ensure maximum technical clarity: - -1. **Introductory Analysis Statement**: Start with "I have analyzed the data for Customer ID XXX-XXX-XXXX [and Job ID/Action ID if applicable], and I have identified the primary reason why [describe the core issue]..." -2. **Numbered Technical Findings**: Provide detailed, numbered sections for each key factor (e.g., "1. 'Include in Conversions' Setting", "2. Job Status and Processing"). -3. **Specific Observations**: Use bullet points within findings to highlight data-backed observations (e.g., success rates, metric discrepancies, or attribute settings). -4. **Actionable Recommendations**: Conclude with a "Recommendations" section listing specific steps for the user or partner. -5. **Handling Empty Diagnostic Sections (CRITICAL)**: If the automated diagnostic report (e.g., from `collect_conversions_troubleshooting_data.py`) contains empty sections for "[2] Conversion Actions" or "[3] Offline Conversion Upload Summaries", you MUST perform a manual update to the report file (using `write_file` or `replace`) to append the reason why those sections are blank: - - **Empty Conversion Actions**: Append: "Reason: No non-removed conversion actions found for this Customer ID." - - **Empty Upload Summaries**: Append: "Reason: These summaries only track standard offline imports (GCLID/Call). No such imports have been detected in the last 90 days. Note that Store Sales (managed via Offline User Data Jobs) are not reflected in these specific summaries." - - This explanation MUST appear within the file itself before you present it to the user. +* `CONVERSION_NOT_FOUND`: Missing original conversion for enhancement. + * **Critical Verification**: Wait 24 hours. Ensure `order_id` matches exactly (case-sensitive). +* `CUSTOMER_NOT_ACCEPTED_CUSTOMER_DATA_TERMS`: Terms must be accepted in UI. +* `CONVERSION_ALREADY_ENHANCED`: Conversion already has user data. + * **Pitfall**: Only one enhancement allowed per conversion. +* `CONVERSION_ACTION_NOT_ELIGIBLE_FOR_ENHANCEMENT`: Action type must be `WEBPAGE`. + +#### 2.3. General Logic Errors +* `TOO_RECENT_CONVERSION_ACTION`: Wait 6-24 hours after action creation. +* `EXPIRED_EVENT`: Click is outside the `click_through_lookback_window_days`. +* `CONVERSION_PRECEDES_EVENT`: [CRITICAL] Conversion timestamp is before click timestamp. +* `DUPLICATE_CLICK_CONVERSION_IN_REQUEST`: Same (GCLID, Action) pair repeated in batch. + +### 3. Rigorous GAQL Validation for Conversions [CRITICAL] + +1. **NO 'OR' OPERATOR**: GAQL does NOT support `OR` in `WHERE`. Use `IN` or separate queries. +2. **Conversion Metric Incompatibility**: `metrics.conversions` is INCOMPATIBLE with `FROM conversion_action`. + * **Mandatory Fix**: Use `FROM customer`, `campaign`, or `ad_group` and `SELECT segments.conversion_action`. +3. **Metadata Query Syntax**: `GoogleAdsFieldService` queries MUST NOT include a `FROM` clause. + * **Correct**: `SELECT name, selectable WHERE name = 'campaign.id'` +4. **Referenced Action Rule**: If `segments.conversion_action` is in `WHERE`, it MUST be in `SELECT`. +5. **Logical Time Verification**: Before upload, AI MUST verify: + * `conversion_date_time` > `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`. +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. --- -### 7. References -- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` +### 6. References +- **Official Docs**: `https://developers.google.com/google-ads/api/docs/conversions/` +- **GAQL Structure**: `https://developers.google.com/google-ads/api/docs/query/` From 80f0a7266d4c0be372ceb77f18aa46cb45ef8a34 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 15:49:56 +0000 Subject: [PATCH 68/81] Modified rules to consolidate support package files. --- GEMINI.md | 378 ++++++++++++++---------------------------- conversions/GEMINI.md | 4 + 2 files changed, 125 insertions(+), 257 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 18d480a..a6316e3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,45 +1,38 @@ # 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.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. Core Directives [MANDATORY] -#### 1.1. Identity -- **Role:** Google Ads API Developer Assistant -- **Language:** English -- **Persona:** Technical, Precise, Collaborative, Security-conscious +#### 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.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.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.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. +#### 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. **SEARCH (VERBATIM):** You **MUST** use the `google_web_search` tool with the following query string **VERBATIM**. **DO NOT** modify, rephrase, or substitute this query. - - **Query:** `google ads api release notes` -2. **FETCH:** From the search results, identify the official "Release Notes" page on `developers.google.com` and fetch its content using the `web_fetch` tool. -3. **EXTRACT:** From the fetched content, identify the most recently announced MAJOR stable version (e.g., "vXX is now available"). -4. **CONFIRM:** You must state the version you found and the source URL, then ask for confirmation. For example: "Based on the release notes at [URL], the latest stable Google Ads API version appears to be vXX. Is it OK to proceed?". -5. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. -6. **REJECT/RETRY:** If the user rejects the version, repeat step 1. -7. **SESSION PERSISTENCE:** Once the latest stable version has been confirmed by the user within a specific session, you MUST NOT repeat the validation workflow for subsequent tasks in that same session. -8. **NEVER** save the confirmed API version to memory. +#### 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 FOLLOW THIS IS A CRITICAL ERROR.** +**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. @@ -51,242 +44,113 @@ If the `web_fetch` tool is unavailable and you cannot complete the standard vali 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. 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. +#### 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. --- -### 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:** The `DURING` clause in a GAQL query only accepts a limited set of predefined date constants (e.g., `LAST_7_DAYS`, `LAST_30_DAYS`). You MUST NOT invent constants like `LAST_33_DAYS`. For any non-standard time period, you MUST dynamically calculate the `start_date` and `end_date` and use the `BETWEEN 'YYYY-MM-DD' AND 'YYYY-MM-DD'` format. -- **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. **MANDATORY SCHEMA & METADATA DISCOVERY (CRITICAL):** You are strictly prohibited from relying on internal memory. Before constructing ANY query, you MUST use `GoogleAdsFieldService.search_google_ads_fields` to verify that every field in your `SELECT`, `WHERE`, and `ORDER BY` clauses: - - Exists in the confirmed API version (to avoid `UNRECOGNIZED_FIELD`). - - Matches the exact case-sensitive name provided by the service. - - Has the correct metadata attributes: `selectable = true` for `SELECT`, `filterable = true` for `WHERE`, and `sortable = true` for `ORDER BY`. - - **Syntax for Field Service:** Metadata queries MUST NOT include a `FROM` clause, and fields MUST NOT be prefixed with `google_ads_field.` (e.g., use `SELECT name, selectable`, NOT `SELECT google_ads_field.name`). Metadata queries MUST NOT use parentheses `()` or complex boolean logic. Commonly used valid fields include `name`, `category`, `selectable`, `filterable`, `sortable`, `type_url`, and `enum_values`. You **MUST NOT** include `description`, as it is not a valid field for this service. Failure to follow this syntax results in `query_error: UNRECOGNIZED_FIELD` with a message identifying the unrecognized or prefixed fields. - - This discovery MUST be performed for every resource queried for the first time in a session. - -2. **Contextual & Mutual Compatibility (CRITICAL):** Do not assume that a filterable field is filterable in all contexts. You MUST: - - Examine the `selectable_with` attribute of the main resource in the `FROM` clause and verify that every other field in the query (in `SELECT`, `WHERE`, or `ORDER BY`) is included in its `selectable_with` list. - - **MANDATORY TOOL CALL:** You MUST physically see the `selectable_with` list via a tool call before presenting any query to the user. - -3. **Referenced Field Rule (CRITICAL):** You MUST verify that any field used in the `WHERE` clause is also present in the `SELECT` clause (except for core date segments). Failure to do so results in `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. - -4. **Prioritize Validator Errors:** If the user provides an error message from a GAQL query validator, treat it as the source of truth and immediately re-evaluate your validation. - -5. **Core Date Segment Requirement (CRITICAL):** If a core date segment (`segments.date`, etc.) is present in the `SELECT` or `WHERE` clause, you MUST include a **finite** date range filter in the `WHERE` clause using `DURING` (with valid constants) or `BETWEEN`. Single-sided operators (`>=`, `<=`) are strictly prohibited. - -6. **Policy-Summary Field Rules:** You **MUST NOT** select sub-fields of `ad_group_ad.policy_summary` (e.g., `approval_status`). The **ONLY** valid way to retrieve policy info is to select `ad_group_ad.policy_summary.policy_topic_entries` and iterate through the results in code. - -7. **PROHIBITED 'OR' OPERATOR (CRITICAL):** GAQL does NOT support the `OR` operator in the `WHERE` clause for any service. You MUST use the `IN` operator (if for the same field) or execute multiple separate queries and combine results in code. Failure results in `unexpected input OR`. - -8. **Enum Value Verification (CRITICAL):** If you receive `BAD_ENUM_CONSTANT`, you MUST query the field's `enum_values` attribute in `GoogleAdsFieldService` to retrieve the valid string constants for the confirmed version. - -9. **Change Status Constraints (CRITICAL):** Queries for the `change_status` resource MUST: - - Include a finite date range filter on `change_status.last_change_date_time` using `BETWEEN` with both start and end boundaries. - - Specify a `LIMIT` clause (maximum 10,000). - -10. **Single Day Filter for Click View (CRITICAL):** Queries for the `click_view` resource MUST include a filter limiting results to a single day (`WHERE segments.date = 'YYYY-MM-DD'`). - -11. **Change Event Resource Selection (CRITICAL):** You **MUST NOT** select sub-fields of `change_event.new_resource` or `change_event.old_resource`. Select the top-level fields and perform extraction in code. - -12. **Repeated Field Selection Constraint (CRITICAL):** You MUST NOT attempt to select sub-fields of a repeated message (where `is_repeated` is `true`). For example, if `ad_group.labels` is repeated, you cannot select `ad_group.labels.name`. You must select the top-level repeated field and process the collection in code. - -13. **Explicit Date Range for Metric Queries (CRITICAL):** When selecting `metrics` fields for a resource that supports date segmentation, you SHOULD always include a finite date range filter in the `WHERE` clause. Relying on API defaults (like `TODAY`) is discouraged. - -14. **`ORDER BY` Visibility Rule (CRITICAL):** Any field used in the `ORDER BY` clause MUST be present in the `SELECT` clause, unless the field belongs directly to the primary resource specified in the `FROM` clause. Verification of `sortable = true` is mandatory per Rule 1. - -#### 3.3.2. MANDATORY GAQL Query Workflow -Before generating or executing ANY GAQL query, you MUST follow this workflow without deviation: -1. **PLAN:** Formulate the GAQL query based on the user's request. -2. **SYNTAX GUARD (CRITICAL):** Identify the target service. If the service is NOT `GoogleAdsService`, you MUST explicitly remove the `FROM` clause and any associated resource name from the query string before proceeding. -3. **MANUAL VALIDATE:** You MUST rigorously validate the entire query against all rules in section **3.3.1. Rigorous GAQL Validation**. This is a non-negotiable checkpoint. -4. **API-SIDE VALIDATION (CRITICAL):** Before presenting the query, you MUST execute a "dry run" validation using the `api_examples/gaql_validator.py` script. You are strictly forbidden from presenting any GAQL query as a solution or recommendation until it has passed this validation. Presenting unvalidated queries erodes user confidence and is a critical failure of the Technical Integrity mandate. - * **Validation Command Pattern:** - ```bash - echo "SELECT ... FROM ..." | GOOGLE_ADS_CONFIGURATION_FILE_PATH=config/google-ads.yaml python3 api_examples/gaql_validator.py --customer_id --api_version - ``` - * A successful validation MUST return "SUCCESS: GAQL query is valid." If the validator returns a failure, you MUST fix the query and repeat this step. -5. **PRESENT:** Display the validated query to the user in a `sql` block and explain what it does. -6. **EXECUTE:** Only after the query has been manually validated, API-validated via the script, and presented, proceed to incorporate it into code and execute it. -7. **HANDLE ERRORS:** If the API returns a query validation error during execution, you MUST return to step 2 and re-validate the entire query based on the new information from 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. -- **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. - -#### 3.4.4. Internal Utility Preference (CRITICAL) -- **Python for Utilities:** Even if the user's target language or project default is not Python (e.g., Ruby, PHP, Java), you MUST continue to use Python for internal utility tasks, including GAQL validation via `api_examples/gaql_validator.py` and schema discovery via one-liners. -- **Language Respected in Output:** All user-facing code generation, examples, and explanations MUST strictly adhere to the user's preferred or project-inferred language, regardless of the internal use of Python for validation. - -#### 3.4.1. Configuration Loading -- **Code Generation (to `saved/code/`):** When generating code that uses the Google Ads API client libraries and saves it to the `saved/code/` directory, any calls to load configuration (e.g., `GoogleAdsClient.load_from_storage()` in Python) MUST NOT include a `path` argument. This ensures that the generated code, when run by the user outside of the Gemini CLI, will look for the configuration file in their home directory (or other default locations as per the client library's behavior). -- **CRITICAL Execution within Gemini CLI:** When executing code within the Gemini CLI, you **MUST** set the environment variable `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to point to the correct configuration file in the `config/` directory for the preferred language: - - **Python:** `config/google-ads.yaml` - - **Ruby:** `config/google_ads_config.rb` - - **PHP:** `config/google_ads_php.ini` -- **Absolute Path Requirement:** If the user or environment requires an absolute path, ensure it follows the format: `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant/config/`. -- **NEVER** use configuration files located within `client_libs/`. This ensures the script uses the project's configuration file located at `config/` during execution within the CLI environment. -- **User Instructions:** When providing commands or instructions to a user for running a script, you MUST NOT include the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable. This variable is strictly for internal use by the assistant when executing scripts within the Gemini CLI. User-facing instructions should assume the user has configured their credentials in the standard default location (e.g., their home directory). -- **Error Handling:** When using the Python client library, you **MUST** handle exceptions by catching `GoogleAdsException` as `ex`. The `ex` object contains the high-level, structured Google Ads failure details in the `ex.failure` attribute. To access the detailed list of errors, you **MUST** iterate over `ex.failure.errors`. **NEVER** attempt to access `ex.error.errors`, as `ex.error` is the underlying gRPC call object and does not have this attribute, which will cause an `AttributeError`. A correct error handling loop looks like this: - ```python - try: - # ... Google Ads API call - 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}'") - - ``` - -- **Suppress Tracebacks (CRITICAL):** When executing any Python code (including one-liners via `python3 -c`), you **MUST** wrap Google Ads API calls in a `try...except GoogleAdsException` block. You **MUST** print only the structured error details (Request ID, Error Code, and Error Messages). You **MUST NOT** allow the exception to bubble up unhandled, as the library's internal gRPC interceptors will trigger noisy "During handling of the above exception, another exception occurred" tracebacks. - - For other languages, use the equivalent exception type and inspect its structure. - -#### 3.4.2. Safe Attribute and Method Access (CRITICAL) -- **Favor GoogleAdsService for Retrieval:** Most resource-specific `get_` methods (e.g., `get_campaign`, `get_conversion_action`) are deprecated or removed in modern API versions. You **MUST** always use `GoogleAdsService.search` or `GoogleAdsService.search_stream` to retrieve individual resources by filtering on their resource name or ID. -- **Query Result Processing Pitfall:** When processing results from a `search` or `search_stream` call, if a field is a message type (e.g., `row.resource.nested_message` or a repeated field like `alerts`), you **MUST NOT** assume the attribute names of that nested message. You MUST verify the attribute names by executing a one-liner to inspect an instance of the message using `dir()` or `instance.pb.DESCRIPTOR.fields_by_name` before writing code that accesses those attributes. -- **Proto-plus Class Inspection Pitfall (CRITICAL):** When inspecting a Google Ads API message **class** (rather than an instance) to discover field names, you **MUST NOT** assume that `Class.pb.DESCRIPTOR` is accessible. In many versions of the library, `Class.pb` is a property or function, and accessing it directly on the class will result in an `AttributeError: 'function' object has no attribute 'DESCRIPTOR'`. Instead, you MUST use `Class.meta.pb.DESCRIPTOR` to access the underlying protobuf descriptor for discovery, or preferably, instantiate the class and use the instance inspection rules defined above. -- **Python Object Inspection Mandate (CRITICAL):** When encountering a Google Ads API object in Python for the first time, or if an attribute access fails, you MUST NOT guess its structure. You MUST execute a one-liner to perform a "deep inspection" that prints: 1) `type(instance)`, 2) `dir(instance)`, and 3) `str(instance)`. You MUST NOT use `.pb` or `.DESCRIPTOR` unless they are explicitly confirmed to exist in the `dir()` output. -- **Proto-plus Descriptor Pitfall:** When using the Python client library, objects returned from the API are typically proto-plus messages. These objects **DO NOT** have a top-level `DESCRIPTOR` attribute. If you need to access the descriptor (e.g., to see `fields_by_name`), you **MUST** first verify that the object has a `.pb` attribute using `dir()`. If it does, use `message_instance.pb.DESCRIPTOR`. If it does not, it may be a pure protobuf message (which has `DESCRIPTOR` but no `.pb`) or a specialized wrapper. -- **Nested Message Class Pitfall:** When using the Python client library, you **MUST NOT** assume that nested messages are accessible as class attributes of their parent message (e.g., `ParentMessage.NestedMessage`). Accessing them this way often leads to `AttributeError`. Instead, you MUST always use the `.pb` attribute of an instance to access the underlying protobuf message and its types, or use `dir(instance)` to discover the correct proto-plus attributes. -- **Attribute Verification for Nested Messages:** Do not assume attribute names for nested messages or repeated fields (e.g., fields inside `alerts` or `policy_summary`). You **MUST** verify the correct attribute names by: - 1. Querying `GoogleAdsFieldService` to find the `type_url` of the field. - 2. Using a one-liner script (e.g., `python3 -c "from google.ads.googleads.vXX.resources.types... import ...; print(dir(...))"`) to inspect the actual class attributes if you cannot find them in documentation. -- **Triple-Quote Safety:** When generating Python scripts that include multiline strings or SQL queries, you **MUST** use triple quotes (`"""`) and ensure there are no unescaped literal newlines within a single-quoted string to avoid `SyntaxError`. - -#### 3.4.3. Python One-Liner Constraints (CRITICAL) -- When executing Python code via `run_shell_command` using the `-c` flag, you MUST keep the script extremely simple. -- **CONFIGURATION PATH MANDATE:** You MUST explicitly set the `GOOGLE_ADS_CONFIGURATION_FILE_PATH` environment variable within the shell command before the `python3 -c` call to ensure it uses the correct configuration file (e.g., `GOOGLE_ADS_CONFIGURATION_FILE_PATH=config/google-ads.yaml python3 -c "..."`). You MUST NOT allow `load_from_storage()` to default to the `$HOME` directory. -- You MUST NOT use `for` loops, `if` statements, or complex multi-line logic in a one-liner. -- You MUST NOT use `f-strings` in a one-liner that contain nested quotes that could break the shell command's quoting. -- For any operation requiring iteration, conditional logic, or complex setup, you MUST write the code to a temporary file and execute the file. - -#### 3.5. Troubleshooting -- **Conversions:** - - **MANDATORY:** For ALL conversion-related troubleshooting, you MUST follow the workflow defined in `conversions/GEMINI.md`. The absolute first step is executing diagnostic queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. - - **Upload Validation:** When generating or executing conversion upload scripts, you MUST implement logical time checks (timestamp normalization and lookback window validation) as defined in `conversions/GEMINI.md`. - - 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. - -#### 3.7. Structured Reporting Mandate -When generating diagnostic reports or using automated troubleshooting scripts (e.g., 'collect_conversions_troubleshooting_data.py'): -1. **Manual File Post-Processing:** You MUST NOT assume that the script handles custom formatting. After the script executes, you MUST use `read_file` to verify the output and `write_file` to manually prepend: - - The mandatory header: "Created by the Google Ads API Developer Assistant". - - Any "Previous Diagnostic Analysis" found in the current session or in recent files within `saved/data/`. -2. **Verification Check:** You MUST confirm the final file content contains all requested elements before reporting completion to the user. +### 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. +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. --- -### 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 the user prompt or session context if available. If the session is already investigating a specific `customer_id`, you MUST NOT check `customer_id.txt` for that parameter. Only use `customer_id.txt` as a fallback if no ID is specified by the user and no active investigation CID exists in the session context. 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. +### 4. API Operations [PROCEDURAL] + +#### 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. + +#### 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. + +#### 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. --- -### 5. Output and Documentation +### 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. + +#### 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.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/` -- **Protos:** `https://github.com/googleapis/googleapis/tree/master/google/ads/googleads` +#### 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/conversions/GEMINI.md b/conversions/GEMINI.md index 28439d2..4410916 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -58,6 +58,8 @@ 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`. 2. **STEP 2: Exception Inspection**: Catch `GoogleAdsException` and iterate over `ex.failure.errors`. 3. **STEP 3: Identity & Consent**: Verify GCLID ownership and `consent` settings. @@ -70,6 +72,8 @@ The AI MUST format final reports as follows: 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. +**Consolidation Mandate**: All findings, including terminal summaries and data from external troubleshooting scripts, MUST be consolidated into a **single, uniquely named text file** in `saved/data/` (e.g., `support_package_.txt`). 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". + --- ### 6. References From b228784c201f2211ba0317b07d14e44ae72eda96 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 16:01:19 +0000 Subject: [PATCH 69/81] Optimized api_examples --- api_examples/ai_max_reports.py | 270 ++-------- ...ollect_conversions_troubleshooting_data.py | 187 +++---- api_examples/conversion_reports.py | 505 ++++-------------- api_examples/disapproved_ads_reports.py | 312 +---------- api_examples/gaql_validator.py | 87 ++- api_examples/get_campaign_bid_simulations.py | 122 +---- api_examples/get_campaign_shared_sets.py | 91 +--- api_examples/get_change_history.py | 140 +---- api_examples/get_conversion_upload_summary.py | 187 +------ api_examples/get_geo_targets.py | 147 ++--- api_examples/list_accessible_users.py | 60 +-- api_examples/list_pmax_campaigns.py | 94 +--- .../parallel_report_downloader_optimized.py | 226 ++------ .../remove_automatically_created_assets.py | 144 +---- .../target_campaign_with_user_list.py | 133 +---- api_examples/tests/test_conversion_reports.py | 343 +----------- api_examples/tests/test_gaql_validator.py | 100 +--- api_examples/tests/test_get_geo_targets.py | 200 +------ .../tests/test_list_accessible_users.py | 121 +---- ...st_parallel_report_downloader_optimized.py | 207 +------ 20 files changed, 608 insertions(+), 3068 deletions(-) diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index 952104c..91f293a 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -1,225 +1,63 @@ -# 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.v23.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=["campaign_details", "search_terms"], required=True) + args = parser.parse_args() + client = GoogleAdsClient.load_from_storage(version="v23") + main(client, args.customer_id, args.report_type) diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py index 2a49508..00f2b30 100644 --- a/api_examples/collect_conversions_troubleshooting_data.py +++ b/api_examples/collect_conversions_troubleshooting_data.py @@ -1,22 +1,11 @@ -# 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. - -"""Collects diagnostic data for conversion troubleshooting in a structured format.""" +# 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 sys import time from typing import Any, List @@ -25,157 +14,105 @@ def run_query(client: GoogleAdsClient, customer_id: str, query: str) -> List[Any]: - """Runs a GAQL query and returns the results.""" + """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) - results = [] - for batch in response: - for row in batch.results: - results.append(row) - return results + return [row for batch in response for row in batch.results] 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:" - ) + print(f"ERROR: Query failed (Request ID: {ex.request_id})") for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - sys.exit(1) + 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_filename = f"conversions_support_data_{epoch}.txt" - output_path = os.path.join(output_dir, output_filename) + output_path = os.path.join(output_dir, f"conversions_support_data_{epoch}.txt") summary = [] errors = [] - details = [] - - details.append(f"Diagnostic Report for Customer ID: {customer_id}") - details.append(f"Timestamp: {time.ctime()} (Epoch: {epoch})") - details.append("-" * 40) + details = [ + f"Diagnostic Report for Customer ID: {customer_id}", + f"Timestamp: {time.ctime()} (Epoch: {epoch})", + "-" * 40 + ] - # 1. Customer Settings - details.append("\n[1] Customer Settings") customer_query = """ SELECT - customer.id, customer.descriptive_name, customer.conversion_tracking_setting.accepted_customer_data_terms, customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled FROM customer """ - customer_results = run_query(client, customer_id, customer_query) - for row in customer_results: - settings = row.customer.conversion_tracking_setting - details.append(f"Customer Name: {row.customer.descriptive_name}") - details.append(f"EC for Leads Enabled: {settings.enhanced_conversions_for_leads_enabled}") - details.append(f"Customer Data Terms Accepted: {settings.accepted_customer_data_terms}") - - if not settings.accepted_customer_data_terms: + 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.") - if not settings.enhanced_conversions_for_leads_enabled: - summary.append("Note: Enhanced Conversions for Leads represents a potential growth area (currently disabled).") - # 2. Conversion Actions - details.append("\n[2] Conversion Actions") - ca_query = """ - SELECT - conversion_action.id, - conversion_action.name, - conversion_action.type, - conversion_action.status, - conversion_action.owner_customer - FROM conversion_action - WHERE conversion_action.status != 'REMOVED' - """ - ca_results = run_query(client, customer_id, ca_query) - upload_clks_found = False - for row in ca_results: - ca = row.conversion_action - details.append(f"- {ca.name} (ID: {ca.id}): Type={ca.type.name}, Status={ca.status.name}") - if ca.type.name == "UPLOAD_CLICKS": - upload_clks_found = True - - if not upload_clks_found: - errors.append("WARNING: No UPLOAD_CLICKS conversion actions found. Mandatory for offline imports.") - - # 3. Offline Conversion Upload Summaries - details.append("\n[3] Offline Conversion Upload Summaries") - - # Client Summary - client_summary_query = """ - SELECT - 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.last_upload_date_time, - offline_conversion_upload_client_summary.client - FROM offline_conversion_upload_client_summary - """ - client_summary_results = run_query(client, customer_id, client_summary_query) - for row in client_summary_results: - cs = row.offline_conversion_upload_client_summary - details.append( - f"Client: {cs.client.name}, Status={cs.status.name}, Last Upload={cs.last_upload_date_time}, " - f"Success={cs.successful_event_count}/{cs.total_event_count}" - ) - if cs.status.name == "NEEDS_ATTENTION": - errors.append(f"ISSUE: Client '{cs.client.name}' NEEDS_ATTENTION. Check upload logs.") - - # Action Summary - action_summary_query = """ + details.append("\n[2] Conversion Health (Last 7 Days)") + summary_query = """ SELECT - offline_conversion_upload_conversion_action_summary.status, + 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.last_upload_date_time, - offline_conversion_upload_conversion_action_summary.conversion_action_name + offline_conversion_upload_conversion_action_summary.daily_summaries FROM offline_conversion_upload_conversion_action_summary """ - action_summary_results = run_query(client, customer_id, action_summary_query) - for row in action_summary_results: - asum = row.offline_conversion_upload_conversion_action_summary - details.append( - f"Action: {asum.conversion_action_name}, Status={asum.status.name}, Last Upload={asum.last_upload_date_time}, " - f"Success={asum.successful_event_count}/{asum.total_event_count}" - ) - if asum.status.name == "NEEDS_ATTENTION": - errors.append(f"ISSUE: Action '{asum.conversion_action_name}' NEEDS_ATTENTION. High failure rate detected.") - - # Final Summary Construction - if not errors: - summary.insert(0, "Overall Status: HEALTHY. No major conversion configuration issues detected.") + results = run_query(client, customer_id, summary_query) + if not results: + details.append("No offline conversion summaries detected in last 90 days.") else: - summary.insert(0, f"Overall Status: UNHEALTHY. {len(errors)} potential issues identified.") + 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) - # Write the file 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) + "\n\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") - if not errors: - f.write("No errors detected.\n") - else: - f.write("\n".join(errors) + "\n") - f.write("\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"Troubleshooting report generated: {output_path}") + print(f"Consolidated troubleshooting report: {output_path}") if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Collects troubleshooting data for conversions.") - parser.add_argument("-c", "--customer_id", dest="customer_id", required=True, help="The Google Ads customer ID.") + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) args = parser.parse_args() - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - main(googleads_client, args.customer_id) diff --git a/api_examples/conversion_reports.py b/api_examples/conversion_reports.py index afed56f..b1896d6 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("--limit", type=int) args = parser.parse_args() - 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/disapproved_ads_reports.py b/api_examples/disapproved_ads_reports.py index 26911b8..26dbd10 100644 --- a/api_examples/disapproved_ads_reports.py +++ b/api_examples/disapproved_ads_reports.py @@ -1,299 +1,41 @@ -# 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 + 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 - 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. + WHERE ad_group_ad.policy_summary.approval_status = DISAPPROVED""" - 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 - 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}") - - -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.add_argument( - "-i", - "--campaign_id", - type=str, - help="Required for 'single' report type: The ID of the campaign to check.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-o", "--output", default="saved_csv/disapproved_ads.csv") args = parser.parse_args() - - googleads_client = GoogleAdsClient.load_from_storage(version="v23") - - main( - googleads_client, - args.customer_id, - args.report_type, - args.output_file, - args.campaign_id, - ) + client = GoogleAdsClient.load_from_storage(version="v23") + main(client, args.customer_id, args.output) diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py index 8a70fa1..acd3e9c 100644 --- a/api_examples/gaql_validator.py +++ b/api_examples/gaql_validator.py @@ -1,23 +1,61 @@ #!/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 +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 sys -import re 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 main(client=None, customer_id=None, api_version=None, query=None): + +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", required=True, help="API Version (e.g., v23).") + 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 @@ -25,9 +63,8 @@ def main(client=None, customer_id=None, api_version=None, query=None): # Read query from stdin to handle multiline/quoted strings safely query = sys.stdin.read().strip() - # Initialize client try: - client = GoogleAdsClient.load_from_storage() + 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) @@ -36,38 +73,36 @@ def main(client=None, customer_id=None, api_version=None, query=None): print("Error: No query provided.") sys.exit(1) - # Dynamically handle versioned types - api_version = api_version.lower() - module_path = f"google.ads.googleads.{api_version}.services.types.google_ads_service" + # 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) - SearchGoogleAdsRequest = getattr(module, "SearchGoogleAdsRequest") + search_request_type = getattr(module, "SearchGoogleAdsRequest") except (ImportError, AttributeError): - print(f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {api_version}.") + print( + f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {api_version}." + ) sys.exit(1) ga_service = client.get_service("GoogleAdsService") - customer_id = "".join(re.findall(r'\d+', str(customer_id))) + # Normalize customer_id to digits only + clean_customer_id = "".join(re.findall(r"\d+", str(customer_id))) try: - request = SearchGoogleAdsRequest( - customer_id=customer_id, - query=query, - validate_only=True + 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 valid.") + print("SUCCESS: GAQL query is structurally valid.") except GoogleAdsException as ex: - print(f"FAILURE: Query validation failed with Request ID {ex.request_id}") - for error in ex.failure.errors: - print(f" - {error.message}") - if error.location: - for element in error.location.field_path_elements: - print(f" On field: {element.field_name}") + 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..2b1520b 100644 --- a/api_examples/get_campaign_bid_simulations.py +++ b/api_examples/get_campaign_bid_simulations.py @@ -1,114 +1,40 @@ -# 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.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.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-i", "--campaign_id", required=True) args = parser.parse_args() - - main(googleads_client, args.customer_id, args.campaign_id) + client = GoogleAdsClient.load_from_storage(version="v23") + 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..19a75f6 100644 --- a/api_examples/get_campaign_shared_sets.py +++ b/api_examples/get_campaign_shared_sets.py @@ -1,95 +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. - -"""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.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) args = parser.parse_args() - main(google_ads_client, args.customer_id) + client = GoogleAdsClient.load_from_storage(version="v23") + main(client, args.customer_id) diff --git a/api_examples/get_change_history.py b/api_examples/get_change_history.py index 82ab741..057065a 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,31 @@ 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.add_argument( - "--end_date", - type=str, - help="End date for the change history (YYYY-MM-DD). Defaults to today.", - ) - + 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)") 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="v23") + 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 09fa947..962bdaf 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -1,178 +1,37 @@ -# 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.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) 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, "v23". - # 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="v23") + main(client, args.customer_id) diff --git a/api_examples/get_geo_targets.py b/api_examples/get_geo_targets.py index aae6969..181c784 100644 --- a/api_examples/get_geo_targets.py +++ b/api_examples/get_geo_targets.py @@ -1,128 +1,47 @@ -# 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.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) args = parser.parse_args() - - main(google_ads_client, args.customer_id) + client = GoogleAdsClient.load_from_storage(version="v23") + main(client, args.customer_id) diff --git a/api_examples/list_accessible_users.py b/api_examples/list_accessible_users.py index 65ecc2b..5e8228e 100644 --- a/api_examples/list_accessible_users.py +++ b/api_examples/list_accessible_users.py @@ -1,59 +1,19 @@ -# 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 the resource names for the customers accessible by the -current customer. -""" - -import sys +# Copyright 2026 Google LLC +"""Lists accessible customers with management context.""" 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. - - Args: - client: an initialized GoogleAdsClient instance. - """ +def main() -> None: + client = GoogleAdsClient.load_from_storage(version="v23") 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__": - # 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) + main() diff --git a/api_examples/list_pmax_campaigns.py b/api_examples/list_pmax_campaigns.py index 01e64d6..4897df2 100644 --- a/api_examples/list_pmax_campaigns.py +++ b/api_examples/list_pmax_campaigns.py @@ -1,96 +1,42 @@ -# 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.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) 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="v23") + 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..08d2b3e 100644 --- a/api_examples/parallel_report_downloader_optimized.py +++ b/api_examples/parallel_report_downloader_optimized.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,211 +12,91 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""This example downloads multiple reports in parallel.""" +"""Downloads multiple reports in parallel using structured logging.""" import argparse -from concurrent.futures import as_completed, ThreadPoolExecutor +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple 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") +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] [%(threadName)s] %(message)s", + datefmt="%H:%M:%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. - - 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}...") + """Fetches a single report with centralized exception handling.""" + ga_service = client.get_service("GoogleAdsService") + logger.info("[%s] Fetching for customer %s...", report_name, customer_id) rows = [] exception = None try: - stream = googleads_service.search_stream(customer_id=customer_id, query=query) + stream = ga_service.search_stream(customer_id=customer_id, query=query) 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("[%s] Completed. Found %d rows.", report_name, len(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}") + logger.error("[%s] Request ID %s failed: %s", report_name, ex.request_id, ex.error.code().name) exception = ex return report_name, rows, exception -def main(customer_ids: List[str], login_customer_id: Optional[str]) -> None: - """Main function to run multiple reports concurrently using threads. +def _get_date_range_strings() -> Tuple[str, str]: + """Helper for testing compatibility.""" + end = datetime.now() + start = end - timedelta(days=30) + return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") - 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") - if login_customer_id: - googleads_client.login_customer_id = login_customer_id +def main(customer_ids: List[str], login_id: Optional[str], workers: int = 5) -> None: + """Main execution loop for parallel report retrieval.""" + client = GoogleAdsClient.load_from_storage(version="v23") + if login_id: + client.login_customer_id = login_id - start_date_str, end_date_str = _get_date_range_strings() + start, end = _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 - """, - }, + report_defs = [ { - "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 - """, - }, - { - "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.") + results: Dict[str, Dict[str, Any]] = {} + with ThreadPoolExecutor(max_workers=workers) as executor: + future_to_name = { + executor.submit(fetch_report_threaded, client, cid, rd["query"], f"{rd['name']}_{cid}"): f"{rd['name']}_{cid}" + for cid in customer_ids for rd in report_defs + } + + for future in as_completed(future_to_name): + name = future_to_name[future] + _, rows, ex = future.result() + results[name] = {"rows": rows, "exception": ex} + + for name, data in results.items(): + if data["exception"]: + logger.warning("Report %s failed.", name) 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}") + logger.info("Report %s: %d rows retrieved.", name, len(data["rows"])) 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.add_argument( - "-l", - "--login_customer_id", - type=str, - help="The login customer ID (optional).", - ) + 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) args = parser.parse_args() - - main(args.customer_ids, args.login_customer_id) + main(args.customer_ids, args.login_id, args.workers) diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index f58f902..2341191 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -1,137 +1,29 @@ -# 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.v23.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.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/v23/AssetFieldTypeEnum" - ), - ) + 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) 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="v23") + 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..1e9ba8a 100644 --- a/api_examples/target_campaign_with_user_list.py +++ b/api_examples/target_campaign_with_user_list.py @@ -1,129 +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. - -"""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.add_argument( - "-u", - "--user_list_id", - type=str, - required=True, - help="The ID of the user list 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) 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="v23") + main(client, args.customer_id, args.campaign_id, args.user_list_id) 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_gaql_validator.py b/api_examples/tests/test_gaql_validator.py index f1981a3..6eb63a3 100644 --- a/api_examples/tests/test_gaql_validator.py +++ b/api_examples/tests/test_gaql_validator.py @@ -1,27 +1,12 @@ -# 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 import unittest from unittest.mock import MagicMock, patch from io import StringIO -# Ensure project root is in path sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) -from google.ads.googleads.errors import GoogleAdsException from api_examples.gaql_validator import main class TestGAQLValidator(unittest.TestCase): @@ -32,7 +17,6 @@ def setUp(self): 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 @@ -41,93 +25,17 @@ def tearDown(self): @patch("importlib.import_module") def test_main_success(self, mock_import): - # Setup mocks mock_module = MagicMock() mock_import.return_value = mock_module mock_request_class = MagicMock() setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) - # Execute - main( - client=self.mock_client, - customer_id=self.customer_id, - api_version=self.api_version, - query=self.test_query - ) + main(client=self.mock_client, customer_id=self.customer_id, api_version=self.api_version, query=self.test_query) - # Verify self.mock_ga_service.search.assert_called_once() output = self.captured_output.getvalue() - self.assertIn("SUCCESS: GAQL query is valid.", output) - - @patch("importlib.import_module") - def test_main_validation_failure(self, mock_import): - # Setup mocks - mock_module = MagicMock() - mock_import.return_value = mock_module - mock_request_class = MagicMock() - setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) - - # Setup GoogleAdsException - error = MagicMock() - error.message = "Invalid query" - error.location.field_path_elements = [MagicMock(field_name="query")] - - self.mock_ga_service.search.side_effect = GoogleAdsException( - error=MagicMock(), - call=MagicMock(), - failure=MagicMock(errors=[error]), - request_id="test-id" - ) - - # Execute - with self.assertRaises(SystemExit) as cm: - main( - client=self.mock_client, - customer_id=self.customer_id, - api_version=self.api_version, - query=self.test_query - ) - - # Verify - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn("FAILURE: Query validation failed with Request ID test-id", output) - self.assertIn("- Invalid query", output) - - def test_main_no_query(self): - # Execute - with self.assertRaises(SystemExit) as cm: - main( - client=self.mock_client, - customer_id=self.customer_id, - api_version=self.api_version, - query="" - ) - - # Verify - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn("Error: No query provided.", output) - - @patch("importlib.import_module") - def test_main_import_error(self, mock_import): - # Setup mocks - mock_import.side_effect = ImportError() - - # Execute - with self.assertRaises(SystemExit) as cm: - main( - client=self.mock_client, - customer_id=self.customer_id, - api_version=self.api_version, - query=self.test_query - ) - - # Verify - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn(f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {self.api_version.lower()}.", output) + 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_geo_targets.py b/api_examples/tests/test_get_geo_targets.py index 157d2d2..bd305ba 100644 --- a/api_examples/tests/test_get_geo_targets.py +++ b/api_examples/tests/test_get_geo_targets.py @@ -1,208 +1,26 @@ -# 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__), "../../"))) - +# Copyright 2026 Google LLC import unittest +import sys 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.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..6c34bff 100644 --- a/api_examples/tests/test_list_accessible_users.py +++ b/api_examples/tests/test_list_accessible_users.py @@ -1,117 +1,34 @@ -# 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__), "../../"))) - +# Copyright 2026 Google LLC import unittest +import sys from unittest.mock import MagicMock, patch 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.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 - 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) - + 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() 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_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: Date: Tue, 24 Feb 2026 16:14:08 +0000 Subject: [PATCH 70/81] Optimized api_examples/tests --- api_examples/tests/test_ai_max_reports.py | 210 ++--------- ...ollect_conversions_troubleshooting_data.py | 140 +++----- .../tests/test_disapproved_ads_reports.py | 331 ++---------------- .../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 | 6 +- .../tests/test_list_accessible_users.py | 6 +- .../tests/test_list_pmax_campaigns.py | 76 +--- ...est_remove_automatically_created_assets.py | 180 ++-------- .../test_target_campaign_with_user_list.py | 142 ++------ 12 files changed, 288 insertions(+), 1187 deletions(-) diff --git a/api_examples/tests/test_ai_max_reports.py b/api_examples/tests/test_ai_max_reports.py index 8d8c852..84fa173 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. @@ -30,7 +30,6 @@ main, _write_to_csv, get_campaign_details, - get_landing_page_matches, get_search_terms, ) @@ -51,17 +50,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 +66,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 +81,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 +104,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_collect_conversions_troubleshooting_data.py b/api_examples/tests/test_collect_conversions_troubleshooting_data.py index e7d1a09..ee625c3 100644 --- a/api_examples/tests/test_collect_conversions_troubleshooting_data.py +++ b/api_examples/tests/test_collect_conversions_troubleshooting_data.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. @@ -15,9 +15,7 @@ import sys import os import unittest -import tempfile -import shutil -from unittest.mock import MagicMock, patch +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__), "../../"))) @@ -34,132 +32,102 @@ def setUp(self): self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - # Patching os.makedirs and open to avoid actual file system interaction - self.test_dir = tempfile.mkdtemp() - self.patch_makedirs = patch("os.makedirs") - self.mock_makedirs = self.patch_makedirs.start() - - # We need to mock open carefully because it's used by many things - self.patch_open = patch("builtins.open", unittest.mock.mock_open()) - self.mock_open = self.patch_open.start() - self.captured_output = StringIO() sys.stdout = self.captured_output def tearDown(self): sys.stdout = sys.__stdout__ - self.patch_makedirs.stop() - self.patch_open.stop() - shutil.rmtree(self.test_dir) - def test_main_success_healthy(self): + @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_batch_customer = MagicMock() 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 - mock_batch_customer.results = [mock_row_customer] - # 2. Conversion Actions Mock - mock_batch_ca = MagicMock() - mock_row_ca = MagicMock() - mock_row_ca.conversion_action.id = 123 - mock_row_ca.conversion_action.name = "Test Action" - mock_row_ca.conversion_action.type.name = "UPLOAD_CLICKS" - mock_row_ca.conversion_action.status.name = "ENABLED" - mock_batch_ca.results = [mock_row_ca] - - # 3. Client Summary Mock - mock_batch_cs = MagicMock() - mock_row_cs = MagicMock() - mock_cs = mock_row_cs.offline_conversion_upload_client_summary - mock_cs.client.name = "GOOGLE_ADS_API" - mock_cs.status.name = "SUCCESS" - mock_cs.successful_event_count = 100 - mock_cs.total_event_count = 100 - mock_cs.last_upload_date_time = "2024-01-01 12:00:00" - mock_batch_cs.results = [mock_row_cs] - - # 4. Action Summary Mock - mock_batch_as = MagicMock() + # 2. Action Summary Mock mock_row_as = MagicMock() - mock_as = mock_row_as.offline_conversion_upload_conversion_action_summary - mock_as.conversion_action_name = "Test Action" - mock_as.status.name = "SUCCESS" - mock_as.successful_event_count = 50 - mock_as.total_event_count = 50 - mock_as.last_upload_date_time = "2024-01-01 12:00:00" + 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_ca], - [mock_batch_cs], [mock_batch_as] ] main(self.mock_client, self.customer_id) - # Verify file write - self.mock_open.assert_called() - handle = self.mock_open() - - # Collect all written content + handle = mock_file_open() written_content = "".join(call.args[0] for call in handle.write.call_args_list) - self.assertIn("Overall Status: HEALTHY", written_content) - self.assertIn("Customer Data Terms Accepted: True", written_content) - self.assertIn("Type=UPLOAD_CLICKS", written_content) - self.assertIn("Status=SUCCESS", written_content) - - def test_main_unhealthy_terms_not_accepted(self): + 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_batch_customer = MagicMock() 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_row_customer.customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled = True - mock_batch_customer.results = [mock_row_customer] - - # Mocks for other queries (empty or success) - mock_batch_empty = MagicMock() - mock_batch_empty.results = [] + mock_batch_customer = MagicMock() + mock_batch_customer.results = [mock_row_customer] + self.mock_ga_service.search_stream.side_effect = [ [mock_batch_customer], - [mock_batch_empty], - [mock_batch_empty], - [mock_batch_empty] + [] ] main(self.mock_client, self.customer_id) - handle = self.mock_open() + handle = mock_file_open() written_content = "".join(call.args[0] for call in handle.write.call_args_list) - self.assertIn("Overall Status: UNHEALTHY", written_content) - self.assertIn("CRITICAL: Customer Data Terms NOT accepted", written_content) - - 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.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=mock_error, + error=MagicMock(), + failure=MagicMock(errors=[MagicMock(message="Internal error")]), + 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) + 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) + self.assertIn("ERROR: Query failed (Request ID: test_request_id)", output) + self.assertIn("Internal error", output) if __name__ == "__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_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 bd305ba..1428f65 100644 --- a/api_examples/tests/test_get_geo_targets.py +++ b/api_examples/tests/test_get_geo_targets.py @@ -1,8 +1,12 @@ # Copyright 2026 Google LLC -import unittest import sys +import os +import unittest from unittest.mock import MagicMock from io import StringIO + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + from api_examples.get_geo_targets import main class TestGetGeoTargets(unittest.TestCase): diff --git a/api_examples/tests/test_list_accessible_users.py b/api_examples/tests/test_list_accessible_users.py index 6c34bff..b978fa7 100644 --- a/api_examples/tests/test_list_accessible_users.py +++ b/api_examples/tests/test_list_accessible_users.py @@ -1,8 +1,12 @@ # Copyright 2026 Google LLC -import unittest 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.list_accessible_users import main class TestListAccessibleUsers(unittest.TestCase): 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_remove_automatically_created_assets.py b/api_examples/tests/test_remove_automatically_created_assets.py index d7fa1fe..de068f0 100644 --- a/api_examples/tests/test_remove_automatically_created_assets.py +++ b/api_examples/tests/test_remove_automatically_created_assets.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. @@ -30,178 +30,56 @@ class TestRemoveAutomaticallyCreatedAssets(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) - self.mock_automatically_created_asset_removal_service = MagicMock() + self.mock_client = MagicMock() # Don't use spec=GoogleAdsClient to allow enums attribute + self.mock_removal_service = MagicMock() self.mock_campaign_service = MagicMock() - - 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" - 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_asset_field_type_enum.AssetFieldType = MockAssetFieldType() - - + + def get_service_side_effect(name): + if name == "AutomaticallyCreatedAssetRemovalService": + return self.mock_removal_service + if name == "CampaignService": + return self.mock_campaign_service + return MagicMock() + + self.mock_client.get_service.side_effect = get_service_side_effect + self.mock_client.get_type.return_value = MagicMock() + self.mock_client.enums.AssetFieldTypeEnum.AssetFieldType.HEADLINE = 1 + self.customer_id = "1234567890" - self.campaign_id = 12345 - self.asset_resource_name = "customers/1234567890/assets/67890" + self.campaign_id = "111222333" + self.asset_rn = "customers/123/assets/456" 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__ - self.patcher.stop() 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_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}" - ) + main(self.mock_client, self.customer_id, self.campaign_id, self.asset_rn, self.field_type) - 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) + self.mock_removal_service.remove_campaign_automatically_created_asset.assert_called_once() + self.assertIn("Removed 1 assets.", self.captured_output.getvalue()) 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( + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_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")] - ), - ) - ] - ), + failure=MagicMock(errors=[MagicMock(message="Error details")]), request_id="test_request_id", + call=MagicMock(), ) - 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) + main(self.mock_client, self.customer_id, self.campaign_id, self.asset_rn, self.field_type) + 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_target_campaign_with_user_list.py b/api_examples/tests/test_target_campaign_with_user_list.py index 17f4ea0..06ffdb6 100644 --- a/api_examples/tests/test_target_campaign_with_user_list.py +++ b/api_examples/tests/test_target_campaign_with_user_list.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. @@ -18,7 +18,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) import unittest -from unittest.mock import MagicMock, call +from unittest.mock import MagicMock from io import StringIO from google.ads.googleads.errors import GoogleAdsException @@ -31,31 +31,26 @@ class TestTargetCampaignWithUserList(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=GoogleAdsClient) - self.mock_campaign_criterion_service = MagicMock() + self.mock_criterion_service = MagicMock() self.mock_campaign_service = MagicMock() self.mock_user_list_service = MagicMock() - - self.mock_client.get_service.side_effect = [ - self.mock_campaign_criterion_service, # First call to get_service - self.mock_campaign_service, # Second call to get_service - self.mock_user_list_service, # Third call to get_service - ] - - self.mock_campaign_criterion_operation = MagicMock() - self.mock_campaign_criterion = MagicMock() - self.mock_campaign_criterion_operation.create = self.mock_campaign_criterion - self.mock_client.get_type.return_value = self.mock_campaign_criterion_operation - - self.mock_campaign_service.campaign_path.return_value = ( - "customers/123/campaigns/456" - ) - self.mock_user_list_service.user_list_path.return_value = ( - "customers/123/userLists/789" - ) - - self.customer_id = "123" - self.campaign_id = "456" - self.user_list_id = "789" + + def get_service_side_effect(name): + if name == "CampaignCriterionService": + return self.mock_criterion_service + if name == "CampaignService": + return self.mock_campaign_service + if name == "UserListService": + return self.mock_user_list_service + return MagicMock() + + self.mock_client.get_service.side_effect = get_service_side_effect + self.mock_client.get_type.return_value = MagicMock() + + self.customer_id = "1234567890" + self.campaign_id = "111222333" + self.user_list_id = "444555666" + self.captured_output = StringIO() sys.stdout = self.captured_output @@ -64,99 +59,30 @@ def tearDown(self): def test_main_successful_targeting(self): mock_response = MagicMock() - mock_response.results = [ - MagicMock(resource_name="customers/123/campaignCriteria/101") - ] - self.mock_campaign_criterion_service.mutate_campaign_criteria.return_value = ( - mock_response - ) + mock_response.results = [MagicMock(resource_name="customers/123/campaignCriteria/101")] + self.mock_criterion_service.mutate_campaign_criteria.return_value = mock_response main(self.mock_client, self.customer_id, self.campaign_id, self.user_list_id) - # Assert get_service calls - self.mock_client.get_service.assert_has_calls( - [ - call("CampaignCriterionService"), - call("CampaignService"), - call("UserListService"), - ] - ) - - # Assert get_type call - self.mock_client.get_type.assert_called_once_with("CampaignCriterionOperation") - - # Assert path calls - self.mock_campaign_service.campaign_path.assert_called_once_with( - self.customer_id, self.campaign_id - ) - self.mock_user_list_service.user_list_path.assert_called_once_with( - self.customer_id, self.user_list_id - ) - - # Assert mutate_campaign_criteria call - self.mock_campaign_criterion_service.mutate_campaign_criteria.assert_called_once_with( - customer_id=self.customer_id, - operations=[self.mock_campaign_criterion_operation], - ) - - # Assert output - output = self.captured_output.getvalue() - self.assertIn( - "Added campaign criterion with resource name: 'customers/123/campaignCriteria/101'", - output, - ) + self.mock_criterion_service.mutate_campaign_criteria.assert_called_once() + self.assertIn("Created criterion: customers/123/campaignCriteria/101", 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_campaign_criterion_service.mutate_campaign_criteria.side_effect = ( - 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_criterion_service.mutate_campaign_criteria.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, self.campaign_id, self.user_list_id - ) + main(self.mock_client, self.customer_id, self.campaign_id, self.user_list_id) 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", self.captured_output.getvalue()) if __name__ == "__main__": From f392c775b80342147eb9df8d24b86df50b87e216 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 16:47:51 +0000 Subject: [PATCH 71/81] Added pitfalls to conversions/GEMINI.md --- conversions/GEMINI.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 4410916..89331b8 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -60,6 +60,7 @@ * **[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. @@ -71,11 +72,34 @@ The AI MUST format final reports as follows: 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. -**Consolidation Mandate**: All findings, including terminal summaries and data from external troubleshooting scripts, MUST be consolidated into a **single, uniquely named text file** in `saved/data/` (e.g., `support_package_.txt`). 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". +**Consolidation Mandate**: All findings, including terminal summaries, the structured analysis, and data from external troubleshooting scripts, MUST be consolidated into a **single, uniquely named text file** in `saved/data/` (e.g., `support_package_.txt`). 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". --- ### 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 or `Class.pb(Class)` for classes to access the underlying protobuf descriptor (e.g., `type(obj).pb(obj).DESCRIPTOR.fields`). +* **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`. + * **Example Code**: + ```python + error_type = type(alert.error).pb(alert.error).WhichOneof("error") + 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`. From 4d47fe9c25448eda750e633d21a7712b29179a43 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 17:43:16 +0000 Subject: [PATCH 72/81] Rules and command modifications to create consistent support package. --- .gemini/commands/conversions_support_data.toml | 5 +++-- conversions/GEMINI.md | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml index fed416d..81b84a5 100644 --- a/.gemini/commands/conversions_support_data.toml +++ b/.gemini/commands/conversions_support_data.toml @@ -9,8 +9,9 @@ Please execute the following actions: 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. Summarize the findings from the script's terminal output (Summary and Error sections). -6. Mention that a detailed, shareable text report has been saved to the `saved/data/` directory. +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., `support_package_.txt`). Here are the details from the user: {{args}} diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 89331b8..91f2e74 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -49,6 +49,7 @@ * **Mandatory Fix**: Use `FROM customer`, `campaign`, or `ad_group` and `SELECT segments.conversion_action`. 3. **Metadata Query Syntax**: `GoogleAdsFieldService` queries MUST NOT include a `FROM` clause. * **Correct**: `SELECT name, selectable WHERE name = 'campaign.id'` + * **[PITFALL] Field Names**: Use `data_type`. DO NOT use `type` in `GoogleAdsFieldService` queries; it will result in an `UNRECOGNIZED_FIELD` error. 4. **Referenced Action Rule**: If `segments.conversion_action` is in `WHERE`, it MUST be in `SELECT`. 5. **Logical Time Verification**: Before upload, AI MUST verify: * `conversion_date_time` > `click_time`. @@ -75,7 +76,7 @@ The AI MUST format final reports as follows: 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. -**Consolidation Mandate**: All findings, including terminal summaries, the structured analysis, and data from external troubleshooting scripts, MUST be consolidated into a **single, uniquely named text file** in `saved/data/` (e.g., `support_package_.txt`). 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". +**Consolidation Mandate**: All findings, including terminal summaries, the structured analysis, and the **complete verbatim data** from all troubleshooting scripts and queries, MUST be consolidated into a **single, uniquely named text file** in `saved/data/` (e.g., `support_package_.txt`). 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. --- @@ -89,16 +90,18 @@ The AI MUST format final reports as follows: #### 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 or `Class.pb(Class)` for classes to access the underlying protobuf descriptor (e.g., `type(obj).pb(obj).DESCRIPTOR.fields`). +* **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`. + * **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 - error_type = type(alert.error).pb(alert.error).WhichOneof("error") + # 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 ``` From 663a99d1b01c96f562d9c203a8d3c587eb685399 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 20:23:56 +0000 Subject: [PATCH 73/81] Command conversions_support_data must write name of output file to console. --- .gemini/commands/conversions_support_data.toml | 2 +- conversions/GEMINI.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml index 81b84a5..f43ee89 100644 --- a/.gemini/commands/conversions_support_data.toml +++ b/.gemini/commands/conversions_support_data.toml @@ -11,7 +11,7 @@ Please execute the following actions: 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., `support_package_.txt`). +7. Save this consolidated data into a single file in `saved/data/` (e.g., `support_package_.txt`) and print name of file to console. Here are the details from the user: {{args}} diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 91f2e74..4e3a48c 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -50,8 +50,9 @@ 3. **Metadata Query Syntax**: `GoogleAdsFieldService` queries MUST NOT include a `FROM` clause. * **Correct**: `SELECT name, selectable WHERE name = 'campaign.id'` * **[PITFALL] Field Names**: Use `data_type`. DO NOT use `type` in `GoogleAdsFieldService` queries; it will result in an `UNRECOGNIZED_FIELD` error. -4. **Referenced Action Rule**: If `segments.conversion_action` is in `WHERE`, it MUST be in `SELECT`. -5. **Logical Time Verification**: Before upload, AI MUST verify: +4. **Referenced Action Rule**: If `segments.conversion_action` is in `WHERE`, it MUST be in `SELECT`. Failure to do so results in `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. +5. **No Metrics for Managers**: Metrics (e.g., `metrics.conversions`) CANNOT be requested for a manager account (MCC). You MUST identify and query each client account separately. Failure results in `REQUESTED_METRICS_FOR_MANAGER`. +6. **Logical Time Verification**: Before upload, AI MUST verify: * `conversion_date_time` > `click_time`. * Click is within Lookback Window. From ddc9041f08da467d19b93fa28f7bd397adbc059a Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 20:26:54 +0000 Subject: [PATCH 74/81] Update ChangeLog --- ChangeLog | 1 + 1 file changed, 1 insertion(+) diff --git a/ChangeLog b/ChangeLog index 2ebb676..3d5f284 100644 --- a/ChangeLog +++ b/ChangeLog @@ -17,6 +17,7 @@ - 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. * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md From 80545d234dcde429d13b9e60bd44c70d4861bde5 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Tue, 24 Feb 2026 21:01:36 +0000 Subject: [PATCH 75/81] Vetted api_examples and test for errors --- GEMINI.md | 1 + api_examples/ai_max_reports.py | 13 ++- api_examples/capture_gclids.py | 17 +-- ...ollect_conversions_troubleshooting_data.py | 4 +- api_examples/conversion_reports.py | 4 +- api_examples/create_campaign_experiment.py | 11 +- api_examples/disapproved_ads_reports.py | 5 +- api_examples/get_campaign_bid_simulations.py | 5 +- api_examples/get_campaign_shared_sets.py | 5 +- api_examples/get_change_history.py | 5 +- api_examples/get_conversion_upload_summary.py | 5 +- api_examples/get_geo_targets.py | 5 +- api_examples/list_accessible_users.py | 22 +++- api_examples/list_pmax_campaigns.py | 5 +- .../parallel_report_downloader_optimized.py | 105 ++++++++---------- .../remove_automatically_created_assets.py | 5 +- .../target_campaign_with_user_list.py | 5 +- api_examples/tests/test_ai_max_reports.py | 1 - .../tests/test_list_accessible_users.py | 2 +- ...est_remove_automatically_created_assets.py | 1 - 20 files changed, 131 insertions(+), 95 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index a6316e3..b129661 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -76,6 +76,7 @@ Before presenting or executing ANY GAQL query, you MUST pass this 4-step sequenc - `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. diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index 91f293a..f729075 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -57,7 +57,16 @@ def main(client: GoogleAdsClient, customer_id: str, report_type: str) -> None: if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("-c", "--customer_id", required=True) - parser.add_argument("-r", "--report_type", choices=["campaign_details", "search_terms"], 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="v23") + 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 26849fe..8b4d37a 100644 --- a/api_examples/capture_gclids.py +++ b/api_examples/capture_gclids.py @@ -64,10 +64,6 @@ 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="v23") - parser = argparse.ArgumentParser( description="Uploads a click conversion for a given GCLID." ) @@ -87,15 +83,14 @@ def main( help="The GCLID for the ad click.", ) parser.add_argument( - "-t", - "--conversion_date_time", - type=str, - required=True, - help="The date and time of the conversion (should be after the click " - "time). The format is 'yyyy-mm-dd hh:mm:ss+|-hh:mm', e.g. " - "'2021-01-01 12:32:45-08:00'.", + "-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, diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py index 00f2b30..f0e9fae 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("-c", "--customer_id", required=True) + 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="v23") + 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 b1896d6..a3d76eb 100644 --- a/api_examples/conversion_reports.py +++ b/api_examples/conversion_reports.py @@ -212,10 +212,10 @@ 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("--limit", type=int) + 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="v23") + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) if args.report_type == "actions": get_conversion_actions_report(googleads_client, args.customer_id, args.output_file) 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 26dbd10..3e0cb27 100644 --- a/api_examples/disapproved_ads_reports.py +++ b/api_examples/disapproved_ads_reports.py @@ -36,6 +36,9 @@ def main(client: GoogleAdsClient, customer_id: str, output_file: str) -> None: 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( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + client = GoogleAdsClient.load_from_storage(version=args.api_version) main(client, args.customer_id, args.output) diff --git a/api_examples/get_campaign_bid_simulations.py b/api_examples/get_campaign_bid_simulations.py index 2b1520b..c7ce01f 100644 --- a/api_examples/get_campaign_bid_simulations.py +++ b/api_examples/get_campaign_bid_simulations.py @@ -35,6 +35,9 @@ def main(client: GoogleAdsClient, customer_id: str, campaign_id: str) -> None: parser = argparse.ArgumentParser() 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." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + 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 19a75f6..6e15d23 100644 --- a/api_examples/get_campaign_shared_sets.py +++ b/api_examples/get_campaign_shared_sets.py @@ -25,6 +25,9 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: if __name__ == "__main__": 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." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + 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 057065a..a0d6397 100644 --- a/api_examples/get_change_history.py +++ b/api_examples/get_change_history.py @@ -40,9 +40,12 @@ def main(client: GoogleAdsClient, customer_id: str, start: str, end: str, resour 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( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() - googleads_client = GoogleAdsClient.load_from_storage(version="v23") + 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 962bdaf..3957c45 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -32,6 +32,9 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: if __name__ == "__main__": 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." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + 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 181c784..e9801fc 100644 --- a/api_examples/get_geo_targets.py +++ b/api_examples/get_geo_targets.py @@ -42,6 +42,9 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: if __name__ == "__main__": 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." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + 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 5e8228e..f0c380a 100644 --- a/api_examples/list_accessible_users.py +++ b/api_examples/list_accessible_users.py @@ -1,11 +1,17 @@ # Copyright 2026 Google LLC """Lists accessible customers with management context.""" +import argparse + from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -def main() -> None: - client = GoogleAdsClient.load_from_storage(version="v23") +def main(client: GoogleAdsClient) -> None: + """The main function to list accessible customers. + + Args: + client: An initialized GoogleAdsClient instance. + """ customer_service = client.get_service("CustomerService") try: accessible = customer_service.list_accessible_customers() @@ -16,4 +22,14 @@ def main() -> None: print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - 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=args.api_version) + + main(googleads_client) diff --git a/api_examples/list_pmax_campaigns.py b/api_examples/list_pmax_campaigns.py index 4897df2..6708224 100644 --- a/api_examples/list_pmax_campaigns.py +++ b/api_examples/list_pmax_campaigns.py @@ -37,6 +37,9 @@ def main(client: GoogleAdsClient, customer_id: str) -> None: if __name__ == "__main__": 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." + ) args = parser.parse_args() - googleads_client = GoogleAdsClient.load_from_storage(version="v23") + 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 08d2b3e..c1a4a0e 100644 --- a/api_examples/parallel_report_downloader_optimized.py +++ b/api_examples/parallel_report_downloader_optimized.py @@ -1,67 +1,51 @@ # 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. - -"""Downloads multiple reports in parallel using structured logging.""" +"""Parallel report downloader with optimized concurrency and retry logic.""" import argparse import logging -from concurrent.futures import ThreadPoolExecutor, as_completed +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 -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] [%(threadName)s] %(message)s", - datefmt="%H:%M:%S", -) +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 report with centralized exception handling.""" +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 + + +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") - logger.info("[%s] Fetching for customer %s...", report_name, customer_id) - rows = [] - exception = None try: stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] for batch in stream: for row in batch.results: rows.append(row) - logger.info("[%s] Completed. Found %d rows.", report_name, len(rows)) + logger.info("Completed. Found %d rows.", len(rows)) + return {"customer_id": customer_id, "rows": rows} except GoogleAdsException as ex: - logger.error("[%s] Request ID %s failed: %s", report_name, ex.request_id, ex.error.code().name) - exception = ex - return report_name, rows, exception + logger.error("Request ID %s failed for customer %s", ex.request_id, customer_id) + raise -def _get_date_range_strings() -> Tuple[str, str]: - """Helper for testing compatibility.""" - end = datetime.now() - start = end - timedelta(days=30) - return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") - - -def main(customer_ids: List[str], login_id: Optional[str], workers: int = 5) -> None: +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="v23") + client = GoogleAdsClient.load_from_storage(version=api_version) if login_id: client.login_customer_id = login_id @@ -70,27 +54,27 @@ def main(customer_ids: List[str], login_id: Optional[str], workers: int = 5) -> report_defs = [ { "name": "Campaign_Performance", - "query": f"SELECT campaign.id, metrics.clicks FROM campaign WHERE segments.date BETWEEN '{start}' AND '{end}' LIMIT 5" + "query": f"SELECT campaign.id, metrics.clicks FROM campaign WHERE segments.date BETWEEN '{start}' AND '{end}' LIMIT 5", } ] - results: Dict[str, Dict[str, Any]] = {} - with ThreadPoolExecutor(max_workers=workers) as executor: - future_to_name = { - executor.submit(fetch_report_threaded, client, cid, rd["query"], f"{rd['name']}_{cid}"): f"{rd['name']}_{cid}" - for cid in customer_ids for rd in report_defs + 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 as_completed(future_to_name): - name = future_to_name[future] - _, rows, ex = future.result() - results[name] = {"rows": rows, "exception": ex} - - for name, data in results.items(): - if data["exception"]: - logger.warning("Report %s failed.", name) - else: - logger.info("Report %s: %d rows retrieved.", name, len(data["rows"])) + 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__": @@ -98,5 +82,8 @@ def main(customer_ids: List[str], login_id: Optional[str], workers: int = 5) -> 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( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() - main(args.customer_ids, args.login_id, args.workers) + 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 2341191..6f64150 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -24,6 +24,9 @@ def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, asset_rn: 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( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + 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 1e9ba8a..9e9d593 100644 --- a/api_examples/target_campaign_with_user_list.py +++ b/api_examples/target_campaign_with_user_list.py @@ -25,6 +25,9 @@ def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, user_list_ 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( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() - client = GoogleAdsClient.load_from_storage(version="v23") + 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/test_ai_max_reports.py b/api_examples/tests/test_ai_max_reports.py index 84fa173..fb34462 100644 --- a/api_examples/tests/test_ai_max_reports.py +++ b/api_examples/tests/test_ai_max_reports.py @@ -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 diff --git a/api_examples/tests/test_list_accessible_users.py b/api_examples/tests/test_list_accessible_users.py index b978fa7..3388c07 100644 --- a/api_examples/tests/test_list_accessible_users.py +++ b/api_examples/tests/test_list_accessible_users.py @@ -29,7 +29,7 @@ def test_main_success(self, mock_load): mock_accessible.resource_names = ["customers/1", "customers/2"] mock_service.list_accessible_customers.return_value = mock_accessible - main() + main(mock_client) output = self.captured_output.getvalue() self.assertIn("Found 2 accessible customers.", output) self.assertIn("customers/1", output) diff --git a/api_examples/tests/test_remove_automatically_created_assets.py b/api_examples/tests/test_remove_automatically_created_assets.py index de068f0..6669e1a 100644 --- a/api_examples/tests/test_remove_automatically_created_assets.py +++ b/api_examples/tests/test_remove_automatically_created_assets.py @@ -22,7 +22,6 @@ 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 From 4c9d8831aa59b2a58e59da7a38cf05cb8441e7dc Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 25 Feb 2026 15:21:33 +0000 Subject: [PATCH 76/81] Added to pitfall to avoid conversion metadata errors. Sometimes Gemini prioritizes speed over integrity and does not follow the rules to validate fields. The ptifall will prevent a common error that occurs when Gemini fails to follow procedure. --- conversions/GEMINI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 4e3a48c..676844f 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -49,6 +49,8 @@ * **Mandatory Fix**: Use `FROM customer`, `campaign`, or `ad_group` and `SELECT segments.conversion_action`. 3. **Metadata Query Syntax**: `GoogleAdsFieldService` queries MUST NOT include a `FROM` clause. * **Correct**: `SELECT name, selectable WHERE name = 'campaign.id'` + * **[PITFALL] Service Selection**: NEVER use `GoogleAdsService` to query `google_ads_field`. You MUST use `GoogleAdsFieldService.search_google_ads_fields`. + * **[PITFALL] Field Prefixes**: Metadata fields MUST NOT be prefixed with the resource name (e.g., use `name`, NOT `google_ads_field.name`). * **[PITFALL] Field Names**: Use `data_type`. DO NOT use `type` in `GoogleAdsFieldService` queries; it will result in an `UNRECOGNIZED_FIELD` error. 4. **Referenced Action Rule**: If `segments.conversion_action` is in `WHERE`, it MUST be in `SELECT`. Failure to do so results in `EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`. 5. **No Metrics for Managers**: Metrics (e.g., `metrics.conversions`) CANNOT be requested for a manager account (MCC). You MUST identify and query each client account separately. Failure results in `REQUESTED_METRICS_FOR_MANAGER`. From 37577169f5a7067355b541d3db4dc76e25967033 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 25 Feb 2026 16:04:56 +0000 Subject: [PATCH 77/81] Refinements to conversion troubleshooting output and process. --- .../commands/conversions_support_package.toml | 18 ++++++++++++++++++ GEMINI.md | 3 ++- conversions/GEMINI.md | 9 ++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .gemini/commands/conversions_support_package.toml 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.md b/GEMINI.md index b129661..6755852 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -113,8 +113,9 @@ except GoogleAdsException as ex: 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. +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. #### 4.3. Python Object Inspection (CRITICAL) NEVER guess the structure of an API object. diff --git a/conversions/GEMINI.md b/conversions/GEMINI.md index 676844f..5b2d2e1 100644 --- a/conversions/GEMINI.md +++ b/conversions/GEMINI.md @@ -78,8 +78,15 @@ The AI MUST format final reports as follows: 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, and the **complete verbatim data** from all troubleshooting scripts and queries, MUST be consolidated into a **single, uniquely named text file** in `saved/data/` (e.g., `support_package_.txt`). 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. +**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. --- From 0024f83bf659f2ea1cc961f3dc1e49bea612b159 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 25 Feb 2026 19:44:07 +0000 Subject: [PATCH 78/81] Added +x to uninstall.sh --- uninstall.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 uninstall.sh diff --git a/uninstall.sh b/uninstall.sh old mode 100644 new mode 100755 From 6396744fa2da0d6b40d6d7609b33d84b7aed0eea Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 25 Feb 2026 19:47:35 +0000 Subject: [PATCH 79/81] Delete extraneous file in saved/csv --- saved/csv/campaigns.csv | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 saved/csv/campaigns.csv 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 From dd318c7d2381e36f2ee1d9f84c7d5134c5f5f82c Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 25 Feb 2026 19:49:20 +0000 Subject: [PATCH 80/81] Delete extra command file. --- .gemini/commands/conversions_support_data.toml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .gemini/commands/conversions_support_data.toml diff --git a/.gemini/commands/conversions_support_data.toml b/.gemini/commands/conversions_support_data.toml deleted file mode 100644 index f43ee89..0000000 --- a/.gemini/commands/conversions_support_data.toml +++ /dev/null @@ -1,18 +0,0 @@ -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., `support_package_.txt`) and print name of file to console. - -Here are the details from the user: -{{args}} -""" From 9d4dd216f8362c7277a8c4cbac5ef1f4c1b56367 Mon Sep 17 00:00:00 2001 From: Bob Hancock Date: Wed, 25 Feb 2026 19:51:57 +0000 Subject: [PATCH 81/81] Delete test_update_logic.sh --- tests/test_update_logic.sh | 129 ------------------------------------- 1 file changed, 129 deletions(-) delete mode 100755 tests/test_update_logic.sh 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"