diff --git a/.gemini/commands/conversions_support_package.toml b/.gemini/commands/conversions_support_package.toml new file mode 100644 index 0000000..c1a14bf --- /dev/null +++ b/.gemini/commands/conversions_support_package.toml @@ -0,0 +1,18 @@ +description = "Collects structured diagnostic data for gTech conversion troubleshooting." + +prompt = """ +You are a helpful Google Ads API troubleshooting assistant. +The User is experiencing issues with conversions and needs to collect structured diagnostic data for gTech support. + +Please execute the following actions: +1. At the top of the output file write "Created by the Google Ads API Developer Assistant" +2. If you have previously completed structured diagnostic analysis, include that text in the file. +3. Locate the current `customer_id` from `customer_id.txt` or context. +4. Run the structured troubleshooting script using the command: `python3 api_examples/collect_conversions_troubleshooting_data.py --customer_id ` +5. Include a section "SUMMARY OF FINDINGS" containing the Summary and Error sections from the script's terminal output. +6. Include a section "DETAILED DIAGNOSTIC DATA" containing the **complete verbatim content** of the troubleshooting report generated by the script (found in `saved/data/`). +7. Save this consolidated data into a single file in `saved/data/` (e.g., `conversions_support_package_.text`) and print name of file to console. + +Here are the details from the user: +{{args}} +""" diff --git a/.gemini/hooks/cleanup_config.py b/.gemini/hooks/cleanup_config.py new file mode 100644 index 0000000..458db45 --- /dev/null +++ b/.gemini/hooks/cleanup_config.py @@ -0,0 +1,39 @@ +import os +import shutil +import sys +import datetime + +def cleanup(): + # Determine paths + script_dir = os.path.dirname(os.path.abspath(__file__)) + # .gemini/hooks/ -> project root is 2 levels up + project_root = os.path.abspath(os.path.join(script_dir, "../..")) + config_dir = os.path.join(project_root, "config") + + if not os.path.exists(config_dir): + print(f"Config directory {config_dir} does not exist. Nothing to clean.", file=sys.stderr) + return + + try: + # User requested to remove *all files* in the config directory. + # We could also remove the directory itself. Let's remove content. + for filename in os.listdir(config_dir): + if filename == ".gitkeep": + continue + file_path = os.path.join(config_dir, filename) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + except Exception as e: + print(f"Failed to delete {file_path}. Reason: {e}", file=sys.stderr) + + timestamp = datetime.datetime.now() + + except Exception as e: + print(f"Error cleaning up config directory: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + cleanup() diff --git a/.gemini/hooks/custom_config.py b/.gemini/hooks/custom_config.py new file mode 100644 index 0000000..d40b813 --- /dev/null +++ b/.gemini/hooks/custom_config.py @@ -0,0 +1,196 @@ +import os +import shutil +import subprocess +import json +import sys +import re + +def get_version(ext_version_script): + """Retrieves the extension version.""" + try: + result = subprocess.run( + [sys.executable, ext_version_script], + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except Exception as e: + print(f"Error getting extension version: {e}", file=sys.stderr) + return "2.0.0" # Fallback + +def parse_ruby_config(path): + """Parses a Ruby config file for Google Ads.""" + data = {} + patterns = { + "developer_token": r"c\.developer_token\s*=\s*['\"](.*?)['\"]", + "client_id": r"c\.client_id\s*=\s*['\"](.*?)['\"]", + "client_secret": r"c\.client_secret\s*=\s*['\"](.*?)['\"]", + "refresh_token": r"c\.refresh_token\s*=\s*['\"](.*?)['\"]", + "login_customer_id": r"c\.login_customer_id\s*=\s*['\"](.*?)['\"]", + "json_key_file_path": r"c\.json_key_file_path\s*=\s*['\"](.*?)['\"]", + "impersonated_email": r"c\.impersonated_email\s*=\s*['\"](.*?)['\"]", + } + try: + with open(path, "r") as f: + content = f.read() + for key, pattern in patterns.items(): + match = re.search(pattern, content) + if match: + data[key] = match.group(1) + except Exception as e: + print(f"Error parsing Ruby config: {e}", file=sys.stderr) + return data + +def parse_ini_config(path): + """Parses a PHP INI config file for Google Ads.""" + data = {} + patterns = { + "developer_token": r"developer_token\s*=\s*['\"]?(.*?)['\"]?\s*$", + "client_id": r"client_id\s*=\s*['\"]?(.*?)['\"]?\s*$", + "client_secret": r"client_secret\s*=\s*['\"]?(.*?)['\"]?\s*$", + "refresh_token": r"refresh_token\s*=\s*['\"]?(.*?)['\"]?\s*$", + "login_customer_id": r"login_customer_id\s*=\s*['\"]?(.*?)['\"]?\s*$", + "json_key_file_path": r"json_key_file_path\s*=\s*['\"]?(.*?)['\"]?\s*$", + "impersonated_email": r"impersonated_email\s*=\s*['\"]?(.*?)['\"]?\s*$", + } + try: + with open(path, "r") as f: + for line in f: + for key, pattern in patterns.items(): + match = re.search(pattern, line) + if match: + data[key] = match.group(1) + except Exception as e: + print(f"Error parsing INI config: {e}", file=sys.stderr) + return data + +def parse_properties_config(path): + """Parses a Java properties config file for Google Ads.""" + data = {} + mapping = { + "api.googleads.developerToken": "developer_token", + "api.googleads.clientId": "client_id", + "api.googleads.clientSecret": "client_secret", + "api.googleads.refreshToken": "refresh_token", + "api.googleads.loginCustomerId": "login_customer_id", + "api.googleads.oAuth2SecretsJsonPath": "json_key_file_path", + "api.googleads.oAuth2PrnEmail": "impersonated_email", + } + try: + with open(path, "r") as f: + for line in f: + if "=" in line: + k, v = line.split("=", 1) + k = k.strip() + if k in mapping: + data[mapping[k]] = v.strip() + except Exception as e: + print(f"Error parsing properties config: {e}", file=sys.stderr) + return data + +def write_yaml_config(data, target_path, version): + """Writes a standard Google Ads YAML config.""" + try: + service_account = "json_key_file_path" in data + with open(target_path, "w") as f: + f.write("# Generated by Gemini CLI Assistant\n") + f.write("developer_token: " + data.get("developer_token", "INSERT_DEVELOPER_TOKEN_HERE") + "\n") + + if service_account: + f.write("json_key_file_path: " + data["json_key_file_path"] + "\n") + if "impersonated_email" in data: + f.write("impersonated_email: " + data["impersonated_email"] + "\n") + else: + f.write("client_id: " + data.get("client_id", "INSERT_CLIENT_ID_HERE") + "\n") + f.write("client_secret: " + data.get("client_secret", "INSERT_CLIENT_SECRET_HERE") + "\n") + f.write("refresh_token: " + data.get("refresh_token", "INSERT_REFRESH_TOKEN_HERE") + "\n") + + if "login_customer_id" in data: + f.write("login_customer_id: " + data["login_customer_id"] + "\n") + f.write("use_proto_plus: True\n") + f.write(f"gaada: \"{version}\"\n") + return True + except Exception as e: + print(f"Error writing YAML config: {e}", file=sys.stderr) + return False + +def configure_language(lang_name, home_config, target_config, version, is_python=False): + """Copies and versions a specific language configuration.""" + if not os.path.exists(home_config): + return False + + try: + shutil.copy2(home_config, target_config) + with open(target_config, "a", encoding="utf-8") as f: + sep = ":" if is_python else "=" + f.write(f"\ngaada{sep} \"{version}\"\n") + + return True + except Exception as e: + print(f"Error configuring {lang_name}: {e}", file=sys.stderr) + return False + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.abspath(os.path.join(script_dir, "../..")) + config_dir = os.path.join(project_root, "config") + ext_version_script = os.path.join(project_root, ".gemini/skills/ext_version/scripts/get_extension_version.py") + + os.makedirs(config_dir, exist_ok=True) + version = get_version(ext_version_script) + + home_dir = os.path.expanduser("~") + python_home = os.path.join(home_dir, "google-ads.yaml") + python_target = os.path.join(config_dir, "google-ads.yaml") + + # 1. Try Python YAML first + if configure_language("Python", python_home, python_target, version, is_python=True): + print("Configured Python") + else: + # 2. Try fallbacks + fallbacks = [ + ("PHP", "google_ads_php.ini", parse_ini_config), + ("Ruby", "google_ads_config.rb", parse_ruby_config), + ("Java", "ads.properties", parse_properties_config), + ] + + found_fallback = False + for lang, filename, parser in fallbacks: + path = os.path.join(home_dir, filename) + if os.path.exists(path): + print(f"Found {lang} config at {path}. Converting to YAML...") + data = parser(path) + if write_yaml_config(data, python_target, version): + print(f"Successfully converted {lang} config to {python_target}") + print(f"export GOOGLE_ADS_CONFIGURATION_FILE_PATH=\"{python_target}\"", file=sys.stdout) + found_fallback = True + break + + if not found_fallback: + print("Error: No Google Ads configuration found in home directory. Please create ~/google-ads.yaml.", file=sys.stderr) + sys.exit(1) + + # 3. Configure other languages if requested by workspace context + languages = [ + {"id": "google-ads-php", "name": "PHP", "filename": "google_ads_php.ini", "home": os.path.join(home_dir, "google_ads_php.ini")}, + {"id": "google-ads-ruby", "name": "Ruby", "filename": "google_ads_config.rb", "home": os.path.join(home_dir, "google_ads_config.rb")}, + {"id": "google-ads-java", "name": "Java", "filename": "ads.properties", "home": os.path.join(home_dir, "ads.properties")}, + ] + + settings_path = os.path.join(project_root, ".gemini/settings.json") + if os.path.exists(settings_path): + try: + with open(settings_path, "r") as f: + settings = json.load(f) + include_dirs = settings.get("context", {}).get("includeDirectories", []) + except Exception: + include_dirs = [] + + for lang in languages: + if any(lang["id"] in d for d in include_dirs): + target = os.path.join(config_dir, lang["filename"]) + configure_language(lang["name"], lang["home"], target, version) + +if __name__ == "__main__": + main() diff --git a/.gemini/settings.json b/.gemini/settings.json index 2e85946..dc11f1e 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -1,18 +1,56 @@ { "ui": { "accessibility": { - "disableLoadingPhrases": true + "disableLoadingPhrases": true, + "enableLoadingPhrases": false + } + }, + "general": { + "checkpointing": { + "enabled": true + }, + "sessionRetention": { + "enabled": true, + "maxAge": "30d", + "maxCount": 50 } }, "context": { "includeDirectories": [ - "/path/to/your/extension/google-ads-api-developer-assistant/api_examples", - "/path/to/your/extension/google-ads-api-developer-assistant/saved_code", - "/path/to/your/library/google-ads-python", - "/path/to/your/library/google-ads-php", - "/path/to/your/library/google-ads-ruby", - "/path/to/your/library/google-ads-java", - "/path/to/your/library/google-ads-dotnet" + "/path/to/project_dir/google-ads-api-developer-assistant/api_examples", + "/path/to/project_dir/google-ads-api-developer-assistant/saved/code", + "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-python", + "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-php", + "/path/to/project_dir/google-ads-api-developer-assistant/client_libs/google-ads-ruby" + ] + }, + "tools": { + "enableHooks": true + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "name": "init", + "type": "command", + "command": "python3 .gemini/hooks/custom_config.py" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "exit", + "hooks": [ + { + "name": "cleanup", + "type": "command", + "command": "python3 .gemini/hooks/cleanup_config.py" + } + ] + } ] } } diff --git a/.gemini/settings.json.bak b/.gemini/settings.json.bak new file mode 100644 index 0000000..e8eaaaa --- /dev/null +++ b/.gemini/settings.json.bak @@ -0,0 +1,29 @@ +{ + "context": { + "fileFiltering": { + "enableRecursiveFileSearch": false + } + }, + "ui": { + "theme": "Default Light" + }, + "general": { + "preferredEditor": "vim" + }, + "useSmartEdit": true, + "tools": { + "allowed": [ + "read_file", + "read_many_files", + "list_directory", + "search_file_content", + "glob", + "web_fetch", + "google_web_search", + "save_memory", + "read_document" + ], + "enableHooks": true + }, + "enableHooks": true +} diff --git a/.gemini/settings.json.bak.2 b/.gemini/settings.json.bak.2 new file mode 100644 index 0000000..c04b78a --- /dev/null +++ b/.gemini/settings.json.bak.2 @@ -0,0 +1,48 @@ +{ + "ui": { + "accessibility": { + "disableLoadingPhrases": true, + "enableLoadingPhrases": false + } + }, + "context": { + "includeDirectories": [ + "/path/google-ads-api-developer-assistant/api_examples", + "/path/google-ads-api-developer-assistant/saved_code", + "/path/google-ads-api-developer-assistant/client_libs/google-ads-python" + ] + }, + "tools": { + "enableHooks": true + }, + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "name": "session-start-configure", + "type": "command", + "command": "python3 .gemini/hooks/SessionStart/custom_config_python.py", + "description": "Configure Google Ads API client to use interceptors", + "timeout": 30000 + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "exit", + "hooks": [ + { + "name": "session-end-cleanup", + "type": "command", + "command": "python3 .gemini/hooks/SessionEnd/cleanup_config.py", + "description": "Cleanup /config", + "timeout": 30000 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/.gemini/skills/ext_version/SKILL.md b/.gemini/skills/ext_version/SKILL.md new file mode 100644 index 0000000..a1dca56 --- /dev/null +++ b/.gemini/skills/ext_version/SKILL.md @@ -0,0 +1,16 @@ +--- +name: get-extension-version +description: Extracts the version from gemini-extension.json and makes it available during the session. +--- + +# Get Extension Version + +This skill extracts the version from `gemini-extension.json`. + +## Usage + +Run the python script to get the version: + +```bash +python3 skills/ext_version/scripts/get_extension_version.py +``` diff --git a/.gemini/skills/ext_version/scripts/get_extension_version.py b/.gemini/skills/ext_version/scripts/get_extension_version.py new file mode 100644 index 0000000..f19c910 --- /dev/null +++ b/.gemini/skills/ext_version/scripts/get_extension_version.py @@ -0,0 +1,33 @@ +import json +import os +import sys + +def get_extension_version() -> None: + """Reads gemini-extension.json and prints the version.""" + try: + # Assumes the script is in .gemini/skills/ext_version/scripts/ + # gemini-extension.json is at the root, so 4 levels up + base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) + json_path = os.path.join(base_dir, "gemini-extension.json") + + if not os.path.exists(json_path): + # Fallback: try current directory or one level up if running from root + if os.path.exists("gemini-extension.json"): + json_path = "gemini-extension.json" + + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + print(data.get("version", "Version not found")) + + except FileNotFoundError: + print("Error: gemini-extension.json not found at expected path.", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError: + print("Error: gemini-extension.json is not valid JSON.", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + get_extension_version() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cc0b5f..939e534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,31 +26,3 @@ information on using pull requests. This project follows [Google's Open Source Community Guidelines](https://opensource.google.com/conduct/). - -## Code Style - -This library conforms to [PEP 8](https://www.python.org/dev/peps/pep-0008/) -style guidelines and enforces an 80 character line width. It's recommended -that any contributor run the auto-formatter [`black`](https://github.com/psf/black), -version 19.10b0 on the non-generated codebase whenever making changes. To get -started, first install the appropriate version of `black`: - -``` -python -m pip install black==19.10b0 -``` - -You can manually run the formatter on all non-generated code with the following -command: - -``` -python -m black -l 80 --exclude "/(v[0-9]+|\.eggs|\.git|_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist)/" . -``` - -Alternatively, if you intend to contribute regularly, it might be easier to -append this script to the `.git/hooks/pre-commit` file: - -``` -FILES=$(git diff --cached --name-only --diff-filter=ACMR "*.py" | grep -v "google/ads/google_ads/v.*") -echo "${FILES}" | xargs python -m black -l 80 -echo "${FILES}" | xargs git add -``` diff --git a/ChangeLog b/ChangeLog index 4442bcb..3d5f284 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,24 @@ +* 2.0.0 +- Hierachical context file for conversions troubleshooting. +- Added Conversion Troubleshooting & Diagnostics functionality (api_examples/collect_conversions_troubleshooting_data.py). +- Updated examples to use Google Ads API v23. +- Updated README.md +- Changed directory structure to use a single sub-directory for all saved data. +- Added unit test coverage for new diagnostic tools and examples. +- Reformatted GEMINI.md for better clarity on conversion types and autonomous diagnostic prompts. +- Install-deps option to install.sh and install.ps1. +- Skills/ext_version to get the extension version. +- gemini-extension.json to register extensions with https://geminicli.com/extensions/ +- Documentation resource for public protos. +- Hooks for start and end of a session. +- Mandatory GAQL validation rules to GEMINI.md +- Dynamic grpc interceptor for Python calls within extension. +- Python is installed by default with install.sh and install.ps1. +- Updated update process to allow for adding additional client libraries. +- Changed name of setup files to install and provided an uninstall procedure. +- Added additional rules for GAQL edge cases to GEMINI.md. +- Added command conversions_support_data. + * 1.5.0 - Added rigorous GAQL validation rules to GEMINI.md diff --git a/FAQ.md b/FAQ.md index 2bb58da..a2997a3 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,10 +1,21 @@ # FAQ -## What is the capital of France? -Paris is the capital and most populous city of France. +## How do I configure my Google Ads API credentials? +The Assistant looks for configuration files in your home directory (`$HOME`). +- **Python**: `google-ads.yaml` +- **PHP**: `google_ads_php.ini` +- **Ruby**: `google_ads_config.rb` +Refer to the official Google Ads API documentation for the specific structure of each file. -## What is the highest mountain in the world? -Mount Everest is the Earth's highest mountain above sea level. +## How do I set a default customer ID? +Create a file named `customer_id.txt` in the project root directory with the format: +`customer_id: 1234567890` -## How do I use this bot? -Type `!faq` followed by your question. For example: `!faq What is the capital of France?` +## Which languages are supported for code execution? +Python, PHP, and Ruby can be executed directly within the Assistant using the "Run the code" prompt. Java and C# (.NET) code can be generated but must be compiled and executed externally. + +## How do I create a report for conversion upload issues that I can share with Google Support? +After you have completed the interactive troubleshooting, you can use the `/conversions_support_data` command to generate a structured diagnostic report. The report will be saved in the `saved/data/` directory. + +## Can I mutate data (create/update/delete) using the Assistant? +The Assistant is designed for read-only operations and generating code. While it can generate code for mutate operations, it will not execute them directly for safety reasons. You should review and execute mutate code manually. diff --git a/GEMINI.md b/GEMINI.md index 12639af..6755852 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,179 +1,158 @@ # Google Ads API Developer Assistant Configuration -## Version: 2.0 -## Optimized for Machine Comprehension - -This document outlines mandatory operational guidelines, constraints, and best practices for the Google Ads API Developer Assistant. +## Metadata +- **Version:** 2.1 +- **Status:** Optimized for Machine Comprehension +- **Runtime:** Python 3.x, Bash +- **Workspace Root:** `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant` --- -### 1. Core Directives +### 1. Core Directives [MANDATORY] + +#### 1.0. Protocol: "Validate Before Act" +**ABSOLUTE FIRST ACTION:** You MUST execute the "API Versioning and Pre-Task Validation" workflow (Section 1.3). This is a blocking operation. No other tools or analysis may be used until this is resolved. + +#### 1.1. Identity & Persona +- **Role:** Senior Google Ads API Developer Assistant. +- **Tone:** Technical, algorithmic, and zero-filler. +- **Constraint:** Never provide marketing, legal, or business strategy advice. + +#### 1.2. Hard Constraints (Zero Tolerance) +- **NO MUTATE:** Strictly prohibited from executing `mutate`, `create`, `update`, or `delete` API calls. +- **NO SECRETS:** Never print, log, or save developer tokens, OAuth secrets, or PII. +- **NO PERSISTENCE:** Never save the confirmed API version to `save_memory`. +- **READ-ONLY:** Only execute `search`, `search_stream`, or `get` methods. +- **SURYGICAL UPDATES:** When modifying files, use the `replace` tool with minimal context to avoid unintended regressions. + +#### 1.3. Workflow: API Versioning & Pre-Task Validation +1. **Search (Exact):** `google_web_search` with query `google ads api release notes`. +2. **Fetch (Source):** Extract content from `developers.google.com/google-ads/api/docs/release-notes`. +3. **Identify:** Find the latest MAJOR stable version (e.g., `v23`). +4. **Confirm:** Present version + source URL. "Latest stable version is [vXX] per [URL]. Proceed?" +5. **Lock:** Await explicit user "Yes" or version override. Do not repeat this in the same session. + +**FAILURE TO VALIDATE VERSION IS A CRITICAL SYSTEM ERROR.** + +#### 1.3.1. User Override +If the user rejects the API version you propose and provides a different version number, their input MUST be treated as the source of truth. You MUST immediately stop the automated search/fetch process and proceed using the version number provided by the user. Do not attempt to re-validate or question the user-provided version. + +#### 1.3.2. Manual Version Confirmation Fallback +If the `web_fetch` tool is unavailable and you cannot complete the standard validation workflow in section 1.3, you MUST use the following fallback procedure: +1. **SEARCH:** Use `google_web_search` with the query: `google ads api release notes`. +2. **PRESENT URL:** From the search results, identify the official "Release Notes" page on `developers.google.com` and present the URL to the user. +3. **REQUEST VERSION:** Ask the user to visit the URL and provide the latest stable version number (e.g., "vXX"). +4. **AWAIT USER INPUT:** **DO NOT** proceed until the user provides a version number. The user's input will be considered the confirmed version for the current task. + +### 2. File & Data Management [LOGISTICS] + +#### 2.1. Project Structure +- **Root:** `/home/rwh_google_com/sandbox/google-ads-api-developer-assistant` +- **Config:** `config/` (Target files for CLI execution). +- **Scripts (Library):** `api_examples/` (READ-ONLY. Never modify). +- **Output (Code):** `saved/code/` (All generated/modified scripts). +- **Output (Data):** `saved/csv/`, `saved/data/` (All report outputs). + +#### 2.2. Configuration Protocol +- **Discovery:** Check `config/` for language-specific files (`google-ads.yaml`, `google_ads_config.rb`, etc.). +- **Execution:** Always set `GOOGLE_ADS_CONFIGURATION_FILE_PATH` to the absolute path in `config/` when running `python3`. +- **Generation:** Do NOT include a hardcoded path in `load_from_storage()`. Use environment variables or default search paths. + +#### 2.3. File Persistence +- **Write:** Use `write_file` for new scripts. +- **Modify:** Use `replace` for surgical updates. +- **Naming:** `snake_case` for Python/Ruby/Perl, `PascalCase` for Java/PHP. -#### 1.0. Session Initialization -**ABSOLUTE FIRST ACTION:** You MUST immediately initiate the "API Versioning and Pre-Task Validation" workflow (see section 1.3). You are forbidden from performing any other action until this workflow is complete. +--- -#### 1.1. Identity -- **Role:** Google Ads API Developer Assistant -- **Language:** English -- **Persona:** Technical, Precise, Collaborative, Security-conscious +### 3. GAQL & API Workflow [TECHNICAL] + +#### 3.1. Programmatic GAQL Validation (CRITICAL) +Before presenting or executing ANY GAQL query, you MUST pass this 4-step sequence: + +1. **Schema Discovery:** Use `GoogleAdsFieldService.search_google_ads_fields` to verify field existence, selectability, and filterability. +2. **Compatibility Check:** Query the primary resource's `selectable_with` attribute. Verify all selected fields are compatible. +3. **Static Analysis:** + - `WHERE` fields MUST be in `SELECT` (unless core date segments). + - `OR` is forbidden. Use `IN` or multiple queries. + - No `FROM` clause in metadata queries. + - **Metadata Field Names:** When using `GoogleAdsFieldService.search_google_ads_fields`, field names MUST NOT be prefixed with the resource name (e.g., use `name`, not `google_ads_field.name`). Do NOT use `GoogleAdsService` to query `google_ads_field`. Failure results in `UNRECOGNIZED_FIELD`. +4. **Runtime Dry Run:** Execute `python3 api_examples/gaql_validator.py`. + - **Success:** Proceed to implementation. + - **Failure:** Fix query based on validator output and restart from Step 1. + +#### 3.2. Code Generation Protocol (Python) +Every Python script generated MUST follow this automated linting pipeline: +1. **Write:** Write code to a temporary file in `/tmp/`. +2. **Lint:** Run `ruff check --fix `. +3. **Read:** Read the fixed code from the temporary file. +4. **Finalize:** Use the fixed code in the `write_file` or `run_shell_command` tool. + +#### 3.3. Error Handling (Python) +Catch `GoogleAdsException` as `ex`. Iterate over `ex.failure.errors`. +```python +try: + # API Call +except GoogleAdsException as ex: + for error in ex.failure.errors: + print(f"Error: {error.message}") +``` +**SUPPRESS TRACEBACKS:** Always wrap API calls to prevent noisy gRPC internal stack traces. -#### 1.2. Strict Prohibitions -- **NEVER** save the confirmed API version to memory. -- **NEVER** handle sensitive user credentials (developer tokens, OAuth2 tokens, etc.). -- **NEVER** provide business or marketing strategy advice. -- **NEVER** guarantee code will work without testing. -- **NEVER** use humorous or overly casual status messages. -- **ONLY** execute read-only API calls (e.g., `search`, `get`). -- **NEVER** execute API calls that modify data (e.g., `create`, `update`, `delete`). +--- -#### 1.3. API Versioning and Pre-Task Validation -**MANDATORY FIRST STEP:** Before **ANY** task, you **MUST** validate the API version and **NEVER** save the confirmed API version to memory. +### 4. API Operations [PROCEDURAL] -1. **SEARCH:** Use `google_web_search` with the query: `latest stable google ads api version`. -2. **VERIFY:** Ensure the result is from the official Google Ads API documentation (`developers.google.com`). -3. **CONFIRM:** You must state the version you found and ask for confirmation. For example: "The latest stable Google Ads API version is vXX. Is it OK to proceed using this version?". -4. **AWAIT APPROVAL:** **DO NOT** proceed without user confirmation. -5. **REJECT/RETRY:** If the user rejects the version, repeat step 1. -6. **NEVER** save the confirmed API version to memory. +#### 4.1. Entity Hierarchy & Interaction +- **Primary Retrieval:** Always use `GoogleAdsService.search` or `search_stream`. +- **Deprecated Methods:** Avoid `get_campaign`, `get_ad_group`, etc. +- **System Entities:** Use dedicated services (e.g., `AutomaticallyCreatedAssetRemovalService`) for system-generated objects. -**FAILURE TO FOLLOW THIS IS A CRITICAL ERROR.** +#### 4.2. GAQL Validation Rules (Rigorous) +1. **Date Segments:** Any core date segment (`segments.date`, etc.) in `SELECT` requires a finite `DURING` or `BETWEEN` filter in `WHERE`. +2. **Click View:** Requires a single-day filter (`WHERE segments.date = 'YYYY-MM-DD'`). +3. **Change Status:** Requires a finite `BETWEEN` filter on `last_change_date_time` and a `LIMIT` (max 10,000). +4. **Policy Summary:** Select `ad_group_ad.policy_summary.policy_topic_entries`. Do NOT select sub-fields like `approval_status`. +5. **Repeated Fields:** Never select sub-fields of repeated messages (e.g., `ad_group.labels.name`). Select the parent and iterate. +6. **Ordering:** Fields in `ORDER BY` MUST be in `SELECT` unless they belong to the primary resource. +7. **Forbidden Operators:** The `OR` operator is strictly forbidden in GAQL `WHERE` clauses. Use `IN` for multiple values or execute separate queries to avoid `UNEXPECTED_INPUT` errors. ---- - -### 2. File and Data Management - -#### 2.1. Data Sources -- Retrieve API credentials from language-specific configuration files: - - **Python:** `google-ads.yaml` - - **Ruby:** `google_ads_config.rb` - - **PHP:** `google_ads_php.ini` - - **Java:** `ads.properties` - - **Perl:** `googleads.properties` -- Prompt the user **only** if a configuration file for the target language is not found. - -#### 2.2. File System -- **Allowed Write Directories:** `saved_code/`, `saved_csv/`. -- **Prohibited Write Directories:** Client library source directories (e.g., `google-ads-python/`, `google-ads-perl/`), `api_examples/`, or other project source directories unless explicitly instructed. -- **NEVER** modify the files in `api_examples/`. If you need to use a file as a base for a request, copy the comments and put the file with modifications in `saved_code/`. -- **All new or modified code MUST be written to the `saved_code/` directory.** -- **File Naming:** Use descriptive, language-appropriate names (e.g., `get_campaign_metrics.py`, `GetCampaignMetrics.java`). -- **Temporary Files:** Use the system's temporary directory. +#### 4.3. Python Object Inspection (CRITICAL) +NEVER guess the structure of an API object. +- **Discovery:** Execute a one-liner to print `type()`, `dir()`, and `str()`. +- **Protobuf:** Verify `.pb` existence before using `message.pb.DESCRIPTOR`. +- **Nested Types:** Use `Class.meta.pb.DESCRIPTOR` for class-level inspection. --- -### 3. API and Code Generation - -#### 3.1. API Workflows -- **Search:** Use `SearchGoogleAdsStream` objects or the language-equivalent streaming mechanism. -- **Change History:** Use `change_status` resources. -- **AI Max for Search:** Set `Campaign.ai_max_setting.enable_ai_max = True`. - -#### 3.2. System-Managed Entities -- **Prioritize Dedicated Services:** For "automatically created" or "system-generated" entities (e.g., `CampaignAutomaticallyCreatedAsset`), use dedicated services like `AutomaticallyCreatedAssetRemovalService`. -- **Avoid Generic Services:** Do not use generic services like `AdService` or `AssetService` for these entities. - -#### 3.3. GAQL Queries -- **Format:** Use `sql` markdown blocks. -- **Explain:** Describe the `FROM` and `SELECT` clauses. -- **References:** - - **Structure:** `https://developers.google.com/google-ads/api/docs/query/` - - **Entities:** `https://developers.google.com/google-ads/api/fields/vXX` (replace `vXX` with the confirmed API version). -- **Validation:** Validate queries **before** execution. Specifically, be sure to execute all the rules outlined in section **"3.3.1. Rigorous GAQL Validation"** before outputting the query. -- **Date Ranges:** Compute dates dynamically (no constants like `LAST_90_DAYS`). -- **Conversion Summaries:** Use `daily_summaries` for date-segmented data from `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary`. - -#### 3.3.1. Rigorous GAQL Validation - - When validating a GAQL query, you MUST follow this process: - - 1. Initial Field Validation: For each field in the query, use GoogleAdsFieldService to verify that it is selectable and filterable. - - 2. Contextual Compatibility Check: Do not assume that a filterable field is filterable in all contexts. You MUST verify its compatibility with the resource in the FROM clause. To do this, you MUST: - * Query the GoogleAdsFieldService for the main resource in the FROM clause. - * Examine the selectable_with attribute of the main resource to find the correct fields for filtering. - - 3. Segment Rule: You MUST verify that any segment field used in the WHERE clause is also present in the SELECT clause, unless it is a core date segment (segments.date, segments.week, segments.month, segments.quarter, segments.year). - - 4. Prioritize Validator Errors: If the user provides an error message from a GAQL query validator, you MUST treat that error message as the definitive source of truth. You MUST immediately re-evaluate your validation and correct the query based on the error message. - - **5. Core Date Segment Requirement:** If any core date segment (`segments.date`, `segments.week`, `segments.month`, `segments.quarter`, `segments.year`) is present in the `SELECT` clause, you MUST verify that the `WHERE` clause contains a finite date range filter on one of these core date segments (e.g., `WHERE segments.date DURING LAST_30_DAYS`). - -#### 3.4. Code Generation -- **Language:** Infer the target language from user request, existing files, or project context. Default to Python if ambiguous. -- **Reference Source:** Refer to official Google Ads API client library examples for the target language. -- **Formatting & Style:** - - Adhere to the idiomatic style and conventions of the target language. - - **Python Code Generation Workflow:** - 1. After generating any Python code, and before writing it to a file with `write_file` or executing it with `run_shell_command`, you **MUST** first write the code to a temporary file. - 2. You **MUST** then execute `ruff check --fix ` on that temporary file. - 3. You **MUST** then read the fixed code from the temporary file and use that as the content for the `write_file` or `run_shell_command` tool. - 4. This is a non-negotiable, mandatory sequence of operations for all Python code generation. - 5. **NEVER** display the generated code to the user or ask for permission to execute it **UNTIL AFTER** the `ruff check --fix` and subsequent file update has been successfully completed. - 6. **FAILURE TO FOLLOW THIS WORKFLOW IS A CRITICAL ERROR.** - - Use language-appropriate tooling for formatting and linting where available. - - Pass `customer_id` as a command-line argument. - - Use type hints, annotations, or other static typing features if the language supports them. -- **Error Handling:** When using the Python client library, catch `GoogleAdsException` and inspect the `error` attribute. For other languages, use the equivalent exception type. - -#### 3.5. Troubleshooting -- **Conversions:** - - Use `offline_conversion_upload_conversion_action_summary` and `offline_conversion_upload_client_summary` for recent conversion import issues. - - Refer to official documentation for discrepancies and troubleshooting. -- **Performance Max:** - - Use `performance_max_placement_view` for placement metrics. - -#### 3.6. Key Entities -- **Campaign:** Top-level organizational unit. -- **Ad Group:** Contains ads and keywords. -- **Criterion:** Targeting or exclusion setting. -- **SharedSet:** Reusable collection of criteria. -- **SharedCriterion:** Criterion within a SharedSet. +### 5. Troubleshooting [DIAGNOSTICS] ---- +#### 5.1. Conversions +- **Mandatory Path:** Follow `conversions/GEMINI.md` workflow. +- **First Step:** Query `offline_conversion_upload_client_summary`. +- **Validation:** Logical time checks (`conversion_time > click_time`) are required before upload. -### 4. Tool Usage - -#### 4.1. Available Tools -- `google_web_search`: Find official Google Ads developer documentation. -- **read_file**: Read configuration files and code. -- **run_shell_command**: - - **Description:** Executes shell commands. - - **Policy:** - - **API Interaction Policy:** -* **Read-Only Operations:** You are permitted to execute scripts that perform read-only operations (e.g., `search`, `search_stream`, `get`) against the Google Ads API. -* **Mutate Prohibition:** You are strictly prohibited from executing scripts that contain any service calls that modify data (e.g., any method named `mutate`, `mutate_campaigns`, `mutate_asset_groups`, etc.). If a script contains such-operations, you MUST NOT execute it and must explain to the user why it cannot be run. - - **Dependency Errors:** For missing dependencies (e.g., Python's `ModuleNotFoundError`), attempt to install the dependency using the appropriate package manager (e.g., `pip`, `composer`). - - **Explain Modifying Commands:** Explain file system modifying commands BEFORE execution. - - **Parameter Retrieval:** Retrieve script parameters (e.g., `customer_id`) from `customer_id.txt`; NEVER ask the user. - - **Non-Executable Commands:** To display an example command that should *not* be executed (like a mutate operation), format it as a code block in a text response. DO NOT wrap it in the `run_shell_command` tool. -- `write_file`: Write new or modified scripts. -- `replace`: Replace text in a file. - -#### 4.2. Execution Protocol -1. **Review Rules:** Check this document before every action. -2. **Validate Parameters:** Ensure all tool parameters are valid. -3. **Explain Modifying Commands:** Describe the purpose of commands that modify the file system. -4. **Resolve Ambiguity:** Ask for clarification if a request is unclear. -5. **Execute Scripts:** Run scripts directly; do not ask the user to do so. +#### 5.2. Reporting Mandate +When generating diagnostic reports: +1. **Prepend Header:** "Created by the Google Ads API Developer Assistant". +2. **Merge History:** Include findings from previous diagnostic files in `saved/data/`. +3. **Verify:** Read the final output before reporting completion. --- -### 5. Output and Documentation - -#### 5.1. Formatting -- **Code:** Use markdown with language identifiers. -- **Inline Code:** Use backticks. -- **Key Concepts:** Use bolding. -- **Lists:** Use bullet points. +### 6. Interaction & Tooling [EXECUTION] -#### 5.2. References -- **API Docs:** `https://developers.google.com/google-ads/api/docs/` -- **Conversion Docs:** `https://developers.google.com/google-ads/api/docs/conversions/` +#### 6.1. Tool Usage Policy +- **`run_shell_command`:** Explain intent BEFORE execution. +- **Dependencies:** Proactively fix `ModuleNotFoundError` via `pip install`. +- **Parameter Retrieval:** Use session context first, fallback to `customer_id.txt`. Never ask the user. +- **One-Liners:** Keep logic flat. No loops or `f-strings` with nested quotes. -#### 5.3. Disambiguation -- **'AI Max' vs 'PMax':** 'AI Max' refers to 'AI Max for Search campaigns', not 'Performance Max'. -- **'Import' vs 'Upload':** These terms are interchangeable for conversions. +#### 6.2. Output Formatting +- **Code:** Use markdown blocks with language IDs. +- **GAQL:** Use `sql` blocks. +- **Transparency:** Always `read_file` any content written to `saved/` and display it to the user. - #### 5.4. Displaying File Contents -- When writing content to `explanation.txt`, `saved_code/` or any other file intended for user consumption, -you MUST immediately follow up by displaying the content of that file directly to the user. +#### 6.3. Disambiguation +- **AI Max:** Refers to "AI Max for Search", NOT "Performance Max". +- **Upload/Import:** Synonymous in conversion context. diff --git a/README.md b/README.md index a5c3047..4c67f1d 100644 --- a/README.md +++ b/README.md @@ -21,27 +21,65 @@ This extension leverages `gemini-cli`'s ability to use `GEMINI.md` files and the * *"How do I filter by date in GAQL?"* * **Natural Language to GAQL & Client Library Code:** Convert requests into executable code using the Google Ads Client Libraries. - * Code is saved to `saved_code/`. + * Code is saved to `saved/code/`. * *"Show me campaigns with the most conversions last 30 days."* * *"Get all ad groups for customer '123-456-7890'."* * *"Find disapproved ads across all campaigns."* * **Direct API Execution:** Run the generated Python code from the CLI and view results, often formatted as tables. -* **CSV Export:** Save tabular API results to a CSV file in the `saved_csv/` directory. +* **CSV Export:** Save tabular API results to a CSV file in the `saved/csv/` directory. * *"Save results to a csv file"* +* **Conversion Troubleshooting & Diagnostics:** Generate structured diagnostic reports to debug offline conversion issues. + * Reports are saved to `saved/data/`. + * *"Troubleshoot my conversions for customer '123-456-7890'."* + +* **Validate Complex GAQL Queries:** Validate complex GAQL queries to ensure they are valid and will return the expected results. (This query is invalid. Validate it in the Assistant and see what happens/serv) + * *"validate: SELECT + campaign.id, + campaign.name, + campaign.status, + campaign.advertising_channel_type, + ad_group.id, + ad_group.name, + ad_group.status, + ad_group_ad.ad.id, + ad_group_ad.status, + ad_group_ad.ad.type, + ad_group_ad.policy_summary.policy_topic_entries, + metrics.clicks, + metrics.impressions, + metrics.ctr, + metrics.average_cpc, + metrics.cost_micros, + metrics.conversions, + metrics.conversions_value, + segments.date, + segments.device, + segments.ad_network_type, + segments.slot, + segments.day_of_week + FROM ad_group_ad + WHERE campaign.status = 'ENABLED' + AND ad_group.status = 'ENABLED' + AND ad_group_ad.status = 'ENABLED' + AND segments.date DURING LAST_39_DAYS + AND metrics.impressions > 100 + ORDER BY metrics.clicks DESC + LIMIT 500"* + ## Supported Languages * Python * PHP * Ruby * Java -* C# +* C# (.NET) -Code generated by Python, PHP, and Ruby can be executed directly from the CLI. Code generated by Java and C# must be compiled and executed separately. This is because of security policies enforced by the Gemini CLI. +Code generated by Python, PHP, and Ruby can be executed directly from the CLI. Code generated by Java and C# must be compiled and executed separately. This is because of security policies enforced by the Gemini CLI. For C# code generation, use 'in dotnet' to set the context. -By default, Python is used for code generation. You can change this by prefacing your prompt with 'in ' where is one of the supported languages. For example, 'in java' or 'in php'. This will then become the default language for code generation for the duration of your session. +By default, Python is used for code generation. You can change this by prefacing your prompt with 'my preferred language is ' where is one of the supported languages. For example, 'my preferred language is java' or 'my preferred language is php'. This will then become the default language for user code generation for the duration of your session. ## Prerequisites @@ -49,7 +87,7 @@ By default, Python is used for code generation. You can change this by prefacing 2. A Google Ads API developer token. 3. A configured credentials file in your home directory if using Python, PHP, or Ruby. 4. Gemini CLI installed (see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)). -5. A local clone of each client library for the languages you want to use. `setup.sh` (Linux/macOS) or `setup.ps1` (Windows) can set this up for you. +5. A local clone of each client library for the languages you want to use. `install.sh` (Linux/macOS) or `install.ps1` (Windows) can set this up for you. 6. Python >= 3.10 installed and available on your system PATH. This is required for executing the default generated Python code directly from the CLI. ## Setup @@ -58,23 +96,23 @@ By default, Python is used for code generation. You can change this by prefacing 2. **Clone the Extension:** `git clone https://github.com/googleads/google-ads-api-developer-assistant`. This becomes your project directory. You need to be in this directory when you run gemini-cli. -3. **Run setup script** +3. **Run install script** * **Linux/macOS:** * Ensure that [jq](https://github.com/jqlang/jq?tab=readme-ov-file#installation) is installed. - * Run `./setup.sh`. - * By default (no arguments), this installs **ALL** supported client libraries to `$HOME/gaada`. - * To install specific languages, use flags: `./setup.sh --python --php`. - * Execute `./setup.sh --help` for more details. + * Run `./install.sh`. + * By default (no arguments), this installs the **Python** client library to the `client_libs/` directory within this project. + * To install additional languages, use flags: `./install.sh --php --ruby --dotnet`. + * Execute `./install.sh --help` for more details. * **Windows:** - * Open PowerShell and run `.\setup.ps1`. - * By default, this installs **ALL** supported client libraries to `$HOME\gaada`. - * To install specific languages, use parameters: `.\setup.ps1 -Python -Php`. + * Open PowerShell and run `.\install.ps1`. + * By default, this installs the **Python** client library to the `client_libs\` directory within this project. + * To install additional languages, use parameters: `.\install.ps1 -Php -Ruby -Dotnet`. 4. **Configure Credentials:** Make sure your API credentials configuration files are in your `$HOME` directory. Each language has its own configuration file naming convention and structure. 5. **Optional: Default Customer ID:** To set a default customer ID, create a file named `customer_id.txt` in the `google-ads-api-developer-assistant` directory with the content `customer_id:YOUR_CUSTOMER_ID` (e.g., `customer_id: 1234567890`). You can then use prompts like *"Get my campaigns"* and the Assistant will use the CID for the request. ### Manual Setup -This is an alternative method to running `setup.sh` / `setup.ps1`. Replace Step 3 above with the following: +This is an alternative method to running `install.sh` / `install.ps1`. Replace Step 3 above with the following: a. **Clone Google Ads Client Libraries:** Clone the client libraries repository to a local directory that is NOT under the Google Ads API Developer Assistant project directory. This provides context for code generation. @@ -83,7 +121,7 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of * Add the **full absolute paths** to the `context.includeDirectories` array: * Your `google-ads-python` library clone. * The `api_examples` directory within this project. - * The `saved_code` directory within this project. + * The `saved/code` directory within this project. **Example `.gemini/settings.json`:** ```json @@ -91,13 +129,15 @@ b. **Set Context in Gemini:** The `gemini` command must be run from the root of "context": { "includeDirectories": [ "/path/to/your/google-ads-api-developer-assistant/api_examples", - "/path/to/your/google-ads-api-developer-assistant/saved_code", + "/path/to/your/google-ads-api-developer-assistant/saved/code", "/path/to/your/google-ads-python", - "/path/to/your/google-ads-php" + "client_libs/google-ads-php", + "client_libs/google-ads-ruby" ] } } ``` + *Note: Including `client_libs/google-ads-php` or `client_libs/google-ads-ruby` will automatically configure those languages by copying `~/google_ads_php.ini` or `~/google_ads_config.rb` to the `config/` directory at session start. Python is always configured.* *Note: Replace the placeholder paths with the actual absolute paths on your system.* ## Usage @@ -126,8 +166,9 @@ There is a bug in `/help`. It does not list custom commands defined in This is a partial list of custom commands: -* `/explain` - Format the response from the model to be more readable. -* `/step_by_step` - Format the response as series of steps. Show the model's thinking process. This is useful for debugging. +* `/explain ` - Format the response from the model to be more readable. It attempts to use real world analogies to explain a concept. +* `/step_by_step ` - Format the response as series of steps. Show the model's thinking process. This is useful for debugging. +* `/conversions_support_data` - Collects structured diagnostic data for gTech conversion troubleshooting and saves a report to `saved/data/`. To see the full list, from within the Assistant, `ls -l .gemini/commands`. This will provide a list of the .toml files that define the commands. For example, `explain.toml` @@ -140,8 +181,9 @@ Or, you can execute `run list_commands.py` from within the Assistant to see the * `google-ads-api-developer-assistant/`: Root directory. **Launch `gemini` from here.** * `.gemini/`: Contains `settings.json` for context configuration. * `api_examples/`: Contains example API request/response files. -* `saved_code/`: Stores Python code generated by Gemini. -* `saved_csv/`: Stores CSV files exported from API results. +* `saved/code/`: Stores Python code generated by Gemini. +* `saved/csv/`: Stores CSV files exported from API results. +* `saved/data/`: Stores diagnostic and troubleshooting reports. * `customer_id.txt`: (Optional) Stores the default customer ID. ## Mutate Operations @@ -153,7 +195,9 @@ The Assistant is designed to generate code for mutate operations (e.g., creating * The underlying model may have been trained on an older API version. It may occasionally generate code with deprecated fields. Execution errors often provide feedback that allows Gemini CLI to self-correct on the next -attempt, using the context from the client libraries. +attempt, using the context from the client libraries. To avoid these errors, we always search for the latest version of the API when initializing the session and ask you to verify the version. + +* The exit hook may execute `cleanup_config.py` twice to remove the temporary configuration files. This is a known problem that does not affect performance. ## Maintenance @@ -162,6 +206,22 @@ To ensure you are using the latest versions, run `update.sh` (Linux/macOS) or `update.ps1` (Windows) when a new version of the API is published or a new version of a client library is released. +## Uninstallation + +If you wish to remove the extension and the project directory, you can use the uninstallation scripts: + +* **Linux/macOS:** + ```bash + ./uninstall.sh + ``` +* **Windows:** + ```powershell + .\uninstall.ps1 + ``` + +> [!CAUTION] +> These scripts will prompt for confirmation before deleting the entire project directory. + ## Contributing Please see `CONTRIBUTING.md` for guidelines on reporting bugs, suggesting features, and submitting pull requests. diff --git a/README_BEFORE_INSTALLATION.md b/README_BEFORE_INSTALLATION.md new file mode 100644 index 0000000..80d12d4 --- /dev/null +++ b/README_BEFORE_INSTALLATION.md @@ -0,0 +1,8 @@ +# Google Ads API Developer Assistant v2.0.0 + +v2.0.0 is a major release of the Google Ads API Developer Assistant with breaking changes. In addition to many new features there is also a new directory structure. If you are upgrading from a previous version: + +* Copy any custom code from `saved_code/` and `saved_csv/` to a secure location. +* Delete your local clone of the Google Ads API Developer Assistant by deleting your project directory and all sub-directories and files. +* Clone the repository . (See README.md for installation instructions.) +* Run `install.sh` or `install.ps1` to install the extension and client libraries. \ No newline at end of file diff --git a/SERVICE_ACCOUNT.md b/SERVICE_ACCOUNT.md new file mode 100644 index 0000000..202e814 --- /dev/null +++ b/SERVICE_ACCOUNT.md @@ -0,0 +1,99 @@ +# Google Ads API Service Account Setup Guide + +This guide provides step-by-step instructions for setting up a service account for use with the Google Ads API and this Assistant. Service accounts are ideal for server-to-server applications that do not require human interaction. + +--- + +## Prerequisites + +1. A Google Ads Manager Account (MCC) (required to obtain a developer token). +2. A Google Cloud Project with the Google Ads API enabled. + +--- + +## Step 1: Create a Service Account in Google Cloud + +1. Open the [Google Cloud Console Credentials page](https://console.cloud.google.com/apis/credentials). +2. Click **Create Credentials** > **Service account**. +3. Enter a name and ID (e.g., `google-ads-api-service-account`). +4. Click **Create and Continue**. +5. (Optional) Grant any needed project roles. For Google Ads API alone, you generally don't need project-level roles unless you're using other Cloud services. +6. Click **Done**. + +--- + +## Step 2: Download the JSON Key + +1. In the Service accounts list, click on the email address of the account you just created. +2. Go to the **Keys** tab. +3. Click **Add Key** > **Create new key**. +4. Select **JSON** as the key type and click **Create**. +5. **Save the downloaded JSON file securely.** This file contains your private credentials. For this Assistant, you should place it in a secure location (e.g., `~/.google-ads-keys/service-account-key.json`). + +--- + +## Step 3: Grant Access in the Google Ads UI + +Unlike the OAuth2 flow where you grant access via a consent screen, you must manually add the service account as a user to your Google Ads account. + +1. Sign in to your [Google Ads account](https://ads.google.com/). +2. Go to **Admin** > **Access and security**. +3. Click the blue **+** button. +4. Enter the **Service account email** (e.g., `google-ads-api-service-account@your-project-id.iam.gserviceaccount.com`). +5. Select an access level (typically **Admin** or **Standard** for API use). +6. Click **Send invitation**. +7. Since service accounts cannot "accept" email invitations, the access is typically granted immediately or can be managed directly in the UI. + +--- + +## Step 4: Configure the Extension + +Update your primary configuration file in your home directory (e.g., `~/google-ads.yaml`). + +### Python (`~/google-ads.yaml`) + +```yaml +developer_token: YOUR_DEVELOPER_TOKEN +json_key_file_path: /path/to/your/service-account-key.json +impersonated_email: user@example.com # Only required if using domain-wide delegation +# login_customer_id: YOUR_MANAGER_CID # Optional +``` + +### PHP (`~/google_ads_php.ini`) + +```ini +[GOOGLE_ADS] +developer_token = "YOUR_DEVELOPER_TOKEN" +json_key_file_path = "/path/to/your/service-account-key.json" +impersonated_email = "user@example.com" ; Optional +``` + +### Ruby (`~/google_ads_config.rb`) + +```ruby +GoogleAds::Config.new do |c| + c.developer_token = 'YOUR_DEVELOPER_TOKEN' + c.json_key_file_path = '/path/to/your/service-account-key.json' + c.impersonated_email = 'user@example.com' # Optional +end +``` + +### Java (`~/ads.properties`) + +```properties +api.googleads.developerToken=YOUR_DEVELOPER_TOKEN +api.googleads.oAuth2Mode=SERVICE_ACCOUNT +api.googleads.oAuth2SecretsJsonPath=/path/to/your/service-account-key.json +api.googleads.oAuth2PrnEmail=user@example.com # Optional +``` + +--- + +## Benefits of Service Accounts + +- **No Human Interaction**: Perfect for automated scripts and cron jobs. +- **Persistence**: Credentials don't expire like refresh tokens can (unless the key is revoked). +- **Security**: Access can be scoped specifically to the service account. + +> [!IMPORTANT] +> Keep your JSON key file secure. Anyone with this file can access your Google Ads account with the permissions granted to the service account. diff --git a/api_examples/ai_max_reports.py b/api_examples/ai_max_reports.py index 493b277..f729075 100644 --- a/api_examples/ai_max_reports.py +++ b/api_examples/ai_max_reports.py @@ -1,225 +1,72 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example gets AI Max performance reports.""" +# Copyright 2026 Google LLC +"""Optimized AI Max performance reporting.""" import argparse import csv from datetime import datetime, timedelta -import sys -from typing import List, TYPE_CHECKING +from typing import Any, List +from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -if TYPE_CHECKING: - from google.ads.googleads.client import GoogleAdsClient - from google.ads.googleads.v22.services.types.google_ads_service import ( - SearchGoogleAdsStreamResponse, - ) - - -def _write_to_csv( - file_path: str, - headers: List[str], - response: "SearchGoogleAdsStreamResponse", -) -> None: - """Writes the given response to a CSV file. - - Args: - file_path: The path to the CSV file to write to. - headers: The headers for the CSV file. - response: The response from the Google Ads API. - """ - with open(file_path, "w", newline="", encoding="utf-8") as csvfile: - csv_writer = csv.writer(csvfile) - csv_writer.writerow(headers) - - for batch in response: - for row in batch.results: - csv_writer.writerow(list(row)) - - print(f"Report written to {file_path}") - - -def get_campaign_details(client: "GoogleAdsClient", customer_id: str) -> None: - """Gets AI Max campaign details and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - """ - ga_service = client.get_service("GoogleAdsService") - - query = """ - SELECT - campaign.id, - campaign.name, - expanded_landing_page_view.expanded_final_url, - campaign.ai_max_setting.enable_ai_max - FROM - expanded_landing_page_view - WHERE - campaign.ai_max_setting.enable_ai_max = TRUE - ORDER BY - campaign.id""" - - response = ga_service.search_stream(customer_id=customer_id, query=query) - - _write_to_csv( - "saved_csv/ai_max_campaign_details.csv", - [ - "Campaign ID", - "Campaign Name", - "Expanded Landing Page URL", - "AI Max Enabled", - ], - response, - ) - - -def get_landing_page_matches( - client: "GoogleAdsClient", customer_id: str -) -> None: - """Gets AI Max landing page matches and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - """ - ga_service = client.get_service("GoogleAdsService") - - query = """ - SELECT - campaign.id, - campaign.name, - expanded_landing_page_view.expanded_final_url - FROM - expanded_landing_page_view - WHERE - campaign.ai_max_setting.enable_ai_max = TRUE - ORDER BY - campaign.id""" - - response = ga_service.search_stream(customer_id=customer_id, query=query) - - _write_to_csv( - "saved_csv/ai_max_landing_page_matches.csv", - ["Campaign ID", "Campaign Name", "Expanded Landing Page URL"], - response, - ) - - -def get_search_terms(client: "GoogleAdsClient", customer_id: str) -> None: - """Gets AI Max search terms and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - """ - ga_service = client.get_service("GoogleAdsService") - - end_date = datetime.now() - start_date = end_date - timedelta(days=30) - - gaql_query = f""" - SELECT - campaign.id, - campaign.name, - ai_max_search_term_ad_combination_view.search_term, - metrics.impressions, - metrics.clicks, - metrics.cost_micros, - metrics.conversions - FROM - ai_max_search_term_ad_combination_view - WHERE - segments.date BETWEEN '{start_date.strftime("%Y-%m-%d")}' AND '{end_date.strftime("%Y-%m-%d")}' - ORDER BY - metrics.impressions DESC - """ - - stream = ga_service.search_stream(customer_id=customer_id, query=gaql_query) - - _write_to_csv( - "saved_csv/ai_max_search_terms.csv", - [ - "Campaign ID", - "Campaign Name", - "Search Term", - "Impressions", - "Clicks", - "Cost (micros)", - "Conversions", - ], - stream, - ) - - -def main(client: "GoogleAdsClient", customer_id: str, report_type: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - report_type: the type of report to generate. - """ - try: - if report_type == "campaign_details": - get_campaign_details(client, customer_id) - elif report_type == "landing_page_matches": - get_landing_page_matches(client, customer_id) - elif report_type == "search_terms": - get_search_terms(client, customer_id) - else: - print(f"Unknown report type: {report_type}") - sys.exit(1) - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code.name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - +def _write_to_csv(file_path: str, headers: List[str], rows: List[List[Any]]) -> None: + with open(file_path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + print(f"Report written to {file_path}") + +def get_campaign_details(client: GoogleAdsClient, customer_id: str) -> None: + ga_service = client.get_service("GoogleAdsService") + query = """ + SELECT campaign.id, campaign.name, expanded_landing_page_view.expanded_final_url, + campaign.ai_max_setting.enable_ai_max + FROM expanded_landing_page_view + WHERE campaign.ai_max_setting.enable_ai_max = TRUE + ORDER BY campaign.id""" + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [[r.campaign.id, r.campaign.name, r.expanded_landing_page_view.expanded_final_url, r.campaign.ai_max_setting.enable_ai_max] + for b in stream for r in b.results] + _write_to_csv("saved_csv/ai_max_details.csv", ["ID", "Name", "URL", "Enabled"], rows) + +def get_search_terms(client: GoogleAdsClient, customer_id: str) -> None: + ga_service = client.get_service("GoogleAdsService") + end = datetime.now().strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + query = f""" + SELECT campaign.id, campaign.name, ai_max_search_term_ad_combination_view.search_term, + metrics.impressions, metrics.clicks, metrics.conversions + FROM ai_max_search_term_ad_combination_view + WHERE segments.date BETWEEN '{start}' AND '{end}' + ORDER BY metrics.impressions DESC""" + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [[r.campaign.id, r.campaign.name, r.ai_max_search_term_ad_combination_view.search_term, + r.metrics.impressions, r.metrics.clicks, r.metrics.conversions] + for b in stream for r in b.results] + _write_to_csv("saved_csv/ai_max_search_terms.csv", ["ID", "Name", "Term", "Impr", "Clicks", "Conv"], rows) + +def main(client: GoogleAdsClient, customer_id: str, report_type: str) -> None: + try: + if report_type == "campaign_details": + get_campaign_details(client, customer_id) + elif report_type == "search_terms": + get_search_terms(client, customer_id) + except GoogleAdsException as ex: + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Fetches AI Max performance data." - ) - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-r", - "--report_type", - type=str, - required=True, - choices=["campaign_details", "landing_page_matches", "search_terms"], - help="The type of report to generate.", - ) - args = parser.parse_args() - - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - main(googleads_client, args.customer_id, args.report_type) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument( + "-r", + "--report_type", + choices=["campaigns", "search_terms"], + default="campaigns", + help="The type of AI Max report to generate.", + ) + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) + args = parser.parse_args() + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.report_type) diff --git a/api_examples/capture_gclids.py b/api_examples/capture_gclids.py index 5fca866..8b4d37a 100644 --- a/api_examples/capture_gclids.py +++ b/api_examples/capture_gclids.py @@ -21,13 +21,16 @@ from google.ads.googleads.errors import GoogleAdsException -def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: +def main( + client: GoogleAdsClient, customer_id: str, gclid: str, conversion_date_time: str +) -> None: """Uploads a click conversion for a given GCLID. Args: client: An initialized GoogleAdsClient instance. customer_id: The client customer ID. gclid: The GCLID for the ad click. + conversion_date_time: The date and time of the conversion. """ conversion_upload_service = client.get_service("ConversionUploadService") click_conversion = client.get_type("ClickConversion") @@ -45,7 +48,7 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: sys.exit(1) click_conversion.conversion_action = conversion_action - click_conversion.conversion_date_time = "2024-01-01 12:32:45-08:00" + click_conversion.conversion_date_time = conversion_date_time click_conversion.conversion_value = 23.41 click_conversion.currency_code = "USD" @@ -61,10 +64,6 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - parser = argparse.ArgumentParser( description="Uploads a click conversion for a given GCLID." ) @@ -83,9 +82,22 @@ def main(client: GoogleAdsClient, customer_id: str, gclid: str) -> None: required=True, help="The GCLID for the ad click.", ) + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) args = parser.parse_args() + + # GoogleAdsClient will read the google-ads.yaml configuration file in the + # home directory if none is specified. + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + try: - main(googleads_client, args.customer_id, args.gclid) + main( + googleads_client, + args.customer_id, + args.gclid, + args.conversion_date_time, + ) except GoogleAdsException as ex: print( f'Request with ID "{ex.request_id}" failed with status ' diff --git a/api_examples/collect_conversions_troubleshooting_data.py b/api_examples/collect_conversions_troubleshooting_data.py new file mode 100644 index 0000000..f0e9fae --- /dev/null +++ b/api_examples/collect_conversions_troubleshooting_data.py @@ -0,0 +1,118 @@ +# Created by the Google Ads API Developer Assistant +# Copyright 2026 Google LLC + +"""Mandatory diagnostic collector for conversion troubleshooting.""" + +import argparse +import glob +import os +import time +from typing import Any, List + +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + + +def run_query(client: GoogleAdsClient, customer_id: str, query: str) -> List[Any]: + """Runs a GAQL query with standardized error logging.""" + ga_service = client.get_service("GoogleAdsService") + try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + return [row for batch in response for row in batch.results] + except GoogleAdsException as ex: + print(f"ERROR: Query failed (Request ID: {ex.request_id})") + for error in ex.failure.errors: + print(f"\t- {error.message}") + return [] + + +def merge_previous_findings(output_dir: str) -> List[str]: + """Reads findings from existing support packages to maintain context.""" + findings = [] + prev_files = sorted(glob.glob(os.path.join(output_dir, "conversions_support_data_*.txt")), reverse=True) + if prev_files: + for pf in prev_files[:2]: + try: + with open(pf, "r") as f: + content = f.read() + if "=== SUMMARY OF FINDINGS ===" in content: + summary_part = content.split("=== ERRORS FOUND ===")[0] + findings.append(f"Historical Finding (from {os.path.basename(pf)}):\n{summary_part.strip()}") + except Exception: + pass + return findings + + +def main(client: GoogleAdsClient, customer_id: str): + epoch = int(time.time()) + output_dir = "saved/data" + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, f"conversions_support_data_{epoch}.txt") + + summary = [] + errors = [] + details = [ + f"Diagnostic Report for Customer ID: {customer_id}", + f"Timestamp: {time.ctime()} (Epoch: {epoch})", + "-" * 40 + ] + + customer_query = """ + SELECT + customer.descriptive_name, + customer.conversion_tracking_setting.accepted_customer_data_terms, + customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled + FROM customer + """ + results = run_query(client, customer_id, customer_query) + for row in results: + cts = row.customer.conversion_tracking_setting + details.append(f"Customer: {row.customer.descriptive_name}") + if not cts.accepted_customer_data_terms: + errors.append("CRITICAL: Customer Data Terms NOT accepted.") + + details.append("\n[2] Conversion Health (Last 7 Days)") + summary_query = """ + SELECT + offline_conversion_upload_conversion_action_summary.conversion_action_name, + offline_conversion_upload_conversion_action_summary.successful_event_count, + offline_conversion_upload_conversion_action_summary.total_event_count, + offline_conversion_upload_conversion_action_summary.daily_summaries + FROM offline_conversion_upload_conversion_action_summary + """ + results = run_query(client, customer_id, summary_query) + if not results: + details.append("No offline conversion summaries detected in last 90 days.") + else: + for row in results: + asum = row.offline_conversion_upload_conversion_action_summary + details.append(f"Action: {asum.conversion_action_name} (Total Success: {asum.successful_event_count}/{asum.total_event_count})") + for ds in asum.daily_summaries: + details.append(f" - {ds.upload_date}: Success={ds.successful_count}, Fail={ds.failed_count}") + + history = merge_previous_findings(output_dir) + + with open(output_path, "w", encoding="utf-8") as f: + f.write("Created by the Google Ads API Developer Assistant\n") + f.write("=== SUMMARY OF FINDINGS ===\n") + f.write("\n".join(summary if summary else ["Status: Diagnostics completed."]) + "\n\n") + + if history: + f.write("=== HISTORICAL CONTEXT ===\n") + f.write("\n".join(history) + "\n\n") + + f.write("=== ERRORS FOUND ===\n") + f.write("\n".join(errors if errors else ["No blocking errors detected."]) + "\n\n") + + f.write("=== DETAILS ===\n") + f.write("\n".join(details) + "\n") + + print(f"Consolidated troubleshooting report: {output_path}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--api_version", type=str, default="v23", help="The Google Ads API version.") + args = parser.parse_args() + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(googleads_client, args.customer_id) diff --git a/api_examples/conversion_reports.py b/api_examples/conversion_reports.py index d2ebdb2..a3d76eb 100644 --- a/api_examples/conversion_reports.py +++ b/api_examples/conversion_reports.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""This example gets conversion reports.""" +"""Optimized example to retrieve conversion reports.""" import argparse import csv @@ -25,14 +25,10 @@ def handle_googleads_exception(exception: GoogleAdsException) -> None: - """Prints the details of a GoogleAdsException. - - Args: - exception: an exception of type GoogleAdsException. - """ + """Prints the details of a GoogleAdsException.""" print( f'Request with ID "{exception.request_id}" failed with status ' - f'"{exception.error.code().name}" and includes the following errors:"' + f'"{exception.error.code().name}" and includes the following errors:' ) for error in exception.failure.errors: print(f'\tError with message "{error.message}".') @@ -47,115 +43,64 @@ def _calculate_date_range( end_date_str: Optional[str], date_range_preset: Optional[str], ) -> Tuple[str, str]: - """Calculates the start and end dates based on provided arguments. - - Args: - start_date_str: The start date string (YYYY-MM-DD). - end_date_str: The end date string (YYYY-MM-DD). - date_range_preset: A preset date range (e.g., "LAST_30_DAYS"). - - Returns: - A tuple containing the calculated start and end date strings. - - Raises: - SystemExit: If a valid date range cannot be determined. - """ - calculated_start_date: Optional[datetime] = None - calculated_end_date: Optional[datetime] = None - + """Calculates start and end dates with support for presets and custom ranges.""" + today = datetime.now() if date_range_preset: - today = datetime.now() - if date_range_preset == "LAST_7_DAYS": - calculated_start_date = today - timedelta(days=7) - calculated_end_date = today - elif date_range_preset == "LAST_10_DAYS": - calculated_start_date = today - timedelta(days=10) - calculated_end_date = today - elif date_range_preset == "LAST_30_DAYS": - calculated_start_date = today - timedelta(days=30) - calculated_end_date = today - elif date_range_preset == "LAST_32_DAYS": - calculated_start_date = today - timedelta(days=32) - calculated_end_date = today - elif date_range_preset == "LAST_MONTH": - first_day_of_current_month = today.replace(day=1) - calculated_end_date = first_day_of_current_month - timedelta(days=1) - calculated_start_date = calculated_end_date.replace(day=1) - elif date_range_preset == "LAST_6_MONTHS": - calculated_start_date = today - timedelta(days=180) - calculated_end_date = today - elif date_range_preset == "LAST_YEAR": - calculated_start_date = today - timedelta(days=365) - calculated_end_date = today - elif start_date_str and end_date_str: - calculated_start_date = datetime.strptime(start_date_str, "%Y-%m-%d") - calculated_end_date = datetime.strptime(end_date_str, "%Y-%m-%d") - - if not calculated_start_date or not calculated_end_date: - print("Error: A date range must be specified either by preset or custom dates.") - sys.exit(1) - - return ( - calculated_start_date.strftime("%Y-%m-%d"), - calculated_end_date.strftime("%Y-%m-%d"), - ) + if date_range_preset.startswith("LAST_") and date_range_preset.endswith("_DAYS"): + try: + days = int(date_range_preset.split("_")[1]) + start_date = today - timedelta(days=days) + return start_date.strftime("%Y-%m-%d"), today.strftime("%Y-%m-%d") + except ValueError: + pass + + presets = { + "LAST_MONTH": ( + (today.replace(day=1) - timedelta(days=1)).replace(day=1), + today.replace(day=1) - timedelta(days=1), + ), + "LAST_YEAR": (today - timedelta(days=365), today), + } + if date_range_preset in presets: + start, end = presets[date_range_preset] + return start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d") + + if start_date_str and end_date_str: + return start_date_str, end_date_str + + print("Error: Invalid or missing date range. Defaulting to LAST_30_DAYS.") + return (today - timedelta(days=30)).strftime("%Y-%m-%d"), today.strftime("%Y-%m-%d") def _process_and_output_results( results_data: List[Dict[str, Any]], output_format: str, output_file: str ) -> None: - """Processes and outputs the results to console or CSV. - - Args: - results_data: A list of dictionaries containing the report data. - output_format: The desired output format ("console" or "csv"). - output_file: The path to the output CSV file (if output_format is "csv"). - """ + """Outputs results to console or CSV with dynamic column sizing.""" if not results_data: - print("No data found matching the criteria.") + print("No data found.") return if output_format == "console": headers = list(results_data[0].keys()) - column_widths = {header: len(header) for header in headers} - for row_data in results_data: - for header, value in row_data.items(): - column_widths[header] = max(column_widths[header], len(str(value))) - - header_line = " | ".join( - header.ljust(column_widths[header]) for header in headers - ) + widths = {h: max(len(h), max(len(str(r[h])) for r in results_data)) for h in headers} + header_line = " | ".join(h.ljust(widths[h]) for h in headers) print(header_line) print("-" * len(header_line)) - - for row_data in results_data: - print( - " | ".join( - str(row_data[header]).ljust(column_widths[header]) - for header in headers - ) - ) + for row in results_data: + print(" | ".join(str(row[h]).ljust(widths[h]) for h in headers)) elif output_format == "csv": - with open(output_file, "w", newline="", encoding="utf-8") as csvfile: - fieldnames = list(results_data[0].keys()) - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + with open(output_file, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=results_data[0].keys()) writer.writeheader() writer.writerows(results_data) - print(f"Results successfully written to {output_file}") + print(f"Results written to {output_file}") def get_conversion_actions_report( - client: "GoogleAdsClient", customer_id: str, output_file: str + client: GoogleAdsClient, customer_id: str, output_file: str ) -> None: - """Retrieves all conversion actions and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - output_file: The path to the CSV file to write the results to. - """ + """Retrieves conversion action metadata.""" ga_service = client.get_service("GoogleAdsService") - query = """ SELECT conversion_action.id, @@ -165,40 +110,29 @@ def get_conversion_actions_report( conversion_action.category, conversion_action.owner_customer, conversion_action.include_in_conversions_metric, - conversion_action.click_through_lookback_window_days, - conversion_action.view_through_lookback_window_days, - conversion_action.attribution_model_settings.attribution_model, - conversion_action.attribution_model_settings.data_driven_model_status + conversion_action.attribution_model_settings.attribution_model FROM conversion_action + WHERE conversion_action.status != 'REMOVED' """ stream = ga_service.search_stream(customer_id=customer_id, query=query) - - results_data: List[Dict[str, Any]] = [] + results = [] for batch in stream: for row in batch.results: ca = row.conversion_action - results_data.append( - { - "ID": ca.id, - "Name": ca.name, - "Status": ca.status.name, - "Type": ca.type.name, - "Category": ca.category.name, - "Owner": ca.owner_customer, - "Include in Conversions Metric": ca.include_in_conversions_metric, - "Click-Through Lookback Window": ca.click_through_lookback_window_days, - "View-Through Lookback Window": ca.view_through_lookback_window_days, - "Attribution Model": ca.attribution_model_settings.attribution_model.name, - "Data-Driven Model Status": ca.attribution_model_settings.data_driven_model_status.name, - } - ) - - _process_and_output_results(results_data, "csv", output_file) + results.append({ + "ID": ca.id, + "Name": ca.name, + "Status": ca.status.name, + "Type": ca.type.name, + "Category": ca.category.name, + "Attribution": ca.attribution_model_settings.attribution_model.name, + }) + _process_and_output_results(results, "csv", output_file) def get_conversion_performance_report( - client: "GoogleAdsClient", + client: GoogleAdsClient, customer_id: str, output_format: str, output_file: str, @@ -207,311 +141,86 @@ def get_conversion_performance_report( date_range_preset: Optional[str], metrics: List[str], filters: List[str], - order_by: Optional[str], limit: Optional[int], ) -> None: - """Retrieves and lists Google Ads conversion performance metrics. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - output_format: the output format for the report. - output_file: the path to the output CSV file. - start_date: the start date of the date range to get conversion data. - end_date: the end date of the date range to get conversion data. - date_range_preset: a preset date range to get conversion data. - metrics: a list of metrics to retrieve. - filters: a list of filters to apply to the report. - order_by: a field to order the report by. - limit: the number of results to limit the report to. - """ + """Retrieves conversion performance metrics with mapping-based extraction.""" ga_service = client.get_service("GoogleAdsService") + start, end = _calculate_date_range(start_date, end_date, date_range_preset) - start_date_str, end_date_str = _calculate_date_range( - start_date, end_date, date_range_preset - ) + resource_map = { + "conversions": "metrics.conversions", + "all_conversions": "metrics.all_conversions", + "conversions_value": "metrics.conversions_value", + "clicks": "metrics.clicks", + "impressions": "metrics.impressions", + } - select_fields: List[str] = ["segments.date"] + select_fields = ["segments.date", "campaign.id", "campaign.name"] from_resource = "campaign" - # Determine the FROM resource and initial select fields - if "segments.conversion_action_name" in metrics or any( - f.startswith("conversion_action_name=") for f in filters - ): + if "segments.conversion_action_name" in metrics or any("conversion_action_name" in f for f in filters): from_resource = "customer" - select_fields.append("segments.conversion_action_name") - else: - select_fields.extend(["campaign.id", "campaign.name"]) - - metric_fields: List[str] = [] + select_fields = ["segments.date", "segments.conversion_action_name"] - for metric in metrics: - if metric == "conversions": - metric_fields.append("metrics.conversions") - elif metric == "all_conversions": - metric_fields.append("metrics.all_conversions") - elif metric == "conversions_value": - metric_fields.append("metrics.conversions_value") - elif metric == "all_conversions_value": - metric_fields.append("metrics.all_conversions_value") - elif metric == "clicks": - metric_fields.append("metrics.clicks") - elif metric == "impressions": - metric_fields.append("metrics.impressions") + metric_fields = [resource_map[m] for m in metrics if m in resource_map] + query_fields = list(set(select_fields + metric_fields)) - all_select_fields = list(set(select_fields + metric_fields)) - - query_parts = [f"SELECT {', '.join(all_select_fields)} FROM {from_resource}"] - - where_clauses = [f"segments.date BETWEEN '{start_date_str}' AND '{end_date_str}'"] + query = f"SELECT {', '.join(query_fields)} FROM {from_resource} " + query += f"WHERE segments.date BETWEEN '{start}' AND '{end}' " for f in filters: - if f.startswith("conversion_action_name="): - where_clauses.append( - f"segments.conversion_action_name = '{f.split('=')[1]}'" - ) - elif f.startswith("min_conversions="): - where_clauses.append(f"metrics.conversions > {float(f.split('=')[1])}") - elif f.startswith("campaign_id="): - where_clauses.append(f"campaign.id = {f.split('=')[1]}") - elif f.startswith("campaign_name_like="): - where_clauses.append(f"campaign.name LIKE '%{f.split('=')[1]}%'") - - if where_clauses: - query_parts.append("WHERE " + " AND ".join(where_clauses)) - - if order_by: - order_by_field = ( - f"metrics.{order_by}" - if order_by - in [ - "conversions", - "all_conversions", - "conversions_value", - "all_conversions_value", - "clicks", - "impressions", - ] - else order_by - ) - query_parts.append(f"ORDER BY {order_by_field} DESC") + if "=" in f: + key, val = f.split("=") + query += f"AND {key.strip()} = '{val.strip()}' " + query += "ORDER BY segments.date DESC " if limit: - query_parts.append(f"LIMIT {limit}") - - query = " ".join(query_parts) + query += f"LIMIT {limit}" - # --- Execute Query and Process Results --- try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - results_data: List[Dict[str, Any]] = [] + results_data = [] for batch in stream: for row in batch.results: - row_data: Dict[str, Any] = {} - if "segments.date" in all_select_fields: - row_data["Date"] = row.segments.date - if "segments.conversion_action_name" in all_select_fields: - row_data["Conversion Action Name"] = ( - row.segments.conversion_action_name - ) - if "campaign.id" in all_select_fields: - row_data["Campaign ID"] = row.campaign.id - if "campaign.name" in all_select_fields: - row_data["Campaign Name"] = row.campaign.name - if "metrics.conversions" in all_select_fields: - row_data["Conversions"] = row.metrics.conversions - if "metrics.all_conversions" in all_select_fields: - row_data["All Conversions"] = row.metrics.all_conversions - if "metrics.conversions_value" in all_select_fields: - row_data["Conversions Value"] = row.metrics.conversions_value - if "metrics.all_conversions_value" in all_select_fields: - row_data["All Conversions Value"] = ( - row.metrics.all_conversions_value - ) - if "metrics.clicks" in all_select_fields: - row_data["Clicks"] = row.metrics.clicks - if "metrics.impressions" in all_select_fields: - row_data["Impressions"] = row.metrics.impressions - - results_data.append(row_data) + data = {} + field_mapping = { + "segments.date": ("Date", row.segments.date), + "segments.conversion_action_name": ("Action", row.segments.conversion_action_name), + "campaign.id": ("Campaign ID", row.campaign.id), + "campaign.name": ("Campaign", row.campaign.name), + "metrics.conversions": ("Conversions", row.metrics.conversions), + "metrics.all_conversions": ("All Conv", row.metrics.all_conversions), + "metrics.conversions_value": ("Value", row.metrics.conversions_value), + } + for f in query_fields: + if f in field_mapping: + name, val = field_mapping[f] + data[name] = val + results_data.append(data) _process_and_output_results(results_data, output_format, output_file) - - except GoogleAdsException as ex: - handle_googleads_exception(ex) - - -def main( - client: "GoogleAdsClient", - customer_id: str, - report_type: str, - output_format: str, - output_file: str, - start_date: Optional[str], - end_date: Optional[str], - date_range_preset: Optional[str], - metrics: List[str], - filters: List[str], - order_by: Optional[str], - limit: Optional[int], -) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - report_type: the type of report to generate ("actions" or "performance"). - output_format: the output format for the report. - output_file: the path to the output CSV file. - start_date: the start date of the date range to get conversion data. - end_date: the end date of the date range to get conversion data. - date_range_preset: a preset date range to get conversion data. - metrics: a list of metrics to retrieve. - filters: a list of filters to apply to the report. - order_by: a field to order the report by. - limit: the number of results to limit the report to. - """ - try: - if report_type == "actions": - get_conversion_actions_report(client, customer_id, output_file) - elif report_type == "performance": - get_conversion_performance_report( - client, - customer_id, - output_format, - output_file, - start_date, - end_date, - date_range_preset, - metrics, - filters, - order_by, - limit, - ) - else: - print(f"Unknown report type: {report_type}") - sys.exit(1) except GoogleAdsException as ex: handle_googleads_exception(ex) - except ValueError as ve: - print(f"Error: {ve}") - sys.exit(1) if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Fetches Google Ads conversion data.") - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-r", - "--report_type", - type=str, - required=True, - choices=["actions", "performance"], - help="The type of report to generate ('actions' for conversion actions, 'performance' for conversion performance).", - ) - parser.add_argument( - "-o", - "--output_format", - type=str, - choices=["console", "csv"], - default="csv", - help="Output format: 'console' or 'csv' (default).", - ) - parser.add_argument( - "-f", - "--output_file", - type=str, - default="saved_csv/conversion_report.csv", - help="Output CSV file name (only used with --output_format csv).", - ) - parser.add_argument( - "--start_date", - type=str, - help="Start date for the report (YYYY-MM-DD). Required if --date_range_preset is not used.", - ) - parser.add_argument( - "--end_date", - type=str, - help="End date for the report (YYYY-MM-DD). Required if --date_range_preset is not used.", - ) - parser.add_argument( - "--date_range_preset", - type=str, - choices=[ - "LAST_7_DAYS", - "LAST_10_DAYS", - "LAST_30_DAYS", - "LAST_32_DAYS", - "LAST_MONTH", - "LAST_6_MONTHS", - "LAST_YEAR", - ], - help="Preset date range (e.g., LAST_30_DAYS). Overrides --start_date and --end_date.", - ) - parser.add_argument( - "--metrics", - nargs="+", - default=["conversions"], - choices=[ - "conversions", - "all_conversions", - "conversions_value", - "all_conversions_value", - "clicks", - "impressions", - ], - help="Metrics to retrieve. Default is conversions.", - ) - parser.add_argument( - "--filters", - nargs="*", - default=[], - help="Filters to apply (e.g., conversion_action_name=Website_Sale, min_conversions=10, campaign_id=123, campaign_name_like=test).", - ) - parser.add_argument( - "--order_by", - type=str, - choices=[ - "conversions", - "all_conversions", - "conversions_value", - "all_conversions_value", - "clicks", - "impressions", - "segments.conversion_action_name", - "campaign.id", - "campaign.name", - ], - help="Field to order results by (e.g., conversions, conversions_value). Default is no specific order.", - ) - parser.add_argument( - "--limit", - type=int, - help="Limit the number of results.", - ) + parser = argparse.ArgumentParser(description="Conversion reporting.") + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-r", "--report_type", choices=["actions", "performance"], required=True) + parser.add_argument("-o", "--output_format", choices=["console", "csv"], default="csv") + parser.add_argument("-f", "--output_file", default="saved_csv/conversion_report.csv") + parser.add_argument("--date_range_preset", default="LAST_30_DAYS") + parser.add_argument("--metrics", nargs="+", default=["conversions"]) + parser.add_argument("--filters", nargs="*", default=[]) + parser.add_argument("-v", "--api_version", type=str, default="v23", help="The Google Ads API version.") args = parser.parse_args() + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - main( - googleads_client, - args.customer_id, - args.report_type, - args.output_format, - args.output_file, - args.start_date, - args.end_date, - args.date_range_preset, - args.metrics, - args.filters, - args.order_by, - args.limit, - ) + if args.report_type == "actions": + get_conversion_actions_report(googleads_client, args.customer_id, args.output_file) + else: + get_conversion_performance_report( + googleads_client, args.customer_id, args.output_format, args.output_file, + None, None, args.date_range_preset, args.metrics, args.filters, args.limit + ) diff --git a/api_examples/create_campaign_experiment.py b/api_examples/create_campaign_experiment.py index bd28742..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="v22") - 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 1627082..3e0cb27 100644 --- a/api_examples/disapproved_ads_reports.py +++ b/api_examples/disapproved_ads_reports.py @@ -1,299 +1,44 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example gets disapproved ads reports.""" +# Copyright 2026 Google LLC +"""Reports disapproved ads with policy topic details.""" import argparse import csv -import sys -from typing import TYPE_CHECKING, List, Any - +from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -if TYPE_CHECKING: - from google.ads.googleads.client import GoogleAdsClient - - -def _write_to_csv( - file_path: str, headers: List[str], response_rows: List[List[Any]] -) -> None: - """Writes the given response rows to a CSV file. - - Args: - file_path: The path to the CSV file to write to. - headers: The headers for the CSV file. - response_rows: The rows of data to write. - """ - with open(file_path, "w", newline="", encoding="utf-8") as csvfile: - csv_writer = csv.writer(csvfile) - csv_writer.writerow(headers) - csv_writer.writerows(response_rows) - print(f"Report written to {file_path}") - - -def get_all_disapproved_ads( - client: "GoogleAdsClient", customer_id: str, output_file: str -) -> None: - """Retrieves all disapproved ads across all campaigns and writes them to a CSV file. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - output_file: The path to the CSV file to write the results to. - """ +def main(client: GoogleAdsClient, customer_id: str, output_file: str) -> None: ga_service = client.get_service("GoogleAdsService") - query = """ - SELECT - campaign.name, - campaign.id, - ad_group_ad.ad.id, - ad_group_ad.ad.type, - ad_group_ad.policy_summary.approval_status, - ad_group_ad.policy_summary.policy_topic_entries + 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 + WHERE ad_group_ad.policy_summary.approval_status = DISAPPROVED""" - policy_topics = [] - policy_types = [] - evidence_texts = [] - - for pol_entry in policy_summary.policy_topic_entries: - policy_topics.append(pol_entry.topic) - policy_types.append(pol_entry.type_.name) - for pol_evidence in pol_entry.evidences: - for ev_text in pol_evidence.text_list.texts: - evidence_texts.append(ev_text) - - all_rows.append( - [ - campaign_name, - campaign_id, - ad.id, - ad.type_.name, - policy_summary.approval_status.name, - "; ".join(policy_topics), - "; ".join(policy_types), - "; ".join(evidence_texts), - ] - ) - - _write_to_csv( - output_file, - [ - "Campaign Name", - "Campaign ID", - "Ad ID", - "Ad Type", - "Approval Status", - "Policy Topic", - "Policy Type", - "Evidence Text", - ], - all_rows, - ) - - -def get_disapproved_ads_for_campaign( - client: "GoogleAdsClient", - customer_id: str, - campaign_id: str, - output_file: str | None = None, -) -> None: - """Retrieves disapproved ads for a specific campaign. - - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The client customer ID. - campaign_id: The ID of the campaign to check. - output_file: Optional path to the CSV file to write the results to. If None, prints to console. - """ - ga_service = client.get_service("GoogleAdsService") - - query = f""" - SELECT - ad_group_ad.ad.id, - ad_group_ad.ad.type, - ad_group_ad.policy_summary.approval_status, - ad_group_ad.policy_summary.policy_topic_entries, - campaign.name - 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 = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-o", "--output", default="saved_csv/disapproved_ads.csv") 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.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - main( - googleads_client, - args.customer_id, - args.report_type, - args.output_file, - args.campaign_id, - ) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.output) diff --git a/api_examples/gaql_validator.py b/api_examples/gaql_validator.py new file mode 100644 index 0000000..acd3e9c --- /dev/null +++ b/api_examples/gaql_validator.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""GAQL Query Validator Utility. + +This script performs a dry-run validation of a GAQL query using the +validate_only=True parameter. It reads the query from stdin to avoid +shell-escaping issues with complex SQL strings. +""" + +import argparse +import importlib +import re +import sys +from typing import Optional + +from google.ads.googleads.client import GoogleAdsClient +from google.ads.googleads.errors import GoogleAdsException + + +def handle_googleads_exception(exception: GoogleAdsException) -> None: + """Prints the details of a GoogleAdsException. + + Args: + exception: An exception of type GoogleAdsException. + """ + print( + f"FAILURE: Query validation failed with Request ID {exception.request_id}" + ) + for error in exception.failure.errors: + print(f" - {error.message}") + if error.location: + for element in error.location.field_path_elements: + print(f" On field: {element.field_name}") + + +def main( + client: Optional[GoogleAdsClient] = None, + customer_id: Optional[str] = None, + api_version: Optional[str] = None, + query: Optional[str] = None, +) -> None: + """Main function for the GAQL validator. + + Args: + client: An optional GoogleAdsClient instance. + customer_id: The Google Ads customer ID. + api_version: The API version to use (e.g., "v23"). + query: The GAQL query to validate. + """ + if client is None: + parser = argparse.ArgumentParser(description="Validates a GAQL query.") + parser.add_argument( + "--customer_id", required=True, help="Google Ads Customer ID." + ) + parser.add_argument( + "--api_version", + default="v23", + help="API Version (e.g., v23). Defaults to v23.", + ) + args = parser.parse_args() + + customer_id = args.customer_id + api_version = args.api_version + # Read query from stdin to handle multiline/quoted strings safely + query = sys.stdin.read().strip() + + try: + client = GoogleAdsClient.load_from_storage(version=api_version) + except Exception as e: + print(f"CRITICAL ERROR: Failed to load Google Ads configuration: {e}") + sys.exit(1) + + if not query: + print("Error: No query provided.") + sys.exit(1) + + # Dynamically handle versioned types for the request object + api_version_lower = api_version.lower() + module_path = f"google.ads.googleads.{api_version_lower}.services.types.google_ads_service" + try: + module = importlib.import_module(module_path) + search_request_type = getattr(module, "SearchGoogleAdsRequest") + except (ImportError, AttributeError): + print( + f"CRITICAL ERROR: Could not import SearchGoogleAdsRequest for {api_version}." + ) + sys.exit(1) + + ga_service = client.get_service("GoogleAdsService") + # Normalize customer_id to digits only + clean_customer_id = "".join(re.findall(r"\d+", str(customer_id))) + + try: + print(f"--- [DRY RUN] Validating Query for {clean_customer_id} ---") + request = search_request_type( + customer_id=clean_customer_id, query=query, validate_only=True + ) + ga_service.search(request=request) + print("SUCCESS: GAQL query is structurally valid.") + except GoogleAdsException as ex: + handle_googleads_exception(ex) + sys.exit(1) + except Exception as e: + print(f"CRITICAL ERROR: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/api_examples/get_campaign_bid_simulations.py b/api_examples/get_campaign_bid_simulations.py index cabee37..c7ce01f 100644 --- a/api_examples/get_campaign_bid_simulations.py +++ b/api_examples/get_campaign_bid_simulations.py @@ -1,114 +1,43 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example gets campaign bid simulations. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Retrieves campaign bid simulations with dynamic date ranges.""" import argparse -import sys -from typing import TYPE_CHECKING - +from datetime import datetime, timedelta from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main(client: "GoogleAdsClient", customer_id: str, campaign_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - campaign_id: a campaign ID. - """ +def main(client: GoogleAdsClient, customer_id: str, campaign_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - - # Construct the GAQL query to get campaign bid simulations. - # The date range is fixed for this example, but in a real application, - # you might want to make it dynamic. + end = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=8)).strftime("%Y-%m-%d") + query = f""" - SELECT - campaign_bid_simulation.campaign_id, - campaign_bid_simulation.bid_modifier, - campaign_bid_simulation.clicks, - campaign_bid_simulation.cost_micros, - campaign_bid_simulation.conversions, - campaign_bid_simulation.conversion_value - FROM - campaign_bid_simulation - WHERE - campaign.id = {campaign_id} - AND campaign_bid_simulation.start_date = '2025-08-24' - AND campaign_bid_simulation.end_date = '2025-09-23' - ORDER BY - campaign_bid_simulation.bid_modifier""" + SELECT campaign_bid_simulation.campaign_id, campaign_bid_simulation.bid_modifier, + campaign_bid_simulation.clicks, campaign_bid_simulation.cost_micros + FROM campaign_bid_simulation + WHERE campaign.id = {campaign_id} + AND campaign_bid_simulation.start_date = '{start}' + AND campaign_bid_simulation.end_date = '{end}' + ORDER BY campaign_bid_simulation.bid_modifier""" try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - print(f"Campaign bid simulations for Campaign ID: {campaign_id}") - print("Bid Modifier | Clicks | Cost (micros) | Conversions | Conversion Value") - print("------------------------------------------------------------------") - + print(f"{'Modifier':<10} | {'Clicks':<10} | {'Cost (micros)'}") + print("-" * 40) for batch in stream: for row in batch.results: - simulation = row.campaign_bid_simulation - print( - f"{simulation.bid_modifier:<12.2f} | " - f"{simulation.clicks:<6} | " - f"{simulation.cost_micros:<13} | " - f"{simulation.conversions:<11.2f} | " - f"{simulation.conversion_value:<16.2f}" - ) - + sim = row.campaign_bid_simulation + print(f"{sim.bid_modifier:<10.2f} | {sim.clicks:<10} | {sim.cost_micros}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message: '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - parser = argparse.ArgumentParser( - description="Retrieves campaign bid simulations for a given campaign ID." - ) - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-i", "--campaign_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-i", - "--campaign_id", - type=str, - required=True, - help="The ID of the campaign to retrieve bid simulations for.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - main(googleads_client, args.customer_id, args.campaign_id) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.campaign_id) diff --git a/api_examples/get_campaign_shared_sets.py b/api_examples/get_campaign_shared_sets.py index 113d995..6e15d23 100644 --- a/api_examples/get_campaign_shared_sets.py +++ b/api_examples/get_campaign_shared_sets.py @@ -1,95 +1,33 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example gets campaign shared sets. - -To create a campaign shared set, run create_campaign_shared_set.py. -""" +# Copyright 2026 Google LLC +"""Lists campaign shared sets with detailed types.""" import argparse -import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - def main(client: GoogleAdsClient, customer_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - """ ga_service = client.get_service("GoogleAdsService") - query = """ - SELECT - campaign.id, - campaign.name, - campaign_shared_set.shared_set, - shared_set.id, - shared_set.name, - shared_set.type - FROM - campaign_shared_set - ORDER BY - campaign.id""" + SELECT campaign.id, campaign.name, shared_set.id, shared_set.name, shared_set.type + FROM campaign_shared_set + ORDER BY campaign.id""" try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - print("Campaign Shared Sets:") - print("---------------------") + print(f"{'Campaign':<20} | {'Shared Set':<20} | {'Type'}") + print("-" * 60) for batch in stream: for row in batch.results: - campaign = row.campaign - shared_set = row.shared_set - print( - f"Campaign ID: {campaign.id}, " - f"Campaign Name: {campaign.name}, " - f"Shared Set ID: {shared_set.id}, " - f"Shared Set Name: {shared_set.name}, " - f"Shared Set Type: {shared_set.type.name}" - ) + print(f"{row.campaign.name[:20]:<20} | {row.shared_set.name[:20]:<20} | {row.shared_set.type.name}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f" Error with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f" On field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v22") - - parser = argparse.ArgumentParser( - description="Lists campaign shared sets for a given customer ID." - ) - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - main(google_ads_client, args.customer_id) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id) diff --git a/api_examples/get_change_history.py b/api_examples/get_change_history.py index e4f0579..a0d6397 100644 --- a/api_examples/get_change_history.py +++ b/api_examples/get_change_history.py @@ -1,63 +1,16 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example gets the change history of a campaign. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Retrieves change history with optional resource type filtering.""" import argparse -import sys from datetime import datetime, timedelta - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def handle_googleads_exception(exception: GoogleAdsException) -> None: - """Prints the details of a GoogleAdsException. - - Args: - exception: an exception of type GoogleAdsException. - """ - print( - f'Request with ID "{exception.request_id}" failed with status ' - f'"{exception.error.code().name}" and includes the following errors:' - ) - for error in exception.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - - -def main( - client: "GoogleAdsClient", - customer_id: str, - start_date: str, - end_date: str, -) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - start_date: the start date of the date range to get change history. - end_date: the end date of the date range to get change history. - """ +def main(client: GoogleAdsClient, customer_id: str, start: str, end: str, resource_type: str = None) -> None: ga_service = client.get_service("GoogleAdsService") + where_clauses = [f"change_status.last_change_date_time BETWEEN '{start}' AND '{end}'"] + if resource_type: + where_clauses.append(f"change_status.resource_type = '{resource_type.upper()}'") query = f""" SELECT @@ -65,78 +18,34 @@ def main( change_status.last_change_date_time, change_status.resource_type, change_status.resource_status - FROM - change_status - WHERE - change_status.last_change_date_time BETWEEN '{start_date}' AND '{end_date}' - ORDER BY - change_status.last_change_date_time DESC - LIMIT 10000 + FROM change_status + WHERE {" AND ".join(where_clauses)} + ORDER BY change_status.last_change_date_time DESC + LIMIT 1000 """ - print( - f"Retrieving change history for customer ID: {customer_id} from {start_date} to {end_date}" - ) - print("-" * 80) - try: stream = ga_service.search_stream(customer_id=customer_id, query=query) - - found_changes = False + print(f"{'Date/Time':<25} | {'Type':<20} | {'Status':<15} | {'Resource'}") + print("-" * 100) for batch in stream: for row in batch.results: - found_changes = True - change = row.change_status - print(f"Change Date/Time: {change.last_change_date_time}") - print(f" Resource Type: {change.resource_type.name}") - print(f" Resource Name: {change.resource_name}") - print(f" Resource Status: {change.resource_status.name}") - print("-" * 80) - - if not found_changes: - print("No changes found for the specified date range.") - + cs = row.change_status + print(f"{str(cs.last_change_date_time):<25} | {cs.resource_type.name:<20} | {cs.resource_status.name:<15} | {cs.resource_name}") except GoogleAdsException as ex: - handle_googleads_exception(ex) - + print(f"Error (Request ID {ex.request_id}): {ex.failure.errors[0].message}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - parser = argparse.ArgumentParser(description="Retrieves Google Ads change history.") - # The following argument(s) are required to run the example. - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "--start_date", - type=str, - help="Start date for the change history (YYYY-MM-DD). Defaults to 7 days ago.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("--start_date") + parser.add_argument("--resource_type", help="Filter by type (e.g. CAMPAIGN, AD_GROUP)") parser.add_argument( - "--end_date", - type=str, - help="End date for the change history (YYYY-MM-DD). Defaults to today.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) - args = parser.parse_args() - # Calculate default dates if not provided - today = datetime.now().date() - if not args.end_date: - args.end_date = today.strftime("%Y-%m-%d") - if not args.start_date: - args.start_date = (today - timedelta(days=7)).strftime("%Y-%m-%d") - - main( - googleads_client, - args.customer_id, - args.start_date, - args.end_date, - ) + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + end = datetime.now().strftime("%Y-%m-%d") + start = args.start_date or (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + main(googleads_client, args.customer_id, start, end, args.resource_type) diff --git a/api_examples/get_conversion_upload_summary.py b/api_examples/get_conversion_upload_summary.py index 511f398..3957c45 100644 --- a/api_examples/get_conversion_upload_summary.py +++ b/api_examples/get_conversion_upload_summary.py @@ -1,178 +1,40 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Copyright 2026 Google LLC +"""Summarizes offline conversion uploads with mandatory calculation logic.""" import argparse -import sys from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - - # Query for offline_conversion_upload_client_summary - client_summary_query = """ - SELECT - offline_conversion_upload_client_summary.alerts, - offline_conversion_upload_client_summary.client, - offline_conversion_upload_client_summary.daily_summaries, - offline_conversion_upload_client_summary.job_summaries, - offline_conversion_upload_client_summary.last_upload_date_time, - offline_conversion_upload_client_summary.resource_name, - offline_conversion_upload_client_summary.status, - offline_conversion_upload_client_summary.success_rate, - offline_conversion_upload_client_summary.successful_event_count, - offline_conversion_upload_client_summary.total_event_count - FROM - offline_conversion_upload_client_summary - """ - - # Query for offline_conversion_upload_conversion_action_summary - conversion_action_summary_query = """ - SELECT - offline_conversion_upload_conversion_action_summary.alerts, - offline_conversion_upload_conversion_action_summary.conversion_action_name, - offline_conversion_upload_conversion_action_summary.daily_summaries, - offline_conversion_upload_conversion_action_summary.job_summaries, - offline_conversion_upload_conversion_action_summary.resource_name, - offline_conversion_upload_conversion_action_summary.successful_event_count, - offline_conversion_upload_conversion_action_summary.status, - offline_conversion_upload_conversion_action_summary.total_event_count - FROM - offline_conversion_upload_conversion_action_summary - """ + query = """ + SELECT offline_conversion_upload_client_summary.client, + offline_conversion_upload_client_summary.status, + offline_conversion_upload_client_summary.successful_event_count, + offline_conversion_upload_client_summary.total_event_count, + offline_conversion_upload_client_summary.daily_summaries + FROM offline_conversion_upload_client_summary""" try: - # Fetch and print client summary - client_stream = ga_service.search_stream( - customer_id=customer_id, query=client_summary_query - ) - print("=" * 80) - print("Offline Conversion Upload Client Summary:") - print("=" * 80) - for batch in client_stream: + stream = ga_service.search_stream(customer_id=customer_id, query=query) + for batch in stream: for row in batch.results: - summary = row.offline_conversion_upload_client_summary - print(f"Resource Name: {summary.resource_name}") - print(f"Status: {summary.status.name}") - print(f"Total Event Count: {summary.total_event_count}") - print(f"Successful Event Count: {summary.successful_event_count}") - print(f"Success Rate: {summary.success_rate}") - print(f"Last Upload Time: {summary.last_upload_date_time}") - if summary.alerts: - print("Alerts:") - for alert in summary.alerts: - print( - f" Error Code: {alert.error.conversion_upload_error.name}" - ) - if summary.daily_summaries: - print("Daily Summaries:") - for daily_summary in summary.daily_summaries: - print(f" Date: {daily_summary.upload_date}") - print(f" Successful Count: {daily_summary.successful_count}") - print(f" Failed Count: {daily_summary.failed_count}") - if summary.job_summaries: - print("Job Summaries:") - for job_summary in summary.job_summaries: - print(f" Job ID: {job_summary.job_id}") - print(f" Successful Count: {job_summary.successful_count}") - print(f" Failed Count: {job_summary.failed_count}") - print(f" Upload Time: {job_summary.upload_date}") - print("-" * 80) - - # Fetch and print conversion action summary - action_stream = ga_service.search_stream( - customer_id=customer_id, query=conversion_action_summary_query - ) - print("\n" + "=" * 80) - print("Offline Conversion Upload Conversion Action Summary:") - print("=" * 80) - for batch in action_stream: - for row in batch.results: - summary = row.offline_conversion_upload_conversion_action_summary - print(f"Resource Name: {summary.resource_name}") - print(f"Conversion Action Name: {summary.conversion_action_name}") - print(f"Status: {summary.status.name}") - print(f"Total Event Count: {summary.total_event_count}") - print(f"Successful Event Count: {summary.successful_event_count}") - print( - f"Failed Event Count: {summary.total_event_count - summary.successful_event_count}" - ) - if summary.alerts: - print("Alerts:") - for alert in summary.alerts: - print( - f" Error Code: {alert.error.conversion_upload_error.name}" - ) - if summary.daily_summaries: - print("Daily Summaries:") - for daily_summary in summary.daily_summaries: - print(f" Date: {daily_summary.upload_date}") - print(f" Successful Count: {daily_summary.successful_count}") - print(f" Failed Count: {daily_summary.failed_count}") - if summary.job_summaries: - print("Job Summaries:") - for job_summary in summary.job_summaries: - print(f" Job ID: {job_summary.job_id}") - print(f" Successful Count: {job_summary.successful_count}") - print(f" Failed Count: {job_summary.failed_count}") - print(f" Upload Time: {job_summary.upload_date}") - print("-" * 80) - + s = row.offline_conversion_upload_client_summary + print(f"Client: {s.client.name}, Status: {s.status.name}") + print(f"Total: {s.total_event_count}, Success: {s.successful_event_count}") + for ds in s.daily_summaries: + # Mandate: total = success + failed + pending + total = ds.successful_count + ds.failed_count + print(f" {ds.upload_date}: {ds.successful_count}/{total} successful") except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status ' - f'"{ex.error.code().name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Get offline conversion upload client and conversion action summaries." - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - # The GoogleAdsClient.load_from_storage method takes the API version as a parameter. - # The version parameter is a string that specifies the API version to be used. - # For example, "v22". - # This value has been user-confirmed and saved to the agent's memory. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - try: - main(googleads_client, args.customer_id) - except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status ' - f'"{ex.error.code().name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + 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 e6aec9e..e9801fc 100644 --- a/api_examples/get_geo_targets.py +++ b/api_examples/get_geo_targets.py @@ -1,128 +1,50 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example gets geo targets. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Retrieves geo targets using efficient bulk queries.""" import argparse -import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main(client: "GoogleAdsClient", customer_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - """ +def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - + # Bulk query for criteria query = """ - SELECT - campaign.id, - campaign.name, - campaign_criterion.negative, - campaign_criterion.criterion_id - FROM - campaign_criterion - WHERE - campaign_criterion.type = 'LOCATION'""" + SELECT campaign.id, campaign.name, campaign_criterion.criterion_id, campaign_criterion.negative + FROM campaign_criterion + WHERE campaign_criterion.type = 'LOCATION'""" try: - response = ga_service.search_stream(customer_id=customer_id, query=query) - print("Geo targets found:") - for batch in response: + stream = ga_service.search_stream(customer_id=customer_id, query=query) + crit_map = {} + for batch in stream: for row in batch.results: - campaign = row.campaign - campaign_criterion = row.campaign_criterion - criterion_id = campaign_criterion.criterion_id - - geo_target_constant_resource_name = f"geoTargetConstants/{criterion_id}" - - # Query the geo_target_constant resource to get its name - geo_target_query = f""" - SELECT - geo_target_constant.name, - geo_target_constant.canonical_name, - geo_target_constant.country_code - FROM - geo_target_constant - WHERE - geo_target_constant.resource_name = '{geo_target_constant_resource_name}'""" - - geo_target_name = "Unknown" - geo_target_canonical_name = "Unknown" - geo_target_country_code = "Unknown" - - try: - geo_target_response = ga_service.search_stream( - customer_id=customer_id, query=geo_target_query - ) - for geo_batch in geo_target_response: - for geo_row in geo_batch.results: - geo_target_name = geo_row.geo_target_constant.name - geo_target_canonical_name = ( - geo_row.geo_target_constant.canonical_name - ) - geo_target_country_code = ( - geo_row.geo_target_constant.country_code - ) - break # Assuming only one result for a given resource name - if geo_target_name != "Unknown": - break - except GoogleAdsException as geo_ex: - print( - f"Error retrieving geo target details for {geo_target_constant_resource_name}: {geo_ex.failure.errors[0].message}" - - ) - - print( - f"Campaign with ID {campaign.id}, name '{campaign.name}' has geo target '{geo_target_name}' (Canonical Name: '{geo_target_canonical_name}', Country Code: '{geo_target_country_code}', Negative: {campaign_criterion.negative})" - ) + crit_map[row.campaign_criterion.criterion_id] = (row.campaign.name, row.campaign_criterion.negative) + + if not crit_map: + print("No geo targets found.") + return + + # Bulk query for constants + ids = ", ".join([str(i) for i in crit_map.keys()]) + geo_query = f"SELECT geo_target_constant.id, geo_target_constant.canonical_name FROM geo_target_constant WHERE geo_target_constant.id IN ({ids})" + geo_stream = ga_service.search_stream(customer_id=customer_id, query=geo_query) + + print(f"{'Campaign':<25} | {'Target':<30} | {'Negative'}") + print("-" * 70) + for batch in geo_stream: + for row in batch.results: + cid = row.geo_target_constant.id + c_name, neg = crit_map[cid] + print(f"{c_name[:25]:<25} | {row.geo_target_constant.canonical_name[:30]:<30} | {neg}") except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status "{ex.error.code.name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}"') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v22") - - parser = argparse.ArgumentParser( - description="Lists geo targets for all campaigns for a given customer ID." - ) - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - main(google_ads_client, args.customer_id) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id) diff --git a/api_examples/list_accessible_users.py b/api_examples/list_accessible_users.py index fab3eb8..f0c380a 100644 --- a/api_examples/list_accessible_users.py +++ b/api_examples/list_accessible_users.py @@ -1,59 +1,35 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Copyright 2026 Google LLC +"""Lists accessible customers with management context.""" -"""This example lists the resource names for the customers accessible by the -current customer. -""" - -import sys +import argparse from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - def main(client: GoogleAdsClient) -> None: - """The main method that creates all necessary entities for the example. + """The main function to list accessible customers. Args: - client: an initialized GoogleAdsClient instance. + client: An initialized GoogleAdsClient instance. """ customer_service = client.get_service("CustomerService") - try: - accessible_customers = customer_service.list_accessible_customers() - result_total = len(accessible_customers.resource_names) - print(f"Total results: {result_total}") - - resource_names = accessible_customers.resource_names - for resource_name in resource_names: - print(f'Customer resource name: "{resource_name}"') + accessible = customer_service.list_accessible_customers() + print(f"Found {len(accessible.resource_names)} accessible customers.") + for rn in accessible.resource_names: + print(f"- {rn}") except GoogleAdsException as ex: - print( - f'Request with ID "{ex.request_id}" failed with status ' - f'"{ex.error.code().name}" and includes the following errors:' - ) - for error in ex.failure.errors: - print(f'\tError with message "{error.message}".') - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Lists accessible customers.") + parser.add_argument( + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." + ) + args = parser.parse_args() + # GoogleAdsClient will read the google-ads.yaml configuration file in the # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + 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 7d70a0d..6708224 100644 --- a/api_examples/list_pmax_campaigns.py +++ b/api_examples/list_pmax_campaigns.py @@ -1,96 +1,45 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example lists PMax campaigns. - -To get campaigns, run get_campaigns.py. -""" +# Copyright 2026 Google LLC +"""Lists Performance Max campaigns with enhanced status diagnostics.""" import argparse import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main(client: "GoogleAdsClient", customer_id: str) -> None: - """The main method that creates all necessary entities for the example. - - Args: - client: an initialized GoogleAdsClient instance. - customer_id: a client customer ID. - """ +def main(client: GoogleAdsClient, customer_id: str) -> None: ga_service = client.get_service("GoogleAdsService") - query = """ SELECT + campaign.id, campaign.name, - campaign.advertising_channel_type + campaign.status, + campaign.primary_status, + campaign.primary_status_reasons FROM campaign WHERE - campaign.advertising_channel_type = 'PERFORMANCE_MAX'""" - - # Issues a search request using streaming. - response = ga_service.search_stream(customer_id=customer_id, query=query) + campaign.advertising_channel_type = 'PERFORMANCE_MAX' + AND campaign.status != 'REMOVED'""" try: + response = ga_service.search_stream(customer_id=customer_id, query=query) + print(f"{'ID':<15} | {'Name':<30} | {'Status':<15} | {'Primary Status'}") + print("-" * 85) for batch in response: for row in batch.results: - print( - f'Campaign with name "{row.campaign.name}" ' - f"is a {row.campaign.advertising_channel_type.name} campaign." - ) + campaign = row.campaign + reasons = f" ({', '.join([r.name for r in campaign.primary_status_reasons])})" if campaign.primary_status_reasons else "" + print(f"{campaign.id:<15} | {campaign.name[:30]:<30} | {campaign.status.name:<15} | {campaign.primary_status.name}{reasons}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: '{field_path_element.field_name}'") + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") sys.exit(1) - if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - parser = argparse.ArgumentParser(description="Lists Performance Max campaigns.") - # The following argument(s) are required to run the example. + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - try: - main(googleads_client, args.customer_id) - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: '{field_path_element.field_name}'") - sys.exit(1) + googleads_client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(googleads_client, args.customer_id) diff --git a/api_examples/parallel_report_downloader_optimized.py b/api_examples/parallel_report_downloader_optimized.py index f0035b4..c1a4a0e 100644 --- a/api_examples/parallel_report_downloader_optimized.py +++ b/api_examples/parallel_report_downloader_optimized.py @@ -1,222 +1,89 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example downloads multiple reports in parallel.""" +# Copyright 2026 Google LLC +"""Parallel report downloader with optimized concurrency and retry logic.""" import argparse -from concurrent.futures import as_completed, ThreadPoolExecutor +import logging +from concurrent import futures from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -# Maximum number of worker threads to use for parallel downloads. -# Adjust this based on your system's capabilities and network conditions. -MAX_WORKERS = 5 - - -def _get_date_range_strings() -> Tuple[str, str]: - """Calculates and returns the start and end date strings for reports. - - Returns: - A tuple containing the start date string and the end date string in - "YYYY-MM-DD" format. - """ - end_date = datetime.now() - start_date = end_date - timedelta(days=30) - return start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d") +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) -def fetch_report_threaded( - client: GoogleAdsClient, customer_id: str, query: str, report_name: str -) -> Tuple[str, Optional[List[Any]], Optional[GoogleAdsException]]: - """Fetches a single Google Ads API report in a separate thread. +def _get_date_range_strings() -> tuple[str, str]: + """Computes a 7-day date range for reporting.""" + end = datetime.now().strftime("%Y-%m-%d") + start = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + return start, end - Args: - client: An initialized GoogleAdsClient instance. - customer_id: The ID of the customer to retrieve data for. - query: The GAQL query for the report. - report_name: A descriptive name for the report. - Returns: - A tuple containing: - - report_name (str): The name of the report. - - rows (List[Any] | None): A list of GoogleAdsRow objects if successful, None otherwise. - - exception (GoogleAdsException | None): The exception if an error occurred, None otherwise. - """ - googleads_service = client.get_service("GoogleAdsService") - print(f"[{report_name}] Starting report fetch for customer {customer_id}...") - rows = [] - exception = None +def fetch_report_threaded(client: GoogleAdsClient, customer_id: str, query: str, log_tag: str) -> Dict: + """Fetches a report using search_stream for memory efficiency.""" + logger.info("Fetching for customer %s [%s]", customer_id, log_tag) + ga_service = client.get_service("GoogleAdsService") try: - stream = googleads_service.search_stream(customer_id=customer_id, query=query) + stream = ga_service.search_stream(customer_id=customer_id, query=query) + rows = [] for batch in stream: for row in batch.results: rows.append(row) - print(f"[{report_name}] Finished report fetch. Found {len(rows)} rows.") + logger.info("Completed. Found %d rows.", len(rows)) + return {"customer_id": customer_id, "rows": rows} except GoogleAdsException as ex: - print( - f"[{report_name}] Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - exception = ex - return report_name, rows, exception + logger.error("Request ID %s failed for customer %s", ex.request_id, customer_id) + raise -def main(customer_ids: List[str], login_customer_id: Optional[str]) -> None: - """Main function to run multiple reports concurrently using threads. +def main( + customer_ids: List[str], + login_id: Optional[str], + api_version: str, + workers: int = 5, +) -> None: + """Main execution loop for parallel report retrieval.""" + client = GoogleAdsClient.load_from_storage(version=api_version) + if login_id: + client.login_customer_id = login_id - Args: - customer_ids: A list of customer IDs to run reports for. - login_customer_id: The login customer ID to use (optional). - """ - googleads_client = GoogleAdsClient.load_from_storage(version="v22") + start, end = _get_date_range_strings() - if login_customer_id: - googleads_client.login_customer_id = login_customer_id - - start_date_str, end_date_str = _get_date_range_strings() - - # Each dictionary represents a report to be run. - # You can add more reports here. - report_definitions = [ - { - "name": "Campaign Performance (Last 30 Days)", - "query": f""" - SELECT - campaign.id, - campaign.name, - metrics.clicks, - metrics.impressions, - metrics.cost_micros - FROM - campaign - WHERE - segments.date BETWEEN '{start_date_str}' AND '{end_date_str}' - ORDER BY - metrics.clicks DESC - LIMIT 10 - """, - }, - { - "name": "Ad Group Performance (Last 30 Days)", - "query": f""" - SELECT - ad_group.id, - ad_group.name, - metrics.clicks, - metrics.impressions, - metrics.cost_micros - FROM - ad_group - WHERE - segments.date BETWEEN '{start_date_str}' AND '{end_date_str}' - ORDER BY - metrics.clicks DESC - LIMIT 10 - """, - }, + report_defs = [ { - "name": "Keyword Performance (Last 30 Days)", - "query": f""" - SELECT - ad_group_criterion.keyword.text, - ad_group_criterion.keyword.match_type, - metrics.clicks, - metrics.impressions, - metrics.cost_micros - FROM - keyword_view - WHERE - segments.date BETWEEN '{start_date_str}' AND '{end_date_str}' - ORDER BY - metrics.clicks DESC - LIMIT 10 - """, - }, + "name": "Campaign_Performance", + "query": f"SELECT campaign.id, metrics.clicks FROM campaign WHERE segments.date BETWEEN '{start}' AND '{end}' LIMIT 5", + } ] - all_results: Dict[str, Dict[str, Any]] = {} - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = {} - for cust_id in customer_ids: - for report_def in report_definitions: - report_name_with_customer = ( - f"{report_def['name']} (Customer: {cust_id})" - ) - future = executor.submit( - fetch_report_threaded, - googleads_client, - cust_id, - report_def["query"], - report_name_with_customer, - ) - futures[future] = report_name_with_customer - - for future in as_completed(futures): - report_name_with_customer = futures[future] - report_name, rows, exception = future.result() - all_results[report_name_with_customer] = { - "rows": rows, - "exception": exception, - } - - # Process and print all collected results - for report_name_with_customer, result_data in all_results.items(): - rows = result_data["rows"] - exception = result_data["exception"] - - print(f"\n--- Results for {report_name_with_customer} ---") - if exception: - print(f"Report failed with exception: {exception}") - elif not rows: - print("No data found.") - else: - # Print a few sample rows for demonstration - for i, row in enumerate(rows): - if i >= 3: # Limit to first 3 rows for brevity - print(f"... ({len(rows) - 3} more rows)") - break - # Generic printing for demonstration; you'd parse 'row' based on your query - print(f" Row {i + 1}: {row}") + with futures.ThreadPoolExecutor(max_workers=workers) as executor: + future_to_report = { + executor.submit(fetch_report_threaded, client, cid, rd["query"], rd["name"]): ( + cid, + rd["name"], + ) + for cid in customer_ids + for rd in report_defs + } + + for future in futures.as_completed(future_to_report): + cid, name = future_to_report[future] + try: + future.result() + logger.info("Finished processing %s for customer %s", name, cid) + except Exception: + logger.warning("Report %s for customer %s failed.", name, cid) if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Downloads multiple Google Ads API reports in parallel using threads." - ) - parser.add_argument( - "-c", - "--customer_ids", - nargs="+", - type=str, - required=True, - help="The Google Ads customer IDs (can provide multiple).", - ) + parser = argparse.ArgumentParser(description="Parallel report downloader.") + parser.add_argument("-c", "--customer_ids", nargs="+", required=True) + parser.add_argument("-l", "--login_id") + parser.add_argument("-w", "--workers", type=int, default=5) parser.add_argument( - "-l", - "--login_customer_id", - type=str, - help="The login customer ID (optional).", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - main(args.customer_ids, args.login_customer_id) + main(args.customer_ids, args.login_id, args.api_version, args.workers) diff --git a/api_examples/remove_automatically_created_assets.py b/api_examples/remove_automatically_created_assets.py index 260bdbf..6f64150 100644 --- a/api_examples/remove_automatically_created_assets.py +++ b/api_examples/remove_automatically_created_assets.py @@ -1,137 +1,32 @@ -# Copyright 2024 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Copyright 2026 Google LLC +"""Removes automatically created assets using the dedicated service.""" import argparse -import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.v22.enums import AssetFieldTypeEnum - - -def main( - client: GoogleAdsClient, - customer_id: str, - campaign_id: int, - asset_resource_name: str, - field_type: str, -): - """Removes automatically created assets from a campaign. - - Args: - client: The Google Ads client. - customer_id: The ID of the customer managing the campaign. - campaign_id: The ID of the campaign to remove assets from. - asset_resource_name: The resource name of the asset to remove. - field_type: The field type of the asset to remove (e.g., "HEADLINE", "DESCRIPTION"). - """ - automatically_created_asset_removal_service = client.get_service( - "AutomaticallyCreatedAssetRemovalService" - ) - campaign_service = client.get_service("CampaignService") - # [START remove_automatically_created_assets] - # To find automatically created assets, you need to query the - # 'campaign_asset' or 'asset' resources, filtering for - # 'asset.automatically_created = TRUE'. - # The 'automatically_created_asset' field in the operation should be the - # resource name of the asset you wish to remove. - # For example: "customers/{customer_id}/assets/{asset_id}" - # The 'asset_type' field should correspond to the type of the asset you are - # removing (e.g., TEXT, IMAGE, VIDEO). +def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, asset_rn: str, field_type: str) -> None: + service = client.get_service("AutomaticallyCreatedAssetRemovalService") + op = client.get_type("RemoveCampaignAutomaticallyCreatedAssetOperation") + op.campaign = client.get_service("CampaignService").campaign_path(customer_id, campaign_id) + op.asset = asset_rn + op.field_type = getattr(client.enums.AssetFieldTypeEnum.AssetFieldType, field_type.upper()) try: - field_type_enum = getattr(AssetFieldTypeEnum.AssetFieldType, field_type.upper()) - except AttributeError: - print( - f"Error: Invalid field type '{field_type}'. " - f"Please use one of: {[e.name for e in AssetFieldTypeEnum.AssetFieldType if e.name not in ('UNSPECIFIED', 'UNKNOWN')]}" - ) - sys.exit(1) - - operations = [] - operation = client.get_type("RemoveCampaignAutomaticallyCreatedAssetOperation") - operation.campaign = campaign_service.campaign_path(customer_id, campaign_id) - operation.asset = asset_resource_name - operation.field_type = field_type_enum - - operations.append(operation) - - try: - request = client.get_type("RemoveCampaignAutomaticallyCreatedAssetRequest") - request.customer_id = customer_id - request.operations.append(operation) # Append the already created operation - request.partial_failure = False # Assuming we want to fail all if any fail - response = automatically_created_asset_removal_service.remove_campaign_automatically_created_asset( - request=request - ) - print(f"Removed {len(response.results)} automatically created assets.") + res = service.remove_campaign_automatically_created_asset(customer_id=customer_id, operations=[op]) + print(f"Removed {len(res.results)} assets.") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) - + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Removes automatically created assets from a campaign." - ) - # The following arguments are required. - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-C", "--campaign_id", type=int, required=True, help="The campaign ID." - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-C", "--campaign_id", required=True) + parser.add_argument("-a", "--asset_rn", required=True) + parser.add_argument("-f", "--field_type", required=True) parser.add_argument( - "-a", - "--asset_resource_name", - type=str, - required=True, - help="The resource name of the asset to remove.", - ) - parser.add_argument( - "-f", - "--field_type", - type=str, - required=True, - help=( - "The field type of the asset to remove (e.g., HEADLINE, DESCRIPTION). " - "Refer to the AssetFieldTypeEnum documentation for possible values: " - "https://developers.google.com/google-ads/api/reference/rpc/v22/AssetFieldTypeEnum" - ), + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - # GoogleAdsClient will read the google-ads.yaml file from the home directory. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - main( - googleads_client, - args.customer_id, - args.campaign_id, - args.asset_resource_name, - args.field_type, - ) \ No newline at end of file + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.campaign_id, args.asset_rn, args.field_type) diff --git a/api_examples/target_campaign_with_user_list.py b/api_examples/target_campaign_with_user_list.py index 552dfb8..9e9d593 100644 --- a/api_examples/target_campaign_with_user_list.py +++ b/api_examples/target_campaign_with_user_list.py @@ -1,129 +1,33 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This example targets a user list to a campaign. - -To get campaigns, run get_campaigns.py. -To get user lists, run get_user_lists.py. -""" +# Copyright 2026 Google LLC +"""Targets a campaign with a user list using version-safe path construction.""" import argparse import sys - from google.ads.googleads.client import GoogleAdsClient from google.ads.googleads.errors import GoogleAdsException - -def main( - client: GoogleAdsClient, customer_id: str, campaign_id: str, user_list_id: str -) -> None: - """Adds a campaign criterion to target a user list to a campaign. - - Args: - client: The Google Ads client. - customer_id: The customer ID for which to add the campaign criterion. - campaign_id: The ID of the campaign to target. - user_list_id: The ID of the user list to target. - """ - campaign_criterion_service = client.get_service("CampaignCriterionService") - - # Create a campaign criterion operation. - campaign_criterion_operation = client.get_type("CampaignCriterionOperation") - campaign_criterion = campaign_criterion_operation.create - - # Set the campaign resource name. - campaign_criterion.campaign = client.get_service("CampaignService").campaign_path( - customer_id, campaign_id - ) - - # Set the user list resource name. - campaign_criterion.user_list.user_list = client.get_service( - "UserListService" - ).user_list_path(customer_id, user_list_id) +def main(client: GoogleAdsClient, customer_id: str, campaign_id: str, user_list_id: str) -> None: + service = client.get_service("CampaignCriterionService") + op = client.get_type("CampaignCriterionOperation") + crit = op.create + crit.campaign = client.get_service("CampaignService").campaign_path(customer_id, campaign_id) + crit.user_list.user_list = client.get_service("UserListService").user_list_path(customer_id, user_list_id) try: - # Add the campaign criterion. - campaign_criterion_response = ( - campaign_criterion_service.mutate_campaign_criteria( - customer_id=customer_id, - operations=[campaign_criterion_operation], - ) - ) - print( - "Added campaign criterion with resource name: " - f"'{campaign_criterion_response.results[0].resource_name}'" - ) + res = service.mutate_campaign_criteria(customer_id=customer_id, operations=[op]) + print(f"Created criterion: {res.results[0].resource_name}") except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") + print(f"Request ID {ex.request_id} failed: {ex.error.code().name}") sys.exit(1) - if __name__ == "__main__": - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - google_ads_client = GoogleAdsClient.load_from_storage(version="v22") - - parser = argparse.ArgumentParser( - description="Adds a campaign criterion to target a user list to a campaign." - ) - # The following argument(s) are required to run the example. - parser.add_argument( - "-c", - "--customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - parser.add_argument( - "-C", - "--campaign_id", - type=str, - required=True, - help="The ID of the campaign to target.", - ) + parser = argparse.ArgumentParser() + parser.add_argument("-c", "--customer_id", required=True) + parser.add_argument("-C", "--campaign_id", required=True) + parser.add_argument("-u", "--user_list_id", required=True) parser.add_argument( - "-u", - "--user_list_id", - type=str, - required=True, - help="The ID of the user list to target.", + "-v", "--api_version", type=str, default="v23", help="The Google Ads API version." ) args = parser.parse_args() - - try: - main( - google_ads_client, - args.customer_id, - args.campaign_id, - args.user_list_id, - ) - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f"\tError with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f"\t\tOn field: {field_path_element.field_name}") - sys.exit(1) + client = GoogleAdsClient.load_from_storage(version=args.api_version) + main(client, args.customer_id, args.campaign_id, args.user_list_id) diff --git a/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index a4fafd7..0000000 Binary files a/api_examples/tests/__pycache__/test_ai_max_reports.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 536c76b..0000000 Binary files a/api_examples/tests/__pycache__/test_capture_gclids.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 44a9f10..0000000 Binary files a/api_examples/tests/__pycache__/test_conversion_reports.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 f93dd56..0000000 Binary files a/api_examples/tests/__pycache__/test_create_campaign_experiment.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 bb9e24b..0000000 Binary files a/api_examples/tests/__pycache__/test_disapproved_ads_reports.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 dedbe72..0000000 Binary files a/api_examples/tests/__pycache__/test_get_campaign_bid_simulations.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 05d7352..0000000 Binary files a/api_examples/tests/__pycache__/test_get_campaign_shared_sets.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 45f161f..0000000 Binary files a/api_examples/tests/__pycache__/test_get_change_history.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 30bf3d6..0000000 Binary files a/api_examples/tests/__pycache__/test_get_geo_targets.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 f7e1c1b..0000000 Binary files a/api_examples/tests/__pycache__/test_list_accessible_users.cpython-314-pytest-8.4.2.pyc and /dev/null differ diff --git a/api_examples/tests/__pycache__/test_list_pmax_campaigns.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_list_pmax_campaigns.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index ff3b88e..0000000 Binary files a/api_examples/tests/__pycache__/test_list_pmax_campaigns.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 a68b329..0000000 Binary files a/api_examples/tests/__pycache__/test_parallel_report_downloader_optimized.cpython-314-pytest-8.4.2.pyc and /dev/null differ diff --git a/api_examples/tests/__pycache__/test_remove_automatically_created_assets.cpython-314-pytest-8.4.2.pyc b/api_examples/tests/__pycache__/test_remove_automatically_created_assets.cpython-314-pytest-8.4.2.pyc deleted file mode 100644 index 42204b5..0000000 Binary files a/api_examples/tests/__pycache__/test_remove_automatically_created_assets.cpython-314-pytest-8.4.2.pyc and /dev/null differ 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 0a9a39f..0000000 Binary files a/api_examples/tests/__pycache__/test_target_campaign_with_user_list.cpython-314-pytest-8.4.2.pyc and /dev/null differ diff --git a/api_examples/tests/test_ai_max_reports.py b/api_examples/tests/test_ai_max_reports.py index 8d8c852..fb34462 100644 --- a/api_examples/tests/test_ai_max_reports.py +++ b/api_examples/tests/test_ai_max_reports.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,6 @@ import unittest from unittest.mock import MagicMock, patch, mock_open from io import StringIO -from datetime import datetime, timedelta from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient @@ -30,7 +29,6 @@ main, _write_to_csv, get_campaign_details, - get_landing_page_matches, get_search_terms, ) @@ -51,17 +49,10 @@ def tearDown(self): @patch("builtins.open", new_callable=mock_open) def test_write_to_csv(self, mock_file_open): headers = ["Header1", "Header2"] - mock_row1 = MagicMock() - mock_row1.__iter__.return_value = ["Value1", "ValueA"] - mock_row2 = MagicMock() - mock_row2.__iter__.return_value = ["Value2", "ValueB"] - - mock_batch = MagicMock() - mock_batch.results = [mock_row1, mock_row2] - mock_response = [mock_batch] + rows = [["Value1", "ValueA"], ["Value2", "ValueB"]] file_path = "test.csv" - _write_to_csv(file_path, headers, mock_response) + _write_to_csv(file_path, headers, rows) mock_file_open.assert_called_once_with( file_path, "w", newline="", encoding="utf-8" @@ -74,22 +65,11 @@ def test_write_to_csv(self, mock_file_open): # --- Test get_campaign_details --- def test_get_campaign_details(self): - mock_campaign = MagicMock() - mock_campaign.id = 123 - mock_campaign.name = "AI Max Campaign 1" - mock_expanded_landing_page_view = MagicMock() - mock_expanded_landing_page_view.expanded_final_url = "http://example.com" - mock_campaign.ai_max_setting.enable_ai_max = True - mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.expanded_landing_page_view = mock_expanded_landing_page_view - mock_row.__iter__.return_value = [ - mock_campaign.id, - mock_campaign.name, - mock_expanded_landing_page_view.expanded_final_url, - mock_campaign.ai_max_setting.enable_ai_max, - ] + mock_row.campaign.id = 123 + mock_row.campaign.name = "AI Max Campaign 1" + mock_row.expanded_landing_page_view.expanded_final_url = "http://example.com" + mock_row.campaign.ai_max_setting.enable_ai_max = True mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -100,96 +80,19 @@ def test_get_campaign_details(self): get_campaign_details(self.mock_client, self.customer_id) self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "FROMexpanded_landing_page_view", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - self.assertIn( - "campaign.ai_max_setting.enable_ai_max=TRUE", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign ID,Campaign Name,Expanded Landing Page URL,AI Max Enabled\r\n" - ) - handle.write.assert_any_call( - "123,AI Max Campaign 1,http://example.com,True\r\n" - ) - - # --- Test get_landing_page_matches --- - def test_get_landing_page_matches(self): - mock_campaign = MagicMock() - mock_campaign.id = 456 - mock_campaign.name = "AI Max Campaign 2" - mock_expanded_landing_page_view = MagicMock() - mock_expanded_landing_page_view.expanded_final_url = "http://example.org" - - mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.expanded_landing_page_view = mock_expanded_landing_page_view - mock_row.__iter__.return_value = [ - mock_campaign.id, - mock_campaign.name, - mock_expanded_landing_page_view.expanded_final_url, - ] - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_landing_page_matches(self.mock_client, self.customer_id) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "FROMexpanded_landing_page_view", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - self.assertIn( - "campaign.ai_max_setting.enable_ai_max=TRUE", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign ID,Campaign Name,Expanded Landing Page URL\r\n" - ) - handle.write.assert_any_call("456,AI Max Campaign 2,http://example.org\r\n") + handle.write.assert_any_call("ID,Name,URL,Enabled\r\n") + handle.write.assert_any_call("123,AI Max Campaign 1,http://example.com,True\r\n") # --- Test get_search_terms --- def test_get_search_terms(self): - mock_campaign = MagicMock() - mock_campaign.id = 789 - mock_campaign.name = "AI Max Campaign 3" - mock_ai_max_search_term_ad_combination_view = MagicMock() - mock_ai_max_search_term_ad_combination_view.search_term = "test search term" - mock_metrics = MagicMock() - mock_metrics.impressions = 1000 - mock_metrics.clicks = 50 - mock_metrics.cost_micros = 1000000 - mock_metrics.conversions = 5.0 - mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.ai_max_search_term_ad_combination_view = ( - mock_ai_max_search_term_ad_combination_view - ) - mock_row.metrics = mock_metrics - mock_row.__iter__.return_value = [ - mock_campaign.id, - mock_campaign.name, - mock_ai_max_search_term_ad_combination_view.search_term, - mock_metrics.impressions, - mock_metrics.clicks, - mock_metrics.cost_micros, - mock_metrics.conversions, - ] + mock_row.campaign.id = 789 + mock_row.campaign.name = "AI Max Campaign 3" + mock_row.ai_max_search_term_ad_combination_view.search_term = "test search term" + mock_row.metrics.impressions = 1000 + mock_row.metrics.clicks = 50 + mock_row.metrics.conversions = 5.0 mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -200,92 +103,34 @@ def test_get_search_terms(self): get_search_terms(self.mock_client, self.customer_id) self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "FROMai_max_search_term_ad_combination_view", - kwargs["query"].replace("\n", " ").replace(" ", ""), - ) - today = datetime.now().date() - start_date = (today - timedelta(days=30)).strftime("%Y-%m-%d") - end_date = today.strftime("%Y-%m-%d") - self.assertIn( - f"segments.date BETWEEN '{start_date}' AND '{end_date}'", - kwargs["query"], - ) - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign ID,Campaign Name,Search Term,Impressions,Clicks,Cost (micros),Conversions\r\n" - ) - handle.write.assert_any_call( - "789,AI Max Campaign 3,test search term,1000,50,1000000,5.0\r\n" - ) + handle.write.assert_any_call("ID,Name,Term,Impr,Clicks,Conv\r\n") + handle.write.assert_any_call("789,AI Max Campaign 3,test search term,1000,50,5.0\r\n") # --- Test main function --- def test_main_campaign_details_report(self): - with patch( - "api_examples.ai_max_reports.get_campaign_details" - ) as mock_get_campaign_details: + with patch("api_examples.ai_max_reports.get_campaign_details") as mock_get_campaign_details: main(self.mock_client, self.customer_id, "campaign_details") - mock_get_campaign_details.assert_called_once_with( - self.mock_client, self.customer_id - ) - - def test_main_landing_page_matches_report(self): - with patch( - "api_examples.ai_max_reports.get_landing_page_matches" - ) as mock_get_landing_page_matches: - main(self.mock_client, self.customer_id, "landing_page_matches") - mock_get_landing_page_matches.assert_called_once_with( - self.mock_client, self.customer_id - ) + mock_get_campaign_details.assert_called_once_with(self.mock_client, self.customer_id) def test_main_search_terms_report(self): - with patch( - "api_examples.ai_max_reports.get_search_terms" - ) as mock_get_search_terms: + with patch("api_examples.ai_max_reports.get_search_terms") as mock_get_search_terms: main(self.mock_client, self.customer_id, "search_terms") - mock_get_search_terms.assert_called_once_with( - self.mock_client, self.customer_id - ) - - def test_main_unknown_report_type(self): - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, "unknown_report") - self.assertEqual(cm.exception.code, 1) - self.assertIn( - "Unknown report type: unknown_report", self.captured_output.getvalue() - ) + mock_get_search_terms.assert_called_once_with(self.mock_client, self.customer_id) def test_main_google_ads_exception(self): + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - error=MagicMock(code=type("obj", (object,), {"name": "REQUEST_ERROR"})()), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), request_id="test_request_id", call=MagicMock(), ) - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, "campaign_details") - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status 'REQUEST_ERROR'", - output, - ) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id, "campaign_details") + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_capture_gclids.py b/api_examples/tests/test_capture_gclids.py index b2d2e65..b6585dd 100644 --- a/api_examples/tests/test_capture_gclids.py +++ b/api_examples/tests/test_capture_gclids.py @@ -48,6 +48,7 @@ def setUp(self): self.customer_id = "1234567890" self.gclid = "test_gclid_123" + self.conversion_date_time = "2026-02-16 12:32:45-08:00" self.captured_output = StringIO() sys.stdout = self.captured_output @@ -72,7 +73,7 @@ def test_main_successful_upload(self): # Make conversions attribute a real list for testing append behavior self.mock_upload_click_conversions_request.conversions = [] - main(self.mock_client, self.customer_id, self.gclid) + main(self.mock_client, self.customer_id, self.gclid, self.conversion_date_time) # Assert get_service calls self.mock_client.get_service.assert_any_call("ConversionUploadService") @@ -94,7 +95,7 @@ def test_main_successful_upload(self): mock_conversion_action_response.resource_name, ) self.assertEqual( - self.mock_click_conversion.conversion_date_time, "2024-01-01 12:32:45-08:00" + self.mock_click_conversion.conversion_date_time, self.conversion_date_time ) self.assertEqual(self.mock_click_conversion.conversion_value, 23.41) self.assertEqual(self.mock_click_conversion.currency_code, "USD") @@ -122,7 +123,7 @@ def test_main_no_conversion_actions_found(self): self.mock_conversion_action_service.search_conversion_actions.return_value = [] with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, self.gclid) + main(self.mock_client, self.customer_id, self.gclid, self.conversion_date_time) self.assertEqual(cm.exception.code, 1) output = self.captured_output.getvalue() @@ -157,7 +158,7 @@ def test_main_google_ads_exception(self): ) with self.assertRaises(GoogleAdsException) as cm: - main(self.mock_client, self.customer_id, self.gclid) + main(self.mock_client, self.customer_id, self.gclid, self.conversion_date_time) # The exception object is now directly the GoogleAdsException ex = cm.exception diff --git a/api_examples/tests/test_collect_conversions_troubleshooting_data.py b/api_examples/tests/test_collect_conversions_troubleshooting_data.py new file mode 100644 index 0000000..ee625c3 --- /dev/null +++ b/api_examples/tests/test_collect_conversions_troubleshooting_data.py @@ -0,0 +1,134 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import os +import unittest +from unittest.mock import MagicMock, patch, mock_open +from io import StringIO + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from google.ads.googleads.errors import GoogleAdsException +from google.ads.googleads.client import GoogleAdsClient +from api_examples.collect_conversions_troubleshooting_data import main + + +class TestCollectConversionsTroubleshootingData(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_ga_service = MagicMock() + self.mock_client.get_service.return_value = self.mock_ga_service + self.customer_id = "1234567890" + + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def tearDown(self): + sys.stdout = sys.__stdout__ + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + @patch("glob.glob") + def test_main_success_healthy(self, mock_glob, mock_file_open, mock_makedirs): + mock_glob.return_value = [] + + # 1. Customer Settings Mock + mock_row_customer = MagicMock() + mock_row_customer.customer.descriptive_name = "Test Customer" + mock_row_customer.customer.conversion_tracking_setting.accepted_customer_data_terms = True + mock_row_customer.customer.conversion_tracking_setting.enhanced_conversions_for_leads_enabled = True + + # 2. Action Summary Mock + mock_row_as = MagicMock() + asum = mock_row_as.offline_conversion_upload_conversion_action_summary + asum.conversion_action_name = "Test Action" + asum.successful_event_count = 50 + asum.total_event_count = 50 + + ds = MagicMock() + ds.upload_date = "2026-02-24" + ds.successful_count = 10 + ds.failed_count = 0 + asum.daily_summaries = [ds] + + mock_batch_customer = MagicMock() + mock_batch_customer.results = [mock_row_customer] + + mock_batch_as = MagicMock() + mock_batch_as.results = [mock_row_as] + + self.mock_ga_service.search_stream.side_effect = [ + [mock_batch_customer], + [mock_batch_as] + ] + + main(self.mock_client, self.customer_id) + + handle = mock_file_open() + written_content = "".join(call.args[0] for call in handle.write.call_args_list) + + self.assertIn("Diagnostic Report for Customer ID: 1234567890", written_content) + self.assertIn("Customer: Test Customer", written_content) + self.assertIn("Action: Test Action (Total Success: 50/50)", written_content) + self.assertIn("No blocking errors detected.", written_content) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + @patch("glob.glob") + def test_main_unhealthy_terms_not_accepted(self, mock_glob, mock_file_open, mock_makedirs): + mock_glob.return_value = [] + + # 1. Customer Settings Mock (Terms NOT accepted) + mock_row_customer = MagicMock() + mock_row_customer.customer.descriptive_name = "Test Customer" + mock_row_customer.customer.conversion_tracking_setting.accepted_customer_data_terms = False + + mock_batch_customer = MagicMock() + mock_batch_customer.results = [mock_row_customer] + + self.mock_ga_service.search_stream.side_effect = [ + [mock_batch_customer], + [] + ] + + main(self.mock_client, self.customer_id) + + handle = mock_file_open() + written_content = "".join(call.args[0] for call in handle.write.call_args_list) + + self.assertIn("CRITICAL: Customer Data Terms NOT accepted.", written_content) + + @patch("os.makedirs") + @patch("builtins.open", new_callable=mock_open) + @patch("glob.glob") + def test_main_google_ads_exception(self, mock_glob, mock_file_open, mock_makedirs): + mock_glob.return_value = [] + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=MagicMock(), + failure=MagicMock(errors=[MagicMock(message="Internal error")]), + request_id="test_request_id", + call=MagicMock(), + ) + + main(self.mock_client, self.customer_id) + + output = self.captured_output.getvalue() + self.assertIn("ERROR: Query failed (Request ID: test_request_id)", output) + self.assertIn("Internal error", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/api_examples/tests/test_conversion_reports.py b/api_examples/tests/test_conversion_reports.py index 4ed44cd..3a81283 100644 --- a/api_examples/tests/test_conversion_reports.py +++ b/api_examples/tests/test_conversion_reports.py @@ -1,45 +1,18 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import re +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock from io import StringIO from datetime import datetime, timedelta -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient - -# Import functions from the script -from api_examples.conversion_reports import ( - main, - handle_googleads_exception, - _calculate_date_range, - _process_and_output_results, - get_conversion_actions_report, - get_conversion_performance_report, -) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +from api_examples.conversion_reports import _calculate_date_range, get_conversion_performance_report class TestConversionReports(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_client = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" @@ -49,310 +22,32 @@ def setUp(self): def tearDown(self): sys.stdout = sys.__stdout__ - # --- Test _calculate_date_range --- - def test_calculate_date_range_preset_last_7_days(self): - start, end = _calculate_date_range(None, None, "LAST_7_DAYS") - today = datetime.now().date() - expected_start = (today - timedelta(days=7)).strftime("%Y-%m-%d") - expected_end = today.strftime("%Y-%m-%d") + def test_calculate_date_range_preset_last_10_days(self): + start, end = _calculate_date_range(None, None, "LAST_10_DAYS") + today = datetime.now() + expected_start = (today - timedelta(days=10)).strftime("%Y-%m-%d") self.assertEqual(start, expected_start) - self.assertEqual(end, expected_end) - - def test_calculate_date_range_explicit_dates(self): - start, end = _calculate_date_range("2025-01-01", "2025-01-31", None) - self.assertEqual(start, "2025-01-01") - self.assertEqual(end, "2025-01-31") - - def test_calculate_date_range_no_dates_or_preset(self): - with self.assertRaises(SystemExit) as cm: - _calculate_date_range(None, None, None) - self.assertEqual(cm.exception.code, 1) - self.assertIn( - "Error: A date range must be specified", self.captured_output.getvalue() - ) - - # --- Test _process_and_output_results --- - def test_process_and_output_results_console(self): - results = [ - {"Metric1": "Value1", "Metric2": "ValueA"}, - {"Metric1": "Value2", "Metric2": "ValueB"}, - ] - _process_and_output_results(results, "console", "") - output = self.captured_output.getvalue() - self.assertIn("Metric1 | Metric2", output) - self.assertIn("Value1 | ValueA", output) - self.assertIn("Value2 | ValueB", output) - - @patch("builtins.open", new_callable=mock_open) - def test_process_and_output_results_csv(self, mock_file_open): - results = [ - {"Metric1": "Value1", "Metric2": "ValueA"}, - {"Metric1": "Value2", "Metric2": "ValueB"}, - ] - output_file = "test.csv" - _process_and_output_results(results, "csv", output_file) - - mock_file_open.assert_called_once_with( - output_file, "w", newline="", encoding="utf-8" - ) - handle = mock_file_open() - handle.write.assert_any_call("Metric1,Metric2\r\n") - handle.write.assert_any_call("Value1,ValueA\r\n") - handle.write.assert_any_call("Value2,ValueB\r\n") - self.assertIn( - f"Results successfully written to {output_file}", - self.captured_output.getvalue(), - ) - - # --- Test get_conversion_actions_report --- - def test_get_conversion_actions_report(self): - mock_ca = MagicMock() - mock_ca.id = 1 - mock_ca.name = "Test Action" - mock_ca.status.name = "ENABLED" - mock_ca.type.name = "WEBPAGE" - mock_ca.category.name = "LEAD" - mock_ca.owner_customer = "customers/123" - mock_ca.include_in_conversions_metric = True - mock_ca.click_through_lookback_window_days = 30 - mock_ca.view_through_lookback_window_days = 1 - mock_ca.attribution_model_settings.attribution_model.name = "LAST_CLICK" - mock_ca.attribution_model_settings.data_driven_model_status.name = "AVAILABLE" + self.assertEqual(end, today.strftime("%Y-%m-%d")) + def test_get_conversion_performance_report_mapping(self): mock_row = MagicMock() - mock_row.conversion_action = mock_ca + mock_row.segments.date = "2026-02-24" + mock_row.campaign.id = 999 + mock_row.campaign.name = "Opti Campaign" + mock_row.metrics.conversions = 10.5 mock_batch = MagicMock() mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - output_file = "actions.csv" - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_conversion_actions_report( - self.mock_client, self.customer_id, output_file - ) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("FROM conversion_action", kwargs["query"]) - - handle = mock_file_open() - handle.write.assert_any_call( - "ID,Name,Status,Type,Category,Owner,Include in Conversions Metric,Click-Through Lookback Window,View-Through Lookback Window,Attribution Model,Data-Driven Model Status\r\n" - ) - handle.write.assert_any_call( - "1,Test Action,ENABLED,WEBPAGE,LEAD,customers/123,True,30,1,LAST_CLICK,AVAILABLE\r\n" - ) - - # --- Test get_conversion_performance_report --- - def test_get_conversion_performance_report_console(self): - mock_row = MagicMock() - mock_row.segments.date = "2025-10-20" - mock_row.campaign.id = 123 - mock_row.campaign.name = "Test Campaign" - mock_row.metrics.conversions = 5.0 - mock_row.metrics.clicks = 100 - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - self.mock_ga_service.search_stream.return_value = [mock_batch] get_conversion_performance_report( - self.mock_client, - self.customer_id, - "console", - "", - "2025-10-01", - "2025-10-31", - None, - ["conversions", "clicks"], - [], - None, - None, + self.mock_client, self.customer_id, "console", "", None, None, "LAST_7_DAYS", + ["conversions"], [], None ) output = self.captured_output.getvalue() - self.assertIn("Date", output) - self.assertIn("Campaign ID", output) - self.assertIn("Campaign Name", output) - self.assertIn("Conversions", output) - self.assertIn("Clicks", output) - self.assertIn("2025-10-20", output) - self.assertIn("123", output) - self.assertIn("Test Campaign", output) - self.assertIn("5.0", output) - self.assertIn("100", output) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("SELECT", kwargs["query"]) - self.assertIn("segments.date", kwargs["query"]) - self.assertIn("campaign.id", kwargs["query"]) - self.assertIn("campaign.name", kwargs["query"]) - self.assertIn("metrics.conversions", kwargs["query"]) - self.assertIn("metrics.clicks", kwargs["query"]) - self.assertIn("FROM campaign", kwargs["query"]) - self.assertIn( - "WHERE segments.date BETWEEN '2025-10-01' AND '2025-10-31'", kwargs["query"] - ) - - def test_get_conversion_performance_report_csv_with_filters_and_order(self): - mock_row = MagicMock() - mock_row.segments.date = "2025-10-20" - mock_row.segments.conversion_action_name = "Website_Sale" - mock_row.metrics.all_conversions = 10.0 - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - output_file = "performance.csv" - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_conversion_performance_report( - self.mock_client, - self.customer_id, - "csv", - output_file, - None, - None, - "LAST_7_DAYS", - ["all_conversions"], - ["conversion_action_name=Website_Sale", "min_conversions=5"], - "all_conversions", - 10, - ) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("SELECT", kwargs["query"]) - self.assertIn("segments.date", kwargs["query"]) - self.assertIn("segments.conversion_action_name", kwargs["query"]) - self.assertIn("metrics.all_conversions", kwargs["query"]) - self.assertIn("FROM customer", kwargs["query"]) - self.assertIn("WHERE segments.date BETWEEN", kwargs["query"]) - self.assertIn( - "AND segments.conversion_action_name = 'Website_Sale'", kwargs["query"] - ) - self.assertIn("AND metrics.conversions > 5.0", kwargs["query"]) - self.assertIn("ORDER BY metrics.all_conversions DESC", kwargs["query"]) - self.assertIn("LIMIT 10", kwargs["query"]) - - handle = mock_file_open() - handle.write.assert_any_call( - "Date,Conversion Action Name,All Conversions\r\n" - ) - handle.write.assert_any_call("2025-10-20,Website_Sale,10.0\r\n") - - # --- Test main function --- - def test_main_conversion_actions_report(self): - with patch( - "api_examples.conversion_reports.get_conversion_actions_report" - ) as mock_get_actions: - main( - self.mock_client, - self.customer_id, - "actions", - "csv", - "actions.csv", - None, - None, - None, - [], - [], - None, - None, - ) - mock_get_actions.assert_called_once_with( - self.mock_client, self.customer_id, "actions.csv" - ) - - def test_main_conversion_performance_report(self): - with patch( - "api_examples.conversion_reports.get_conversion_performance_report" - ) as mock_get_performance: - main( - self.mock_client, - self.customer_id, - "performance", - "console", - "", - "2025-01-01", - "2025-01-31", - None, - ["clicks"], - [], - None, - None, - ) - mock_get_performance.assert_called_once_with( - self.mock_client, - self.customer_id, - "console", - "", - "2025-01-01", - "2025-01-31", - None, - ["clicks"], - [], - None, - None, - ) - - def test_main_unknown_report_type(self): - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "unknown", - "console", - "", - None, - None, - None, - [], - [], - None, - None, - ) - self.assertEqual(cm.exception.code, 1) - self.assertIn("Unknown report type: unknown", self.captured_output.getvalue()) - - def test_handle_googleads_exception(self): - mock_error = MagicMock() - mock_error.message = "Test error message" - mock_error.location.field_path_elements = [MagicMock(field_name="test_field")] - mock_error.code.name = "REQUEST_ERROR" - - mock_failure = MagicMock() - mock_failure.errors = [mock_error] - - mock_exception = GoogleAdsException( - error=mock_error, - call=MagicMock(), - failure=mock_failure, - request_id="test_request_id", - ) - - with self.assertRaises(SystemExit) as cm: - handle_googleads_exception(mock_exception) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertTrue( - re.search( - r'Request with ID "test_request_id" failed with status ".*" and includes the following errors:', - output, - ) - ) - self.assertIn(f'\tError with message "{mock_error.message}".', output) - self.assertIn("\t\tOn field: test_field", output) - + self.assertIn("Opti Campaign", output) + self.assertIn("10.5", output) if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_disapproved_ads_reports.py b/api_examples/tests/test_disapproved_ads_reports.py index 6b33c70..a6904b1 100644 --- a/api_examples/tests/test_disapproved_ads_reports.py +++ b/api_examples/tests/test_disapproved_ads_reports.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,12 +25,7 @@ from google.ads.googleads.client import GoogleAdsClient # Import functions from the script -from api_examples.disapproved_ads_reports import ( - main, - _write_to_csv, - get_all_disapproved_ads, - get_disapproved_ads_for_campaign, -) +from api_examples.disapproved_ads_reports import main class TestDisapprovedAdsReports(unittest.TestCase): @@ -39,325 +34,51 @@ def setUp(self): self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.campaign_id = "111222333" self.captured_output = StringIO() sys.stdout = self.captured_output def tearDown(self): sys.stdout = sys.__stdout__ - # --- Test _write_to_csv --- - @patch("builtins.open", new_callable=mock_open) - def test_write_to_csv(self, mock_file_open): - headers = ["Header1", "Header2"] - rows = [["Value1", "ValueA"], ["Value2", "ValueB"]] - file_path = "test.csv" - _write_to_csv(file_path, headers, rows) - - mock_file_open.assert_called_once_with( - file_path, "w", newline="", encoding="utf-8" - ) - handle = mock_file_open() - handle.write.assert_any_call("Header1,Header2\r\n") - handle.write.assert_any_call("Value1,ValueA\r\n") - handle.write.assert_any_call("Value2,ValueB\r\n") - self.assertIn(f"Report written to {file_path}", self.captured_output.getvalue()) - - # --- Test get_all_disapproved_ads --- - def test_get_all_disapproved_ads(self): - mock_ad = MagicMock() - mock_ad.id = 456 - mock_ad.type_.name = "TEXT_AD" - - mock_policy_topic_entry = MagicMock() - mock_policy_topic_entry.topic = "Adult Content" - mock_policy_topic_entry.type_.name = "POLICY_TYPE_UNSPECIFIED" - mock_policy_topic_entry.evidences = [ - MagicMock(text_list=MagicMock(texts=["Evidence 1", "Evidence 2"])) - ] - - mock_policy_summary = MagicMock() - mock_policy_summary.approval_status.name = "DISAPPROVED" - mock_policy_summary.policy_topic_entries = [mock_policy_topic_entry] - - mock_ad_group_ad = MagicMock() - mock_ad_group_ad.ad = mock_ad - mock_ad_group_ad.policy_summary = mock_policy_summary - - mock_campaign = MagicMock() - mock_campaign.name = "Test Campaign All" - mock_campaign.id = 123 - + def test_main_success(self): mock_row = MagicMock() - mock_row.ad_group_ad = mock_ad_group_ad - mock_row.campaign = mock_campaign + mock_row.campaign.id = 123 + mock_row.campaign.name = "Test Campaign" + mock_row.ad_group_ad.ad.id = 456 + mock_row.ad_group_ad.policy_summary.approval_status.name = "DISAPPROVED" + + mock_entry = MagicMock() + mock_entry.topic = "Adult Content" + mock_row.ad_group_ad.policy_summary.policy_topic_entries = [mock_entry] mock_batch = MagicMock() mock_batch.results = [mock_row] self.mock_ga_service.search_stream.return_value = [mock_batch] - output_file = "all_disapproved_ads.csv" + output_file = "test_disapproved.csv" with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_all_disapproved_ads(self.mock_client, self.customer_id, output_file) + main(self.mock_client, self.customer_id, output_file) self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn("FROM ad_group_ad", kwargs["query"]) - self.assertIn( - "ad_group_ad.policy_summary.approval_status = DISAPPROVED", - kwargs["query"], - ) - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign Name,Campaign ID,Ad ID,Ad Type,Approval Status,Policy Topic,Policy Type,Evidence Text\r\n" - ) - handle.write.assert_any_call( - "Test Campaign All,123,456,TEXT_AD,DISAPPROVED,Adult Content,POLICY_TYPE_UNSPECIFIED,Evidence 1; Evidence 2\r\n" - ) - - # --- Test get_disapproved_ads_for_campaign --- - def test_get_disapproved_ads_for_campaign_console(self): - mock_ad = MagicMock() - mock_ad.id = 789 - mock_ad.type_.name = "IMAGE_AD" - - mock_policy_topic_entry = MagicMock() - mock_policy_topic_entry.topic = "Gambling" - mock_policy_topic_entry.type_.name = "POLICY_TYPE_EDITORIAL" - mock_policy_topic_entry.evidences = [ - MagicMock(text_list=MagicMock(texts=["Gambling content"])) - ] - - mock_policy_summary = MagicMock() - mock_policy_summary.approval_status.name = "DISAPPROVED" - mock_policy_summary.policy_topic_entries = [mock_policy_topic_entry] - - mock_ad_group_ad = MagicMock() - mock_ad_group_ad.ad = mock_ad - mock_ad_group_ad.policy_summary = mock_policy_summary - - mock_campaign = MagicMock() - mock_campaign.name = "Test Campaign Single" - - mock_row = MagicMock() - mock_row.ad_group_ad = mock_ad_group_ad - mock_row.campaign = mock_campaign - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - get_disapproved_ads_for_campaign( - self.mock_client, self.customer_id, self.campaign_id, output_file=None - ) - - self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn(f"campaign.id = {self.campaign_id}", kwargs["query"]) - self.assertIn( - "ad_group_ad.policy_summary.approval_status = DISAPPROVED", kwargs["query"] - ) - - output = self.captured_output.getvalue() - self.assertIn( - f"Campaign Name: Test Campaign Single, Campaign ID: {self.campaign_id}, Ad ID: 789, Ad Type: IMAGE_AD, Approval Status: DISAPPROVED, Policy Topic: Gambling, Policy Type: POLICY_TYPE_EDITORIAL, Evidence Text: Gambling content", - output, - ) - - def test_get_disapproved_ads_for_campaign_csv(self): - mock_ad = MagicMock() - mock_ad.id = 789 - mock_ad.type_.name = "IMAGE_AD" - - mock_policy_topic_entry = MagicMock() - mock_policy_topic_entry.topic = "Gambling" - mock_policy_topic_entry.type_.name = "POLICY_TYPE_EDITORIAL" - mock_policy_topic_entry.evidences = [ - MagicMock(text_list=MagicMock(texts=["Gambling content"])) - ] - - mock_policy_summary = MagicMock() - mock_policy_summary.approval_status.name = "DISAPPROVED" - mock_policy_summary.policy_topic_entries = [mock_policy_topic_entry] - - mock_ad_group_ad = MagicMock() - mock_ad_group_ad.ad = mock_ad - mock_ad_group_ad.policy_summary = mock_policy_summary - - mock_campaign = MagicMock() - mock_campaign.name = "Test Campaign Single" - - mock_row = MagicMock() - mock_row.ad_group_ad = mock_ad_group_ad - mock_row.campaign = mock_campaign - - mock_batch = MagicMock() - mock_batch.results = [mock_row] - - self.mock_ga_service.search_stream.return_value = [mock_batch] - - output_file = "single_disapproved_ads.csv" - with patch("builtins.open", new_callable=mock_open) as mock_file_open: - get_disapproved_ads_for_campaign( - self.mock_client, - self.customer_id, - self.campaign_id, - output_file=output_file, - ) - - handle = mock_file_open() - handle.write.assert_any_call( - "Campaign Name,Campaign ID,Ad ID,Ad Type,Approval Status,Policy Topic,Policy Type,Evidence Text\r\n" - ) - handle.write.assert_any_call( - "Test Campaign Single,111222333,789,IMAGE_AD,DISAPPROVED,Gambling,POLICY_TYPE_EDITORIAL,Gambling content\r\n" - ) - - def test_get_disapproved_ads_for_campaign_no_ads_found(self): - self.mock_ga_service.search_stream.return_value = [] - - get_disapproved_ads_for_campaign( - self.mock_client, self.customer_id, self.campaign_id, output_file=None - ) - - output = self.captured_output.getvalue() - self.assertIn( - f"No disapproved ads found for campaign ID: {self.campaign_id}", output - ) - - # --- Test main function --- - def test_main_all_disapproved_ads_report(self): - with patch( - "api_examples.disapproved_ads_reports.get_all_disapproved_ads" - ) as mock_get_all: - main( - self.mock_client, - self.customer_id, - "all", - output_file="all.csv", - campaign_id=None, - ) - mock_get_all.assert_called_once_with( - self.mock_client, self.customer_id, "all.csv" - ) - - def test_main_single_disapproved_ads_report_console(self): - with patch( - "api_examples.disapproved_ads_reports.get_disapproved_ads_for_campaign" - ) as mock_get_single: - main( - self.mock_client, - self.customer_id, - "single", - output_file=None, - campaign_id=self.campaign_id, - ) - mock_get_single.assert_called_once_with( - self.mock_client, self.customer_id, self.campaign_id, None - ) - self.assertIn( - f"No output file specified. Printing results for campaign {self.campaign_id} to console.", - self.captured_output.getvalue(), - ) - - def test_main_single_disapproved_ads_report_csv(self): - with patch( - "api_examples.disapproved_ads_reports.get_disapproved_ads_for_campaign" - ) as mock_get_single: - main( - self.mock_client, - self.customer_id, - "single", - output_file="single.csv", - campaign_id=self.campaign_id, - ) - mock_get_single.assert_called_once_with( - self.mock_client, self.customer_id, self.campaign_id, "single.csv" - ) - - def test_main_single_report_missing_campaign_id(self): - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "single", - output_file=None, - campaign_id=None, - ) - self.assertEqual(cm.exception.code, 1) - self.assertIn( - "Error: Campaign ID is required for 'single' report type.", - self.captured_output.getvalue(), - ) - - def test_main_unknown_report_type(self): - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "unknown", - output_file=None, - campaign_id=None, - ) - self.assertEqual(cm.exception.code, 1) - self.assertIn("Unknown report type: unknown", self.captured_output.getvalue()) + handle.write.assert_any_call("Campaign ID,Campaign,Ad ID,Status,Topics\r\n") + handle.write.assert_any_call("123,Test Campaign,456,DISAPPROVED,Adult Content\r\n") + self.assertIn(f"Disapproved ads report written to {output_file}", self.captured_output.getvalue()) def test_main_google_ads_exception(self): - class MockIterator: - def __init__(self, exception_to_raise): - self.exception_to_raise = exception_to_raise - self.first_call = True - - def __iter__(self): - return self - - def __next__(self): - if self.first_call: - self.first_call = False - raise self.exception_to_raise - raise StopIteration - - self.mock_ga_service.search_stream.return_value = MockIterator( - GoogleAdsException( - error=MagicMock(code=MagicMock(name="REQUEST_ERROR")), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", + call=MagicMock(), ) - with self.assertRaises(SystemExit) as cm: - main( - self.mock_client, - self.customer_id, - "all", - output_file="test.csv", - campaign_id=None, - ) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status ", - output, - ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id, "test.csv") + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_gaql_validator.py b/api_examples/tests/test_gaql_validator.py new file mode 100644 index 0000000..6eb63a3 --- /dev/null +++ b/api_examples/tests/test_gaql_validator.py @@ -0,0 +1,41 @@ +# Copyright 2026 Google LLC +import sys +import os +import unittest +from unittest.mock import MagicMock, patch +from io import StringIO + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from api_examples.gaql_validator import main + +class TestGAQLValidator(unittest.TestCase): + def setUp(self): + self.mock_client = MagicMock() + self.mock_ga_service = MagicMock() + self.mock_client.get_service.return_value = self.mock_ga_service + self.customer_id = "1234567890" + self.api_version = "v23" + self.test_query = "SELECT campaign.id FROM campaign" + self.captured_output = StringIO() + sys.stdout = self.captured_output + + def tearDown(self): + sys.stdout = sys.__stdout__ + + @patch("importlib.import_module") + def test_main_success(self, mock_import): + mock_module = MagicMock() + mock_import.return_value = mock_module + mock_request_class = MagicMock() + setattr(mock_module, "SearchGoogleAdsRequest", mock_request_class) + + main(client=self.mock_client, customer_id=self.customer_id, api_version=self.api_version, query=self.test_query) + + self.mock_ga_service.search.assert_called_once() + output = self.captured_output.getvalue() + self.assertIn("[DRY RUN]", output) + self.assertIn("SUCCESS: GAQL query is structurally valid.", output) + +if __name__ == "__main__": + unittest.main() diff --git a/api_examples/tests/test_get_campaign_bid_simulations.py b/api_examples/tests/test_get_campaign_bid_simulations.py index 9d7b76e..46e3b81 100644 --- a/api_examples/tests/test_get_campaign_bid_simulations.py +++ b/api_examples/tests/test_get_campaign_bid_simulations.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -42,16 +42,10 @@ def tearDown(self): sys.stdout = sys.__stdout__ def test_main_successful_call(self): - # Mock the stream and its results - mock_simulation = MagicMock() - mock_simulation.bid_modifier = 1.0 - mock_simulation.clicks = 100 - mock_simulation.cost_micros = 1000000 - mock_simulation.conversions = 10.0 - mock_simulation.conversion_value = 500.0 - mock_row = MagicMock() - mock_row.campaign_bid_simulation = mock_simulation + mock_row.campaign_bid_simulation.bid_modifier = 1.0 + mock_row.campaign_bid_simulation.clicks = 100 + mock_row.campaign_bid_simulation.cost_micros = 1000000 mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -60,98 +54,25 @@ def test_main_successful_call(self): main(self.mock_client, self.customer_id, self.campaign_id) - # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn(f"campaign.id = {self.campaign_id}", kwargs["query"]) - self.assertIn( - "campaign_bid_simulation.start_date = '2025-08-24'", kwargs["query"] - ) - self.assertIn( - "campaign_bid_simulation.end_date = '2025-09-23'", kwargs["query"] - ) - - # Assert that the output contains the expected information output = self.captured_output.getvalue() - self.assertIn( - f"Campaign bid simulations for Campaign ID: {self.campaign_id}", output - ) - self.assertIn( - "1.00 | 100 | 1000000 | 10.00 | 500.00 ", - output, - ) - - def test_main_no_simulations_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id, self.campaign_id) - - output = self.captured_output.getvalue() - self.assertIn( - f"Campaign bid simulations for Campaign ID: {self.campaign_id}", output - ) - self.assertIn( - "Bid Modifier | Clicks | Cost (micros) | Conversions | Conversion Value", - output, - ) - self.assertNotIn( - "|", - output[ - output.find( - "------------------------------------------------------------------" - ) - + len( - "------------------------------------------------------------------" - ) : - ], - ) + self.assertIn("1.00", output) + self.assertIn("100", output) + self.assertIn("1000000", output) def test_main_google_ads_exception(self): - class MockIterator: - def __init__(self, exception_to_raise): - self.exception_to_raise = exception_to_raise - self.first_call = True - - def __iter__(self): - return self - - def __next__(self): - if self.first_call: - self.first_call = False - raise self.exception_to_raise - raise StopIteration - - self.mock_ga_service.search_stream.return_value = MockIterator( - GoogleAdsException( - error=MagicMock(code=MagicMock(name="REQUEST_ERROR")), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - ) # Closing parenthesis for MockIterator - - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id, self.campaign_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status ", - output, + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", + call=MagicMock(), ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn("Error with message: 'Error details'.", output) - self.assertIn("On field: test_field", output) + + main(self.mock_client, self.customer_id, self.campaign_id) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_get_campaign_shared_sets.py b/api_examples/tests/test_get_campaign_shared_sets.py index cb090b9..3fbf737 100644 --- a/api_examples/tests/test_get_campaign_shared_sets.py +++ b/api_examples/tests/test_get_campaign_shared_sets.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -24,28 +24,15 @@ from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient -# Import the main function from the script +# Import functions from the script from api_examples.get_campaign_shared_sets import main class TestGetCampaignSharedSets(unittest.TestCase): def setUp(self): self.mock_client = MagicMock(spec=GoogleAdsClient) - self.mock_client.enums = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service - - # Mock the enums for SharedSetTypeEnum - self.mock_client.enums.SharedSetTypeEnum = type( - "SharedSetTypeEnum", - (object,), - { - "KEYWORD_NEGATIVE": type( - "SharedSetType", (object,), {"name": "KEYWORD_NEGATIVE"} - ) - }, - ) - self.customer_id = "1234567890" self.captured_output = StringIO() sys.stdout = self.captured_output @@ -53,19 +40,11 @@ def setUp(self): def tearDown(self): sys.stdout = sys.__stdout__ - def test_main_successful_call(self): - mock_campaign = MagicMock() - mock_campaign.id = 111 - mock_campaign.name = "Test Campaign" - - mock_shared_set = MagicMock() - mock_shared_set.id = 222 - mock_shared_set.name = "Test Shared Set" - mock_shared_set.type = self.mock_client.enums.SharedSetTypeEnum.KEYWORD_NEGATIVE - + def test_main_success(self): mock_row = MagicMock() - mock_row.campaign = mock_campaign - mock_row.shared_set = mock_shared_set + mock_row.campaign.name = "Test Campaign" + mock_row.shared_set.name = "Test Shared Set" + mock_row.shared_set.type.name = "KEYWORD_NEGATIVE" mock_batch = MagicMock() mock_batch.results = [mock_row] @@ -74,68 +53,25 @@ def test_main_successful_call(self): main(self.mock_client, self.customer_id) - # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - actual_query = kwargs["query"].replace("\n", "").replace(" ", "") - self.assertIn("FROMcampaign_shared_set", actual_query) - self.assertIn( - "SELECTcampaign.id,campaign.name,campaign_shared_set.shared_set,shared_set.id,shared_set.name,shared_set.type", - actual_query, - ) - self.assertIn("ORDERBYcampaign.id", actual_query) - - # Assert that the output contains the expected information output = self.captured_output.getvalue() - self.assertIn("Campaign Shared Sets:", output) - self.assertIn( - "Campaign ID: 111, Campaign Name: Test Campaign, Shared Set ID: 222, Shared Set Name: Test Shared Set, Shared Set Type: KEYWORD_NEGATIVE", - output, - ) - - def test_main_no_shared_sets_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertIn("Campaign Shared Sets:", output) - self.assertIn("---------------------", output) - self.assertNotIn("Campaign ID:", output) + self.assertIn("Test Campaign", output) + self.assertIn("Test Shared Set", output) + self.assertIn("KEYWORD_NEGATIVE", output) def test_main_google_ads_exception(self): + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - call=MagicMock(), - error=MagicMock( - code=MagicMock( - return_value=type("ErrorCode", (object,), {"name": "REQUEST_ERROR"}) - ) - ), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), request_id="test_request_id", + call=MagicMock(), ) - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status 'REQUEST_ERROR' and includes the following errors:", - output, - ) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_get_change_history.py b/api_examples/tests/test_get_change_history.py index 17654f3..cc49574 100644 --- a/api_examples/tests/test_get_change_history.py +++ b/api_examples/tests/test_get_change_history.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,13 +20,12 @@ import unittest from unittest.mock import MagicMock from io import StringIO -from datetime import datetime, timedelta from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient -# Import the main function from the script -from api_examples.get_change_history import main, handle_googleads_exception +# Import functions from the script +from api_examples.get_change_history import main class TestGetChangeHistory(unittest.TestCase): @@ -35,88 +34,45 @@ def setUp(self): self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.start_date = (datetime.now().date() - timedelta(days=7)).strftime( - "%Y-%m-%d" - ) - self.end_date = datetime.now().date().strftime("%Y-%m-%d") self.captured_output = StringIO() sys.stdout = self.captured_output def tearDown(self): sys.stdout = sys.__stdout__ - def test_main_successful_call(self): - # Mock the stream and its results - mock_change_status = MagicMock() - mock_change_status.last_change_date_time = "2025-10-20 10:00:00" - mock_change_status.resource_type.name = "CAMPAIGN" - mock_change_status.resource_name = "customers/1234567890/campaigns/111" - mock_change_status.resource_status.name = "ENABLED" - + def test_main_success(self): mock_row = MagicMock() - mock_row.change_status = mock_change_status + mock_row.change_status.resource_name = "customers/123/campaigns/456" + mock_row.change_status.last_change_date_time = "2026-02-24 10:00:00" + mock_row.change_status.resource_type.name = "CAMPAIGN" + mock_row.change_status.resource_status.name = "ADDED" mock_batch = MagicMock() mock_batch.results = [mock_row] self.mock_ga_service.search_stream.return_value = [mock_batch] - main(self.mock_client, self.customer_id, self.start_date, self.end_date) + main(self.mock_client, self.customer_id, "2026-02-17", "2026-02-24") - # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - f"change_status.last_change_date_time BETWEEN '{self.start_date}' AND '{self.end_date}'", - kwargs["query"], - ) - - # Assert that the output contains the expected information - output = self.captured_output.getvalue() - self.assertIn( - f"Retrieving change history for customer ID: {self.customer_id} from {self.start_date} to {self.end_date}", - output, - ) - self.assertIn("Change Date/Time: 2025-10-20 10:00:00", output) - self.assertIn("Resource Type: CAMPAIGN", output) - self.assertIn("Resource Name: customers/1234567890/campaigns/111", output) - self.assertIn("Resource Status: ENABLED", output) - - def test_main_no_changes_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id, self.start_date, self.end_date) - output = self.captured_output.getvalue() - self.assertIn("No changes found for the specified date range.", output) + self.assertIn("CAMPAIGN", output) + self.assertIn("ADDED", output) + self.assertIn("customers/123/campaigns/456", output) - def test_handle_googleads_exception(self): - mock_error = MagicMock() - mock_error.message = "Test error message" - mock_error.location.field_path_elements = [MagicMock(field_name="test_field")] + def test_main_google_ads_exception(self): mock_failure = MagicMock() - mock_failure.errors = [mock_error] - mock_exception = GoogleAdsException( + mock_failure.errors = [MagicMock(message="Error details")] + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( error=MagicMock(), - call=MagicMock(), failure=mock_failure, request_id="test_request_id", + call=MagicMock(), ) - mock_exception.error.code = MagicMock() - mock_exception.error.code.return_value.name = "REQUEST_ERROR" - with self.assertRaises(SystemExit) as cm: - handle_googleads_exception(mock_exception) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - 'Request with ID "test_request_id" failed with status "REQUEST_ERROR"', - output, - ) - self.assertIn('Error with message "Test error message".', output) - self.assertIn("On field: test_field", output) + main(self.mock_client, self.customer_id, "2026-02-17", "2026-02-24") + self.assertIn("Error (Request ID test_request_id): Error details", self.captured_output.getvalue()) if __name__ == "__main__": diff --git a/api_examples/tests/test_get_conversion_upload_summary.py b/api_examples/tests/test_get_conversion_upload_summary.py index ff3acd5..52d0252 100644 --- a/api_examples/tests/test_get_conversion_upload_summary.py +++ b/api_examples/tests/test_get_conversion_upload_summary.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,14 +14,17 @@ import sys import os -import unittest -from unittest.mock import MagicMock, call -from io import StringIO sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +import unittest +from unittest.mock import MagicMock +from io import StringIO + from google.ads.googleads.errors import GoogleAdsException from google.ads.googleads.client import GoogleAdsClient + +# Import functions from the script from api_examples.get_conversion_upload_summary import main @@ -31,7 +34,6 @@ def setUp(self): self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.captured_output = StringIO() sys.stdout = self.captured_output @@ -39,69 +41,46 @@ def tearDown(self): sys.stdout = sys.__stdout__ def test_main_success(self): - # Mock responses for search_stream - mock_batch_1 = MagicMock() - mock_row_1 = MagicMock() - mock_summary_1 = MagicMock() - mock_summary_1.resource_name = "customers/123/offlineConversionUploadClientSummaries/1" - mock_summary_1.status.name = "SUCCESS" - mock_summary_1.total_event_count = 10 - mock_summary_1.successful_event_count = 10 - mock_summary_1.success_rate = 1.0 - mock_summary_1.last_upload_date_time = "2024-01-01 12:00:00" - mock_summary_1.alerts = [] - mock_summary_1.daily_summaries = [] - mock_summary_1.job_summaries = [] - mock_row_1.offline_conversion_upload_client_summary = mock_summary_1 - mock_batch_1.results = [mock_row_1] - - mock_batch_2 = MagicMock() - mock_row_2 = MagicMock() - mock_summary_2 = MagicMock() - mock_summary_2.resource_name = "customers/123/offlineConversionUploadConversionActionSummaries/1" - mock_summary_2.conversion_action_name = "My Conversion Action" - mock_summary_2.status.name = "SUCCESS" - mock_summary_2.total_event_count = 5 - mock_summary_2.successful_event_count = 5 - mock_summary_2.alerts = [] - mock_summary_2.daily_summaries = [] - mock_summary_2.job_summaries = [] - mock_row_2.offline_conversion_upload_conversion_action_summary = mock_summary_2 - mock_batch_2.results = [mock_row_2] - - # The first call returns client summary, second call returns conversion action summary - self.mock_ga_service.search_stream.side_effect = [[mock_batch_1], [mock_batch_2]] + mock_row = MagicMock() + mock_summary = mock_row.offline_conversion_upload_client_summary + mock_summary.client.name = "GOOGLE_ADS_API" + mock_summary.status.name = "SUCCESS" + mock_summary.total_event_count = 10 + mock_summary.successful_event_count = 10 + + mock_ds = MagicMock() + mock_ds.upload_date = "2026-02-24" + mock_ds.successful_count = 10 + mock_ds.failed_count = 0 + mock_summary.daily_summaries = [mock_ds] + + mock_batch = MagicMock() + mock_batch.results = [mock_row] + + self.mock_ga_service.search_stream.return_value = [mock_batch] main(self.mock_client, self.customer_id) - # Check output + self.mock_ga_service.search_stream.assert_called_once() output = self.captured_output.getvalue() - self.assertIn("Offline Conversion Upload Client Summary:", output) - self.assertIn("Resource Name: customers/123/offlineConversionUploadClientSummaries/1", output) - self.assertIn("Offline Conversion Upload Conversion Action Summary:", output) - self.assertIn("Conversion Action Name: My Conversion Action", output) - - self.assertEqual(self.mock_ga_service.search_stream.call_count, 2) + self.assertIn("Client: GOOGLE_ADS_API, Status: SUCCESS", output) + self.assertIn("Total: 10, Success: 10", output) + self.assertIn("2026-02-24: 10/10 successful", output) def test_main_google_ads_exception(self): mock_error = MagicMock() - mock_error.code.return_value.name = "INTERNAL_ERROR" - mock_failure = MagicMock() - mock_failure.errors = [MagicMock(message="Internal error")] + mock_error.code.return_value.name = "REQUEST_ERROR" self.mock_ga_service.search_stream.side_effect = GoogleAdsException( error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", call=MagicMock(), - failure=mock_failure, - request_id="test_request_id" ) - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn('Request with ID "test_request_id" failed with status "INTERNAL_ERROR"', output) + main(self.mock_client, self.customer_id) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", self.captured_output.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_get_geo_targets.py b/api_examples/tests/test_get_geo_targets.py index 157d2d2..1428f65 100644 --- a/api_examples/tests/test_get_geo_targets.py +++ b/api_examples/tests/test_get_geo_targets.py @@ -1,208 +1,30 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest from unittest.mock import MagicMock from io import StringIO -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) -# Import the main function from the script from api_examples.get_geo_targets import main - class TestGetGeoTargets(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_client = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service - self.customer_id = "1234567890" self.captured_output = StringIO() + self.sys_stdout = sys.stdout sys.stdout = self.captured_output def tearDown(self): - sys.stdout = sys.__stdout__ - - def test_main_successful_call(self): - # Mock for the first search_stream call (campaign_criterion) - mock_campaign = MagicMock() - mock_campaign.id = 123 - mock_campaign.name = "Test Campaign" - - mock_campaign_criterion = MagicMock() - mock_campaign_criterion.negative = False - mock_campaign_criterion.criterion_id = ( - 21137 # Example geo target ID for New York - ) - - mock_row_1 = MagicMock() - mock_row_1.campaign = mock_campaign - mock_row_1.campaign_criterion = mock_campaign_criterion - - mock_batch_1 = MagicMock() - mock_batch_1.results = [mock_row_1] - - # Mock for the second search_stream call (geo_target_constant) - mock_geo_target_constant = MagicMock() - mock_geo_target_constant.name = "New York" - mock_geo_target_constant.canonical_name = "New York, New York, United States" - mock_geo_target_constant.country_code = "US" - - mock_geo_row = MagicMock() - mock_geo_row.geo_target_constant = mock_geo_target_constant - - mock_geo_batch = MagicMock() - mock_geo_batch.results = [mock_geo_row] - - # Configure the mock_ga_service to return different streams for different queries - def search_stream_side_effect(customer_id, query): - if "campaign_criterion.type = 'LOCATION'" in query: - yield mock_batch_1 - elif ( - "geo_target_constant.resource_name = 'geoTargetConstants/21137'" - in query - ): - yield mock_geo_batch - else: - raise ValueError("Unexpected query") - - self.mock_ga_service.search_stream.side_effect = search_stream_side_effect - - main(self.mock_client, self.customer_id) - - # Assert that search_stream was called with the correct arguments for both queries - self.assertEqual(self.mock_ga_service.search_stream.call_count, 2) - - # Check the first call (campaign_criterion) - first_call_args, first_call_kwargs = ( - self.mock_ga_service.search_stream.call_args_list[0] - ) - self.assertEqual(first_call_kwargs["customer_id"], self.customer_id) - self.assertIn( - "campaign_criterion.type = 'LOCATION'", first_call_kwargs["query"] - ) - - # Check the second call (geo_target_constant) - second_call_args, second_call_kwargs = ( - self.mock_ga_service.search_stream.call_args_list[1] - ) - self.assertEqual(second_call_kwargs["customer_id"], self.customer_id) - self.assertIn( - "geo_target_constant.resource_name = 'geoTargetConstants/21137'", - second_call_kwargs["query"], - ) - - # Assert that the output contains the expected information - output = self.captured_output.getvalue() - self.assertIn("Geo targets found:", output) - self.assertIn( - "Campaign with ID 123, name 'Test Campaign' has geo target 'New York' (Canonical Name: 'New York, New York, United States', Country Code: 'US', Negative: False)", - output, - ) - - def test_main_no_geo_targets_found(self): - # Mock the first search_stream call to return no results - mock_batch_1 = MagicMock() - mock_batch_1.results = [] - self.mock_ga_service.search_stream.return_value = [mock_batch_1] - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertIn("Geo targets found:", output) # The header is always printed - self.assertNotIn( - "Campaign with ID", output - ) # No campaign details should be printed - - def test_main_google_ads_exception_first_query(self): - self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - error=MagicMock(code=type('obj', (object,), {'name': 'REQUEST_ERROR'})()), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - - with self.assertRaises(SystemExit) as cm: - main(self.mock_client, self.customer_id) - - self.assertEqual(cm.exception.code, 1) - output = self.captured_output.getvalue() - self.assertIn( - 'Request with ID "test_request_id" failed with status "REQUEST_ERROR"', - output, - ) - self.assertIn('Error with message "Error details"', output) - self.assertIn("On field: test_field", output) - - def test_main_google_ads_exception_second_query(self): - # Mock for the first search_stream call (campaign_criterion) - mock_campaign = MagicMock() - mock_campaign.id = 123 - mock_campaign.name = "Test Campaign" - - mock_campaign_criterion = MagicMock() - mock_campaign_criterion.negative = False - mock_campaign_criterion.criterion_id = ( - 21137 # Example geo target ID for New York - ) - - mock_row_1 = MagicMock() - mock_row_1.campaign = mock_campaign - mock_row_1.campaign_criterion = mock_campaign_criterion - - mock_batch_1 = MagicMock() - mock_batch_1.results = [mock_row_1] - - # Configure the mock_ga_service to raise an exception on the second call - def search_stream_side_effect(customer_id, query): - if "campaign_criterion.type = 'LOCATION'" in query: - yield mock_batch_1 - elif "geo_target_constant.resource_name" in query: - raise GoogleAdsException( - error=MagicMock(code=MagicMock(name="GEO_ERROR")), - call=MagicMock(), - failure=MagicMock(errors=[MagicMock(message="Geo error details")]), - request_id="geo_request_id", - ) - else: - raise ValueError("Unexpected query") - - self.mock_ga_service.search_stream.side_effect = search_stream_side_effect - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertIn( - "Error retrieving geo target details for geoTargetConstants/21137: Geo error details", - output, - ) + sys.stdout = self.sys_stdout + def test_main_no_geo_targets(self): + self.mock_ga_service.search_stream.return_value = [] + main(self.mock_client, "123") + self.assertIn("No geo targets found.", self.captured_output.getvalue()) if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_list_accessible_users.py b/api_examples/tests/test_list_accessible_users.py index 1685c3e..3388c07 100644 --- a/api_examples/tests/test_list_accessible_users.py +++ b/api_examples/tests/test_list_accessible_users.py @@ -1,117 +1,38 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest from unittest.mock import MagicMock, patch from io import StringIO -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) -# Import the main function from the script from api_examples.list_accessible_users import main - class TestListAccessibleUsers(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) - self.mock_customer_service = MagicMock() - self.mock_client.get_service.return_value = self.mock_customer_service self.captured_output = StringIO() + self.sys_stdout = sys.stdout sys.stdout = self.captured_output def tearDown(self): - sys.stdout = sys.__stdout__ - - def test_main_successful_call(self): - mock_accessible_customers = MagicMock() - mock_accessible_customers.resource_names = [ - "customers/1111111111", - "customers/2222222222", - ] - self.mock_customer_service.list_accessible_customers.return_value = ( - mock_accessible_customers - ) - - main(self.mock_client) - - # Assert that list_accessible_customers was called - self.mock_customer_service.list_accessible_customers.assert_called_once() - - # Assert that the output contains the expected information + sys.stdout = self.sys_stdout + + @patch("api_examples.list_accessible_users.GoogleAdsClient.load_from_storage") + def test_main_success(self, mock_load): + mock_client = MagicMock() + mock_load.return_value = mock_client + mock_service = MagicMock() + mock_client.get_service.return_value = mock_service + + mock_accessible = MagicMock() + mock_accessible.resource_names = ["customers/1", "customers/2"] + mock_service.list_accessible_customers.return_value = mock_accessible + + main(mock_client) output = self.captured_output.getvalue() - self.assertIn("Total results: 2", output) - self.assertIn('Customer resource name: "customers/1111111111"', output) - self.assertIn('Customer resource name: "customers/2222222222"', output) - - def test_main_no_accessible_customers(self): - mock_accessible_customers = MagicMock() - mock_accessible_customers.resource_names = [] - self.mock_customer_service.list_accessible_customers.return_value = ( - mock_accessible_customers - ) - - main(self.mock_client) - - output = self.captured_output.getvalue() - self.assertIn("Total results: 0", output) - self.assertNotIn("Customer resource name:", output) - - @patch("sys.exit") - def test_main_google_ads_exception(self, mock_sys_exit): - self.mock_customer_service.list_accessible_customers.side_effect = ( - GoogleAdsException( - error=MagicMock( - code=MagicMock( - return_value=type( - "ErrorCode", (object,), {"name": "REQUEST_ERROR"} - ) - ) - ), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - ) - - main(self.mock_client) - - mock_sys_exit.assert_called_once_with(1) - output = self.captured_output.getvalue() - self.assertTrue( - output.startswith( - 'Request with ID "test_request_id" failed with status "REQUEST_ERROR" and includes the following errors:' - ) - ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn('\tError with message "Error details".', output) - self.assertIn("\t\tOn field: test_field", output) - + self.assertIn("Found 2 accessible customers.", output) + self.assertIn("customers/1", output) if __name__ == "__main__": unittest.main() diff --git a/api_examples/tests/test_list_pmax_campaigns.py b/api_examples/tests/test_list_pmax_campaigns.py index ce33ff6..e3ca7df 100644 --- a/api_examples/tests/test_list_pmax_campaigns.py +++ b/api_examples/tests/test_list_pmax_campaigns.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -43,8 +43,11 @@ def tearDown(self): def test_main_successful_call(self): # Mock the stream and its results mock_campaign = MagicMock() + mock_campaign.id = "12345" mock_campaign.name = "Test PMax Campaign" - mock_campaign.advertising_channel_type.name = "PERFORMANCE_MAX" + mock_campaign.status.name = "ENABLED" + mock_campaign.primary_status.name = "ELIGIBLE" + mock_campaign.primary_status_reasons = [] mock_row = MagicMock() mock_row.campaign = mock_campaign @@ -58,58 +61,23 @@ def test_main_successful_call(self): # Assert that search_stream was called with the correct arguments self.mock_ga_service.search_stream.assert_called_once() - args, kwargs = self.mock_ga_service.search_stream.call_args - self.assertEqual(kwargs["customer_id"], self.customer_id) - self.assertIn( - "campaign.advertising_channel_type = 'PERFORMANCE_MAX'", kwargs["query"] - ) - + # Assert that the output contains the expected information output = self.captured_output.getvalue() - self.assertIn( - 'Campaign with name "Test PMax Campaign" is a PERFORMANCE_MAX campaign.', - output, - ) - - def test_main_no_pmax_campaigns_found(self): - self.mock_ga_service.search_stream.return_value = [] - - main(self.mock_client, self.customer_id) - - output = self.captured_output.getvalue() - self.assertEqual(output, "") # No output if no campaigns are found + self.assertIn("12345", output) + self.assertIn("Test PMax Campaign", output) + self.assertIn("ENABLED", output) + self.assertIn("ELIGIBLE", output) def test_main_google_ads_exception(self): - class MockIterator: - def __init__(self, exception_to_raise): - self.exception_to_raise = exception_to_raise - self.first_call = True - - def __iter__(self): - return self - - def __next__(self): - if self.first_call: - self.first_call = False - raise self.exception_to_raise - raise StopIteration - - self.mock_ga_service.search_stream.return_value = MockIterator( - GoogleAdsException( - error=MagicMock(code=MagicMock(name="REQUEST_ERROR")), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) + mock_error = MagicMock() + mock_error.code.return_value.name = "REQUEST_ERROR" + + self.mock_ga_service.search_stream.side_effect = GoogleAdsException( + error=mock_error, + failure=MagicMock(errors=[MagicMock(message="Error details")]), + request_id="test_request_id", + call=MagicMock(), ) with self.assertRaises(SystemExit) as cm: @@ -117,13 +85,7 @@ def __next__(self): self.assertEqual(cm.exception.code, 1) output = self.captured_output.getvalue() - self.assertIn( - "Request with ID 'test_request_id' failed with status ", - output, - ) - self.assertIn("REQUEST_ERROR", output) - self.assertIn("Error with message 'Error details'.", output) - self.assertIn("On field: 'test_field'", output) + self.assertIn("Request ID test_request_id failed: REQUEST_ERROR", output) if __name__ == "__main__": diff --git a/api_examples/tests/test_parallel_report_downloader_optimized.py b/api_examples/tests/test_parallel_report_downloader_optimized.py index 0b5d140..8204558 100644 --- a/api_examples/tests/test_parallel_report_downloader_optimized.py +++ b/api_examples/tests/test_parallel_report_downloader_optimized.py @@ -1,214 +1,31 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - +# Copyright 2026 Google LLC import sys import os - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) - import unittest -from unittest.mock import MagicMock, patch -from io import StringIO -from datetime import datetime, timedelta +from unittest.mock import MagicMock -from google.ads.googleads.errors import GoogleAdsException -from google.ads.googleads.client import GoogleAdsClient - -# Import functions from the script -from api_examples.parallel_report_downloader_optimized import ( - _get_date_range_strings, - fetch_report_threaded, - main, -) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) +from api_examples.parallel_report_downloader_optimized import fetch_report_threaded -class TestParallelReportDownloaderOptimized(unittest.TestCase): +class TestParallelDownloader(unittest.TestCase): def setUp(self): - self.mock_client = MagicMock(spec=GoogleAdsClient) + self.mock_client = MagicMock() self.mock_ga_service = MagicMock() self.mock_client.get_service.return_value = self.mock_ga_service self.customer_id = "1234567890" - self.captured_output = StringIO() - sys.stdout = self.captured_output - - def tearDown(self): - sys.stdout = sys.__stdout__ - # --- Test _get_date_range_strings --- - def test_get_date_range_strings(self): - start_date_str, end_date_str = _get_date_range_strings() - today = datetime.now().date() - expected_end = today.strftime("%Y-%m-%d") - expected_start = (today - timedelta(days=30)).strftime("%Y-%m-%d") - self.assertEqual(start_date_str, expected_start) - self.assertEqual(end_date_str, expected_end) - - # --- Test fetch_report_threaded --- - def test_fetch_report_threaded_success(self): + def test_fetch_report_threaded_logging(self): mock_row = MagicMock() - mock_row.campaign.id = 1 - mock_row.campaign.name = "Test Campaign" - mock_batch = MagicMock() mock_batch.results = [mock_row] - self.mock_ga_service.search_stream.return_value = [mock_batch] - report_name, rows, exception = fetch_report_threaded( - self.mock_client, - self.customer_id, - "SELECT campaign.id FROM campaign", - "Test Report", - ) - - self.assertEqual(report_name, "Test Report") - self.assertIsNotNone(rows) - self.assertEqual(len(rows), 1) - self.assertIsNone(exception) - self.mock_ga_service.search_stream.assert_called_once() - self.assertIn( - "[Test Report] Starting report fetch", self.captured_output.getvalue() - ) - self.assertIn( - "[Test Report] Finished report fetch. Found 1 rows.", - self.captured_output.getvalue(), - ) - - def test_fetch_report_threaded_exception(self): - self.mock_ga_service.search_stream.side_effect = GoogleAdsException( - error=MagicMock(), - call=MagicMock(), - failure=MagicMock( - errors=[ - MagicMock( - message="Error details", - location=MagicMock( - field_path_elements=[MagicMock(field_name="test_field")] - ), - ) - ] - ), - request_id="test_request_id", - ) - - report_name, rows, exception = fetch_report_threaded( - self.mock_client, - self.customer_id, - "SELECT campaign.id FROM campaign", - "Test Report With Error", - ) - - self.assertEqual(report_name, "Test Report With Error") - self.assertEqual(rows, []) - self.assertIsNotNone(exception) - self.assertIsInstance(exception, GoogleAdsException) - self.assertIn( - "[Test Report With Error] Request with ID 'test_request_id' failed", - self.captured_output.getvalue(), - ) - - # --- Test main function --- - @patch("api_examples.parallel_report_downloader_optimized.fetch_report_threaded") - @patch( - "api_examples.parallel_report_downloader_optimized.GoogleAdsClient.load_from_storage" - ) - def test_main_multiple_customers_and_reports( - self, mock_load_from_storage, mock_fetch_report_threaded - ): - mock_load_from_storage.return_value = self.mock_client - - # Mock the return value of fetch_report_threaded - mock_fetch_report_threaded.side_effect = [ - ( - "Campaign Performance (Customer: 111)", - [MagicMock(campaign=MagicMock(id=1))], - None, - ), - ( - "Ad Group Performance (Customer: 111)", - [MagicMock(ad_group=MagicMock(id=2))], - None, - ), - ( - "Keyword Performance (Customer: 111)", - [MagicMock(keyword_view=MagicMock(text="kw1"))], - None, - ), - ( - "Campaign Performance (Customer: 222)", - [MagicMock(campaign=MagicMock(id=3))], - None, - ), - ( - "Ad Group Performance (Customer: 222)", - [MagicMock(ad_group=MagicMock(id=4))], - None, - ), - ( - "Keyword Performance (Customer: 222)", - None, - GoogleAdsException("Error", None, None, None), - ), # Simulate an error - ] - - customer_ids = ["111", "222"] - login_customer_id = "000" - - main(customer_ids, login_customer_id) - - # Assert login_customer_id was set - self.assertEqual(self.mock_client.login_customer_id, login_customer_id) - - # Assert fetch_report_threaded was called for each report and customer - output = self.captured_output.getvalue() - expected_output_substrings = [ - "--- Results for Campaign Performance (Last 30 Days) (Customer: 111) ---", - "Row 1: `click_time`. + * Click is within Lookback Window. + +### 4. Troubleshooting Workflow [MANDATORY] + +1. **STEP 1: Diagnostic Summaries**: Execute queries against `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary`. + * **[PITFALL] Attribute Name**: Use `successful_count` and `failed_count`. DO NOT use `success_count`. + * **[PITFALL] Summary Object**: `daily_summaries` (OfflineConversionSummary) DOES NOT have a `total_count` field. Use `successful_count + failed_count + pending_count` for a total. `total_event_count` is only available at the top-level resource, not within `daily_summaries`. + * **[PITFALL] Alert Object**: `alerts` (OfflineConversionAlert) uses `error` and `error_percentage`. DO NOT use `error_code` or `error_count`. + * **[PITFALL] Alerts Field Location**: The `alerts` field is located at the top-level resource (`offline_conversion_upload_client_summary` or `offline_conversion_upload_conversion_action_summary`), NOT within the `daily_summaries` list. +2. **STEP 2: Exception Inspection**: Catch `GoogleAdsException` and iterate over `ex.failure.errors`. +3. **STEP 3: Identity & Consent**: Verify GCLID ownership and `consent` settings. + +### 5. Structured Diagnostic Reporting [MANDATORY] + +The AI MUST format final reports as follows: +1. **Introductory Analysis**: State the Customer ID and the primary issue identified. +2. **Numbered Technical Findings**: Detailed analysis of specific factors (e.g., Status, Metrics). +3. **Specific Observations**: Bulleted data points (success rates, specific errors). +4. **Actionable Recommendations**: Clear next steps for the user. +5. **Empty Section Handling**: If summaries are empty, AI MUST append "Reason: No standard offline imports detected in last 90 days" inside the report. +6. **Full Diagnostic Data Mandate**: The report MUST contain the verbatim output or detailed data from the `offline_conversion_upload_client_summary` and `offline_conversion_upload_conversion_action_summary` queries to ensure transparency and complete diagnostic visibility. +7. **Structured Analysis Mandate**: The report MUST include a structured section containing "Primary Errors Identified" (with root causes and fixes), "Specific Action Failures", "General Health" assessment, and "Actionable Recommendations" as presented to the user. +8. **Verbatim Screen Output Mandate**: The report MUST ALWAYS include the verbatim structured analysis and recommendations text presented to the user on the screen (e.g. detailed findings for EXPIRED_EVENT, specific action failures, and timing issues). + +**Consolidation Mandate**: All findings, including terminal summaries, the structured analysis, the verbatim screen output, and the **complete verbatim data** from all troubleshooting scripts and queries, MUST be consolidated into a **single, uniquely named text file** in `saved/data/`. + +**Mandatory Naming Rule**: +- For reports generated via the `/conversions_support_package` command, the file MUST be named exactly `conversions_support_package_.text`. +- For any other conversion-related reports or files, DO NOT use this specific naming convention. + +This file MUST be the sole artifact submitted to the user for support. It must start with the header "Created by the Google Ads API Developer Assistant". Placeholders or references to other files for "details" are strictly prohibited; all data must be contained within this single file. + +--- + +### 6. References +- **Official Docs**: `https://developers.google.com/google-ads/api/docs/conversions/` +- **GAQL Structure**: `https://developers.google.com/google-ads/api/docs/query/` + +--- + +### 7. Python Object Inspection & Error Handling [MANDATORY] + +#### 7.1. Proto-plus Message Inspection +* **No Direct Descriptor Access**: NEVER use `obj.DESCRIPTOR`, `obj.pb`, or `obj.meta` on a message instance or class. These are hidden by the `proto-plus` wrapper. +* **Correct Inspection**: Use `type(obj).pb(obj)` for instances. For classes, use `Class.meta.pb.DESCRIPTOR` to access the underlying protobuf descriptor. +* **Linter Compliance**: When using `type(obj).pb(obj)` for inspection, ensure the resulting object is actually used or use a leading underscore (e.g., `_pb_obj`) to avoid "unused variable" linter errors (e.g., Ruff F841). +* **AttributeError Handling**: If an `AttributeError: Unknown field for : ` occurs, it means the attribute is not defined in the protobuf message. Immediately verify the field name against the official API documentation or use `dir(obj)` to see available attributes. + +#### 7.2. Conversion-Specific Object Pitfalls +* **OfflineConversionAlert**: + * **CRITICAL: Error Field Structure**: The `alert.error` field is NOT a direct enum. it is a `oneof` message (type `OfflineConversionError`) containing fields for different error categories (e.g., `conversion_upload_error`, `conversion_adjustment_upload_error`). + * **Mandatory Access Pattern**: To get the error string, you MUST identify which field in the `oneof` is set and then access its `.name`. The `oneof` field name in `OfflineConversionError` is `error_code`. + * **Example Code**: + ```python + # Mandatory access pattern for OfflineConversionError oneof + error_type = type(alert.error).pb(alert.error).WhichOneof("error_code") + error_val = getattr(alert.error, error_type) + error_name = error_val.name + ``` +* **Diagnostic Reports**: When summarizing failed conversions, always include the error name and the `error_percentage` from `OfflineConversionAlert`. diff --git a/customer_id.txt b/customer_id.txt index 0b93666..92ef13c 100644 --- a/customer_id.txt +++ b/customer_id.txt @@ -1 +1 @@ -customer_id: 12345678 +customer_id: 8466202666 diff --git a/gemini-extension.json b/gemini-extension.json index 54b19a1..51d7538 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "google-ads-api-developer-assistant", - "version": "1.6.0", + "version": "2.0.0", "contextFileName": "GEMINI.md" } diff --git a/setup.ps1 b/install.ps1 similarity index 68% rename from setup.ps1 rename to install.ps1 index 36eb1cc..6d976c8 100644 --- a/setup.ps1 +++ b/install.ps1 @@ -25,20 +25,24 @@ Include google-ads-dotnet. .EXAMPLE - .\setup.ps1 -Python -Java - Installs only Python and Java libraries. + .\install.ps1 -Java + Installs Java and Python libraries. .EXAMPLE - .\setup.ps1 - Installs ALL supported libraries. + .\install.ps1 + Installs only the Python library. + +.EXAMPLE + .\install.ps1 -Java + Installs Java and Python libraries. #> param( - [switch]$Python, [switch]$Php, [switch]$Ruby, [switch]$Java, - [switch]$Dotnet + [switch]$Dotnet, + [switch]$InstallDeps ) $ErrorActionPreference = "Stop" @@ -75,14 +79,16 @@ function Get-RepoConfig { } # --- Defaults --- -# If no specific languages selected, select all -if (-not ($Python -or $Php -or $Ruby -or $Java -or $Dotnet)) { - Write-Host "No specific languages selected. Defaulting to ALL languages." - $Python = $true - $Php = $true - $Ruby = $true - $Java = $true - $Dotnet = $true +$Python = $true +$AnySelected = $false + +if ($Php -or $Ruby -or $Java -or $Dotnet) { + $AnySelected = $true +} + +# If no specific languages selected, default to Python only +if (-not $AnySelected) { + Write-Host "No additional languages selected. Defaulting to Python only." } # --- Dependency Check --- @@ -170,7 +176,7 @@ if (-not (Test-Path -LiteralPath $SettingsFile)) { Write-Host "Updating $SettingsFile with context paths..." $ContextPathExamples = Join-Path $ProjectDirAbs "api_examples" -$ContextPathSaved = Join-Path $ProjectDirAbs "saved_code" +$ContextPathSaved = Join-Path $ProjectDirAbs "saved/code" try { $SettingsJson = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json @@ -202,6 +208,30 @@ try { Write-Host "Successfully updated $SettingsFile" Write-Host "New contents of context.includeDirectories:" Write-Host ($SettingsJson.context.includeDirectories | Out-String) + + Write-Host "Registering Google Ads API Developer Assistant as a Gemini extension..." + if (Get-Command gemini -ErrorAction SilentlyContinue) { + try { + $InstallOutput = "Y" | & gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + if ($InstallOutput -match "already installed") { + Write-Host "Extension already installed. Reinstalling..." + gemini extensions uninstall "google-ads-api-developer-assistant" 2>&1 | Out-Null + $InstallOutput = "Y" | & gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git 2>&1 | Out-String + } else { + Write-Warning $InstallOutput + Write-Warning "Failed to register extension automatically. You may need to run 'gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git' manually." + } + } else { + Write-Host $InstallOutput + } + } + catch { + Write-Warning "An unexpected error occurred during extension registration: $_" + } + } else { + Write-Warning "'gemini' command not found. Skipping extension registration." + } } catch { Write-Error "ERROR: Failed to update settings file: $_" @@ -210,7 +240,36 @@ catch { -Write-Host "Setup complete." + +if ($Python -and $InstallDeps) { + Write-Host "Installing google-ads via pip..." + python -m pip install --upgrade google-ads +} + +if ($Php -and $InstallDeps) { + Write-Host "Installing google-ads-php dependencies via composer..." + $path = $LibPaths["php"] + if (Test-Path (Join-Path $path "composer.json")) { + Push-Location $path + try { composer install } finally { Pop-Location } + } else { + Write-Warning "composer.json not found in $path" + } +} + +if ($Ruby -and $InstallDeps) { + Write-Host "Installing google-ads-ruby dependencies via bundle..." + $path = $LibPaths["ruby"] + if (Test-Path (Join-Path $path "Gemfile")) { + Push-Location $path + try { bundle install } finally { Pop-Location } + } else { + Write-Warning "Gemfile not found in $path" + } +} + +Write-Host "Installation complete." Write-Host "" Write-Host "IMPORTANT: You must manually configure a development environment for each language you wish to use." Write-Host " (e.g., run 'pip install google-ads' for Python, run 'composer install' for PHP, etc.)" +Write-Host " If you used -InstallDeps, you can verify the installation by running 'python -m pip show google-ads' for Python, 'composer show google/ads-api-php-client' for PHP, etc." diff --git a/setup.sh b/install.sh similarity index 77% rename from setup.sh rename to install.sh index 505aea8..9828ae2 100755 --- a/setup.sh +++ b/install.sh @@ -67,12 +67,13 @@ get_repo_name() { # --- Defaults --- # Simple variables to track selection (associative arrays not supported in Bash 3.2) -INSTALL_PYTHON=false +INSTALL_PYTHON=true INSTALL_PHP=false INSTALL_RUBY=false INSTALL_JAVA=false INSTALL_DOTNET=false ANY_SELECTED=false +INSTALL_DEPS=false # --- Dependency Check --- if ! command -v jq &> /dev/null; then @@ -114,19 +115,20 @@ usage() { echo "" echo " This script initializes the development environment for the Google Ads API Developer Assistant." echo " It clones the selected client libraries into '${DEFAULT_PARENT_DIR}'." + echo " The google-ads-python library is always installed by default." echo "" echo " Options:" - echo " -h, --help Show this help message and exit" - echo " --python Include google-ads-python" + echo " -h, --help Show this help message and exit + --install-deps Install dependencies (e.g. pip packages)" echo " --php Include google-ads-php" echo " --ruby Include google-ads-ruby" echo " --java Include google-ads-java" echo " --dotnet Include google-ads-dotnet" echo "" - echo " If no language flags are provided, ALL supported languages will be installed." + echo " If no language flags are provided, only the Python library will be installed." echo "" echo " Example:" - echo " $0 --java --python (Installs only Java and Python libraries)" + echo " $0 --java (Installs Java and Python libraries)" echo "" } @@ -137,11 +139,6 @@ while [[ $# -gt 0 ]]; do usage exit 0 ;; - --python) - INSTALL_PYTHON=true - ANY_SELECTED=true - shift - ;; --php) INSTALL_PHP=true ANY_SELECTED=true @@ -162,6 +159,10 @@ while [[ $# -gt 0 ]]; do ANY_SELECTED=true shift ;; + --install-deps) + INSTALL_DEPS=true + shift + ;; *) err "ERROR: Unknown argument: $1" usage @@ -171,14 +172,9 @@ while [[ $# -gt 0 ]]; do done # --- Language Selection Logic --- -# If no languages selected, select all +# Python is always installed. Other languages are only installed if selected. if [[ "${ANY_SELECTED}" == "false" ]]; then - echo "No specific languages selected. Defaulting to ALL languages." - INSTALL_PYTHON=true - INSTALL_PHP=true - INSTALL_RUBY=true - INSTALL_JAVA=true - INSTALL_DOTNET=true + echo "No additional languages selected. Defaulting to Python only." fi # --- Path Resolution and Validation --- @@ -270,7 +266,7 @@ fi echo "Updating ${SETTINGS_FILE} with context paths..." readonly CONTEXT_PATH_EXAMPLES="${PROJECT_DIR_ABS}/api_examples" -readonly CONTEXT_PATH_SAVED="${PROJECT_DIR_ABS}/saved_code" +readonly CONTEXT_PATH_SAVED="${PROJECT_DIR_ABS}/saved/code" # Construct jq args JQ_ARGS=( @@ -318,13 +314,59 @@ if ! mv "${TMP_SETTINGS_FILE}" "${SETTINGS_FILE}"; then exit 1 fi +echo "Registering Google Ads API Developer Assistant as a Gemini extension..." +if command -v gemini &> /dev/null; then + # Use yes Y to handle the interactive prompt as --consent is not supported in OSS + # Capture output to detect "already installed" state + if ! INSTALL_OUTPUT=$(yes Y | gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git 2>&1); then + if [[ "${INSTALL_OUTPUT}" == *"already installed"* ]]; then + echo "Extension already installed. Reinstalling..." + gemini extensions uninstall "google-ads-api-developer-assistant" || true + yes Y | gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git + else + echo "${INSTALL_OUTPUT}" >&2 + err "WARN: Failed to register extension automatically. You may need to run 'gemini extensions install https://github.com/googleads/google-ads-api-developer-assistant.git' manually." + fi + else + echo "${INSTALL_OUTPUT}" + fi +else + echo "WARN: 'gemini' command not found. Skipping extension registration." +fi + +if is_enabled "python" && [[ "${INSTALL_DEPS}" == "true" ]]; then + echo "Installing google-ads via pip..." + python -m pip install --upgrade google-ads +fi + +if is_enabled "php" && [[ "${INSTALL_DEPS}" == "true" ]]; then + echo "Installing google-ads-php dependencies via composer..." + eval "path=\"\$LIB_PATH_php\"" + if [[ -f "${path}/composer.json" ]]; then + (cd "${path}" && composer install) + else + echo "WARN: composer.json not found in ${path}" + fi +fi + +if is_enabled "ruby" && [[ "${INSTALL_DEPS}" == "true" ]]; then + echo "Installing google-ads-ruby dependencies via bundle..." + eval "path=\"\$LIB_PATH_ruby\"" + if [[ -f "${path}/Gemfile" ]]; then + (cd "${path}" && bundle install) + else + echo "WARN: Gemfile not found in ${path}" + fi +fi + trap - EXIT # Clear the trap echo "Successfully updated ${SETTINGS_FILE}" echo "New contents of context.includeDirectories:" jq '.context.includeDirectories' "${SETTINGS_FILE}" -echo "Setup complete." +echo "Installation complete." echo "" -echo "IMPORTANT: You must manually configure a development environment for each language you wish to use." +echo "IMPORTANT: You must configure and verify the development environment for each language you wish to use." echo " (e.g., run 'pip install google-ads' for Python, run 'composer install' for PHP, etc.)" +echo " If you used --install-deps, you can verify the installation by running 'python -m pip show google-ads' for Python, 'composer show google/ads-api-php-client' for PHP, etc." diff --git a/saved/.gitkeep b/saved/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved/code/.gitkeep b/saved/code/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved/csv/.gitkeep b/saved/csv/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved/data/.gitkeep b/saved/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/saved_code/get_all_campaigns_to_csv.py b/saved_code/get_all_campaigns_to_csv.py deleted file mode 100644 index 494f1d5..0000000 --- a/saved_code/get_all_campaigns_to_csv.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import argparse -import csv -import sys - -from google.ads.googleads.client import GoogleAdsClient -from google.ads.googleads.errors import GoogleAdsException - - -def main(client: GoogleAdsClient, customer_id: str) -> None: - ga_service = client.get_service("GoogleAdsService") - - query = """ - SELECT - campaign.id, - campaign.name, - campaign.status - FROM - campaign - ORDER BY - campaign.id""" - - # Issues a search request using streaming. - response = ga_service.search_stream(customer_id=customer_id, query=query) - - output_file = "/path/to/google-ads-assistant/saved_csv/campaigns.csv" - - try: - with open(output_file, "w", newline="") as csvfile: - csv_writer = csv.writer(csvfile) - csv_writer.writerow(["Campaign ID", "Campaign Name", "Campaign Status"]) - - for batch in response: - for row in batch.results: - csv_writer.writerow([ - row.campaign.id, - row.campaign.name, - row.campaign.status.name, - ]) - print(f"Campaign data saved to {output_file}") - except GoogleAdsException as ex: - print( - f"Request with ID '{ex.request_id}' failed with status " - f"'{ex.error.code().name}' and includes the following errors:" - ) - for error in ex.failure.errors: - print(f" Error with message '{error.message}'.") - if error.location: - for field_path_element in error.location.field_path_elements: - print(f" On field: '{field_path_element.field_name}'") - sys.exit(1) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description=( - "Gets all campaigns for the specified customer ID and saves them to a" - " CSV file." - ) - ) - # The following argument(s) should be provided to the example. - parser.add_argument( - "-c", - "---customer_id", - type=str, - required=True, - help="The Google Ads customer ID.", - ) - args = parser.parse_args() - - # GoogleAdsClient will read the google-ads.yaml configuration file in the - # home directory if none is specified. - googleads_client = GoogleAdsClient.load_from_storage(version="v22") - - main(googleads_client, args.customer_id) diff --git a/saved_csv/campaigns.csv b/saved_csv/campaigns.csv deleted file mode 100644 index 3831d89..0000000 --- a/saved_csv/campaigns.csv +++ /dev/null @@ -1,38 +0,0 @@ -Campaign ID,Campaign Name,Campaign Status -12345678,Sales-Search-1-test,REMOVED -12345678,Website traffic-Search-2,REMOVED -12345678,Search,PAUSED -12345678,Youtube CTC,ENABLED -12345678,Hotel Mirror Test Prototype,REMOVED -12345678,failed evc #2,REMOVED -12345678,failed evc,REMOVED -12345678,Youtube EVC,ENABLED -12345678,Offline External,ENABLED -12345678,youtube Xdev,ENABLED -12345678,Enhanced Conversions,REMOVED -12345678,Offline Gclid-less,ENABLED -12345678,Youtube Mobile iOS,ENABLED -12345678,Search Mobile iOS,REMOVED -12345678,EC,ENABLED -12345678,Youtube CTC #2,ENABLED -12345678,Search #2,ENABLED -12345678,Youtube Mobile iOS #2,ENABLED -12345678,EC #2,ENABLED -12345678,Offline External #2,ENABLED -12345678,Youtube EVC #2,ENABLED -12345678,youtube Xdev #2,ENABLED -12345678,Offline Gclid-less #2,ENABLED -12345678,EC Search #3,ENABLED -12345678,Search #3,PAUSED -12345678,Discovery Test,ENABLED -12345678,EC Youtube #1,ENABLED -12345678,Search UA Import,ENABLED -12345678,Display #1,ENABLED -12345678,11/10 search ca,ENABLED -12345678,Test campaign 1675234428613,REMOVED -12345678,Search#4,ENABLED -12345678,DMA Search,ENABLED -12345678,Leaf - test VAC,ENABLED -12345678,Demand Gen,ENABLED -12345678,Shopping Campaign (udm=28),ENABLED -12345678,Search DDA,ENABLED diff --git a/tests/test_install.ps1 b/tests/test_install.ps1 new file mode 100644 index 0000000..6ee5b56 --- /dev/null +++ b/tests/test_install.ps1 @@ -0,0 +1,148 @@ +<# +.SYNOPSIS + Test script for install.ps1 +#> + +$ErrorActionPreference = "Stop" + +# --- Test Setup --- +$TestTmpDir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName() +New-Item -ItemType Directory -Force -Path $TestTmpDir | Out-Null +$InstallScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "install.ps1") + +Write-Host "Running tests in $TestTmpDir" + +# Cleanup +function Cleanup { + Remove-Item -Recurse -Force $TestTmpDir -ErrorAction SilentlyContinue +} +# Register cleanup? PowerShell try/finally is better. + +try { + # 1. Mock Environment + $FakeHome = Join-Path $TestTmpDir "fake_home" + $FakeProject = Join-Path $TestTmpDir "fake_project" + $FakeBin = Join-Path $FakeHome "bin" + New-Item -ItemType Directory -Force -Path $FakeBin | Out-Null + New-Item -ItemType Directory -Force -Path $FakeProject | Out-Null + + # Add FakeBin to PATH + $env:PATH = "$FakeBin$([System.IO.Path]::PathSeparator)$env:PATH" + + # Create Mock Scripts (Simulating Linux environment where we test) + # git mock + Set-Content -Path (Join-Path $FakeBin "git") -Value "#!/bin/bash`nif [[ `"`$1`" == `"rev-parse`" ]]; then echo `"$FakeProject`"; elif [[ `"`$1`" == `"clone`" ]]; then mkdir -p `"`$3/.git`"; echo `"Mock cloned`"; else echo `"Mock git`"; fi" + # chmod +x not needed if we stay in pwsh? Wait, pwsh on Linux uses PATH to find executables. + # We need to make them executable. + + if ($IsLinux) { + chmod +x (Join-Path $FakeBin "git") + } + + # Python Mock + $InstallLog = Join-Path $TestTmpDir "install_log.txt" + Set-Content -Path (Join-Path $FakeBin "python") -Value "#!/bin/bash`necho `"MOCK: python `$*`" >> `"$InstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "python") } + + # Composer Mock + Set-Content -Path (Join-Path $FakeBin "composer") -Value "#!/bin/bash`necho `"MOCK: composer `$*`" >> `"$InstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "composer") } + + # Bundle Mock + Set-Content -Path (Join-Path $FakeBin "bundle") -Value "#!/bin/bash`necho `"MOCK: bundle `$*`" >> `"$InstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "bundle") } + + # Git needs to be git.exe on Windows. This test likely only runs on Linux per the environment. + + # 2. Setup Fake Project + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject ".gemini") | Out-Null + Set-Content -Path (Join-Path $FakeProject ".gemini/settings.json") -Value '{"context": {"includeDirectories": []}}' + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject "api_examples") | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject "saved/code") | Out-Null + + # Create dummy composer.json and Gemfile + $PhpDir = Join-Path $FakeProject "client_libs/google-ads-php" + New-Item -ItemType Directory -Force -Path $PhpDir | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $PhpDir "composer.json") | Out-Null + + $RubyDir = Join-Path $FakeProject "client_libs/google-ads-ruby" + New-Item -ItemType Directory -Force -Path $RubyDir | Out-Null + New-Item -ItemType File -Force -Path (Join-Path $RubyDir "Gemfile") | Out-Null + + # --- Test Case 1: Run install.ps1 -Php -Ruby -InstallDeps --- + Write-Host "--- Running install.ps1 -Php -Ruby -InstallDeps ---" + Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue + + # We must run it in the FakeProject dir so git rev-parse finds it? + # install.ps1 calls `git rev-parse --show-toplevel`. + # Our mock git returns $FakeProject regardless of CWD if we mocked it correctly. + # Ah, our mock git `rev-parse` returns `$FakeProject`. + + # Execute install.ps1 + & $InstallScriptPath -Php -Ruby -InstallDeps + if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed" } + + $LogContent = Get-Content -Raw $InstallLog -ErrorAction SilentlyContinue + Write-Host "Log Content:`n$LogContent" + + if ($LogContent -match "python .* pip install .* google-ads") { Write-Host "PASS: python pip install" } else { throw "FAIL: python pip install missed" } + if ($LogContent -match "composer install") { Write-Host "PASS: composer install" } else { throw "FAIL: composer install missed" } + if ($LogContent -match "bundle install") { Write-Host "PASS: bundle install" } else { throw "FAIL: bundle install missed" } + + # Verify settings.json inclusion + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + $IncludedDirs = $Settings.context.includeDirectories + if ($IncludedDirs -contains (Join-Path $FakeProject "client_libs/google-ads-python")) { Write-Host "PASS: settings contains python" } else { throw "FAIL: settings missing python" } + if ($IncludedDirs -contains (Join-Path $FakeProject "client_libs/google-ads-php")) { Write-Host "PASS: settings contains php" } else { throw "FAIL: settings missing php" } + if ($IncludedDirs -contains (Join-Path $FakeProject "client_libs/google-ads-ruby")) { Write-Host "PASS: settings contains ruby" } else { throw "FAIL: settings missing ruby" } + + # --- Test Case 2: Run install.ps1 NO InstallDeps --- + Write-Host "--- Running install.ps1 (NO Deps) ---" + Remove-Item -Force $InstallLog -ErrorAction SilentlyContinue + + & $InstallScriptPath -Php -Ruby + if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed" } + + if (Test-Path $InstallLog) { + throw "FAIL: Install log exists, commands ran when they shouldn't have" + } else { + Write-Host "PASS: No install commands executed" + } + + # Verify settings.json still has python + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + if ($Settings.context.includeDirectories -contains (Join-Path $FakeProject "client_libs/google-ads-python")) { Write-Host "PASS: settings still contains python" } else { throw "FAIL: settings missing python in selective run" } + + # --- Test Case 3: Run install.ps1 Default (no flags) --- + Write-Host "--- Running install.ps1 (Default) ---" + # Ensure client_libs is clean for this test case + Remove-Item -Recurse -Force (Join-Path $FakeProject "client_libs") -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path (Join-Path $FakeProject "client_libs") | Out-Null + + & $InstallScriptPath + if ($LASTEXITCODE -ne 0) { throw "install.ps1 failed" } + + $Settings = Get-Content -Raw (Join-Path $FakeProject ".gemini/settings.json") | ConvertFrom-Json + $IncludedDirs = $Settings.context.includeDirectories + + # Check Python exists + $ExpectedPython = Join-Path $FakeProject "client_libs/google-ads-python" + if ($IncludedDirs -contains $ExpectedPython) { Write-Host "PASS: settings contains python" } else { throw "FAIL: settings missing python in default run" } + + # Check others don't exist + $Langs = @("php", "ruby", "java", "dotnet") + foreach ($L in $Langs) { + $NotExpected = Join-Path $FakeProject "client_libs/google-ads-$L" + if ($IncludedDirs -contains $NotExpected) { throw "FAIL: settings contains $L but should not in default run" } else { Write-Host "PASS: settings correctly missing $L" } + } + + Write-Host "ALL TESTS PASSED" + +} +catch { + Write-Error "Test Failed: $_" + exit 1 +} +finally { + Cleanup +} diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100755 index 0000000..3c91865 --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,235 @@ +#!/bin/bash +set -u + +# --- Test Setup --- +TEST_TMP_DIR=$(mktemp -d) +SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/install.sh" + +echo "Running tests in ${TEST_TMP_DIR}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" +} +trap cleanup EXIT + +# 1. Mock Environment +FAKE_HOME=$(mktemp -d) +FAKE_PROJECT=$(mktemp -d) +echo "FAKE_HOME: ${FAKE_HOME}" +echo "FAKE_PROJECT: ${FAKE_PROJECT}" + +export HOME="${FAKE_HOME}" +mkdir -p "${FAKE_HOME}/bin" +export PATH="${FAKE_HOME}/bin:${PATH}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" + rm -rf "${FAKE_HOME}" + rm -rf "${FAKE_PROJECT}" +} +trap cleanup EXIT + +# Create mock git +cat > "${FAKE_HOME}/bin/git" < /dev/null; then + echo "jq not found, using mock implementation (this test prefers real jq)" + # A simple mock might be too hard for the complex jq command used + echo "FAIL: real jq is required for this test" + exit 1 +fi + +# 2. Setup "Project" in Temp Dir +# install.sh expects to be run from within the repo +# We will run it from FAKE_PROJECT, pretending it's the repo root +mkdir -p "${FAKE_PROJECT}/.gemini" +echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settings.json" + +# Create dummy directories that install.sh references +mkdir -p "${FAKE_PROJECT}/api_examples" +mkdir -p "${FAKE_PROJECT}/saved/code" + +# --- Test Case 1: Run install.sh --- +echo "--- Running install.sh ---" +if ! bash "${SETUP_SCRIPT_PATH}"; then + echo "FAIL: install.sh failed" + exit 1 +fi + +# Check if directory created (mock clone) +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-python/.git" ]]; then + echo "FAIL: google-ads-python was not 'cloned' (mocked)" + exit 1 +fi + +# Check that other languages are NOT cloned +for lang in php ruby java dotnet; do + if [[ -d "${FAKE_PROJECT}/client_libs/google-ads-${lang}" ]]; then + echo "FAIL: google-ads-${lang} was cloned but should not have been (default is Python only)" + exit 1 + fi +done + +# Check if settings.json updated +if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "PASS: settings.json contains google-ads-python" +else + echo "FAIL: settings.json does NOT contain google-ads-python" + cat "${FAKE_PROJECT}/.gemini/settings.json" + exit 1 +fi + +# Verify other languages are NOT in settings.json +for lang in php ruby java dotnet; do + if grep -q "google-ads-${lang}" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "FAIL: settings.json contains google-ads-${lang} but should not (default is Python only)" + exit 1 + fi +done + +# --- Test Case 2: Run install.sh --java (update existing check) --- +echo "--- Running install.sh --java ---" +if ! bash "${SETUP_SCRIPT_PATH}" --java; then + echo "FAIL: install.sh failed with --java" + exit 1 +fi + +# Check if java directory created +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-java/.git" ]]; then + echo "FAIL: google-ads-java was not 'cloned'" + exit 1 +fi + +# Check if settings.json has both now (actually jq might rewrite/append, install.sh overwrites the list based on selection?) +# install.sh reads: JQ_ARGS arguments based on enabled languages in THAT run. +# It overwrites `context.includeDirectories` with `[$examples, $saved, ...selected_libs]`. +# So if I run with ONLY --java, python might be REMOVED? +# Let's check the script logic: +# `for lang in $ALL_LANGS; do if is_enabled "$lang"; then ... JQ_ARGS+=...; fi; done` +# `JQ_ARRAY_STR="[\$examples, \$saved"` ... `JQ_ARRAY_STR+=", \$lib_$lang"` ... +# Yes, it overwrites with ONLY the currently selected languages + existing examples/saved. +# THIS IS IMPORTANT. Running `install.sh --java` AFTER `install.sh --python` removes python from settings if `install.sh` doesn't read existing settings. +# Wait, `install.sh` REPLACES the list. +# Let's verify this behavior is what we expect or if it's a "bug" (or feature). +# For now, I test that java IS present. + +if grep -q "google-ads-java" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "PASS: settings.json contains google-ads-java" +else + echo "FAIL: settings.json does NOT contain google-ads-java" + exit 1 +fi + +# Verify Python is present (Since Python is now always enabled) +if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then + echo "INFO: google-ads-python is STILL present (Always enabled)" +else + echo "FAIL: google-ads-python is GONE (It should always be present)" + exit 1 +fi + +# Mock python +cat > "${FAKE_HOME}/bin/python" <> "${TEST_TMP_DIR}/install_log.txt" +else + echo "Mock python: \$*" +fi +EOF +chmod +x "${FAKE_HOME}/bin/python" + +# Mock composer +cat > "${FAKE_HOME}/bin/composer" <> "${TEST_TMP_DIR}/install_log.txt" +EOF +chmod +x "${FAKE_HOME}/bin/composer" + +# Mock bundle +cat > "${FAKE_HOME}/bin/bundle" <> "${TEST_TMP_DIR}/install_log.txt" +EOF +chmod +x "${FAKE_HOME}/bin/bundle" + +# Create dummy composer.json and Gemfile for detection +mkdir -p "${FAKE_PROJECT}/client_libs/google-ads-php" +touch "${FAKE_PROJECT}/client_libs/google-ads-php/composer.json" +mkdir -p "${FAKE_PROJECT}/client_libs/google-ads-ruby" +touch "${FAKE_PROJECT}/client_libs/google-ads-ruby/Gemfile" + + +# --- Test Case 3: Install Deps --- +echo "--- Running install.sh --php --ruby --install-deps ---" +# Clear log +rm -f "${TEST_TMP_DIR}/install_log.txt" + +if ! bash "${SETUP_SCRIPT_PATH}" --php --ruby --install-deps; then + echo "FAIL: install.sh failed with --install-deps" + exit 1 +fi + +LOG_CONTENT=$(cat "${TEST_TMP_DIR}/install_log.txt" 2>/dev/null || true) +echo "Install Log Content:" +echo "$LOG_CONTENT" + +if echo "$LOG_CONTENT" | grep -q "python -m pip install --upgrade google-ads"; then + echo "PASS: python pip install detected" +else + echo "FAIL: python pip install NOT detected" + exit 1 +fi + +if echo "$LOG_CONTENT" | grep -q "composer install"; then + echo "PASS: composer install detected" +else + echo "FAIL: composer install NOT detected" + exit 1 +fi + +if echo "$LOG_CONTENT" | grep -q "bundle install"; then + echo "PASS: bundle install detected" +else + echo "FAIL: bundle install NOT detected" + exit 1 +fi + +# --- Test Case 4: No Install Deps (Verify NO install) --- +echo "--- Running install.sh --php --ruby (NO deps) ---" +rm -f "${TEST_TMP_DIR}/install_log.txt" + +if ! bash "${SETUP_SCRIPT_PATH}" --php --ruby; then + echo "FAIL: install.sh failed without --install-deps" + exit 1 +fi + +if [[ -f "${TEST_TMP_DIR}/install_log.txt" ]]; then + echo "FAIL: install_log.txt should not exist (or be empty) but found content:" + cat "${TEST_TMP_DIR}/install_log.txt" + exit 1 +else + echo "PASS: No install commands executed" +fi + +echo "ALL TESTS PASSED" diff --git a/tests/test_setup.sh b/tests/test_setup.sh deleted file mode 100755 index 2decabb..0000000 --- a/tests/test_setup.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -set -u - -# --- Test Setup --- -TEST_TMP_DIR=$(mktemp -d) -SETUP_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/setup.sh" - -echo "Running tests in ${TEST_TMP_DIR}" - -# Cleanup function -cleanup() { - rm -rf "${TEST_TMP_DIR}" -} -trap cleanup EXIT - -# 1. Mock Environment -FAKE_HOME=$(mktemp -d) -FAKE_PROJECT=$(mktemp -d) -echo "FAKE_HOME: ${FAKE_HOME}" -echo "FAKE_PROJECT: ${FAKE_PROJECT}" - -export HOME="${FAKE_HOME}" -mkdir -p "${FAKE_HOME}/bin" -export PATH="${FAKE_HOME}/bin:${PATH}" - -# Cleanup function -cleanup() { - rm -rf "${TEST_TMP_DIR}" - rm -rf "${FAKE_HOME}" - rm -rf "${FAKE_PROJECT}" -} -trap cleanup EXIT - -# Create mock git -cat > "${FAKE_HOME}/bin/git" < /dev/null; then - echo "jq not found, using mock implementation (this test prefers real jq)" - # A simple mock might be too hard for the complex jq command used - echo "FAIL: real jq is required for this test" - exit 1 -fi - -# 2. Setup "Project" in Temp Dir -# setup.sh expects to be run from within the repo -# We will run it from FAKE_PROJECT, pretending it's the repo root -mkdir -p "${FAKE_PROJECT}/.gemini" -echo '{"context": {"includeDirectories": []}}' > "${FAKE_PROJECT}/.gemini/settings.json" - -# Create dummy directories that setup.sh references -mkdir -p "${FAKE_PROJECT}/api_examples" -mkdir -p "${FAKE_PROJECT}/saved_code" - -# --- Test Case 1: Run setup.sh with --python --- -echo "--- Running setup.sh --python ---" -if ! bash "${SETUP_SCRIPT_PATH}" --python; then - echo "FAIL: setup.sh failed with --python" - exit 1 -fi - -# Check if directory created (mock clone) -if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-python/.git" ]]; then - echo "FAIL: google-ads-python was not 'cloned' (mocked)" - exit 1 -fi - -# Check if settings.json updated -if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then - echo "PASS: settings.json contains google-ads-python" -else - echo "FAIL: settings.json does NOT contain google-ads-python" - cat "${FAKE_PROJECT}/.gemini/settings.json" - exit 1 -fi - -# --- Test Case 2: Run setup.sh --java (update existing check) --- -echo "--- Running setup.sh --java ---" -if ! bash "${SETUP_SCRIPT_PATH}" --java; then - echo "FAIL: setup.sh failed with --java" - exit 1 -fi - -# Check if java directory created -if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-java/.git" ]]; then - echo "FAIL: google-ads-java was not 'cloned'" - exit 1 -fi - -# Check if settings.json has both now (actually jq might rewrite/append, setup.sh overwrites the list based on selection?) -# setup.sh reads: JQ_ARGS arguments based on enabled languages in THAT run. -# It overwrites `context.includeDirectories` with `[$examples, $saved, ...selected_libs]`. -# So if I run with ONLY --java, python might be REMOVED? -# Let's check the script logic: -# `for lang in $ALL_LANGS; do if is_enabled "$lang"; then ... JQ_ARGS+=...; fi; done` -# `JQ_ARRAY_STR="[\$examples, \$saved"` ... `JQ_ARRAY_STR+=", \$lib_$lang"` ... -# Yes, it overwrites with ONLY the currently selected languages + existing examples/saved. -# THIS IS IMPORTANT. Running `setup.sh --java` AFTER `setup.sh --python` removes python from settings if `setup.sh` doesn't read existing settings. -# Wait, `setup.sh` REPLACES the list. -# Let's verify this behavior is what we expect or if it's a "bug" (or feature). -# For now, I test that java IS present. - -if grep -q "google-ads-java" "${FAKE_PROJECT}/.gemini/settings.json"; then - echo "PASS: settings.json contains google-ads-java" -else - echo "FAIL: settings.json does NOT contain google-ads-java" - exit 1 -fi - -# Verify Python is gone (based on current implementation analysis) -if grep -q "google-ads-python" "${FAKE_PROJECT}/.gemini/settings.json"; then - echo "INFO: google-ads-python is STILL present (Accumulative?)" -else - echo "INFO: google-ads-python is GONE (Expected per current logic if overwriting)" -fi - -echo "ALL TESTS PASSED" diff --git a/tests/test_uninstall.ps1 b/tests/test_uninstall.ps1 new file mode 100644 index 0000000..ac04a9e --- /dev/null +++ b/tests/test_uninstall.ps1 @@ -0,0 +1,83 @@ +<# +.SYNOPSIS + Test script for uninstall.ps1 +#> + +$ErrorActionPreference = "Stop" + +# --- Test Setup --- +$TestTmpDir = [System.IO.Path]::GetTempPath() + [System.IO.Path]::GetRandomFileName() +New-Item -ItemType Directory -Force -Path $TestTmpDir | Out-Null +$UninstallScriptPath = Resolve-Path (Join-Path $PSScriptRoot ".." "uninstall.ps1") + +Write-Host "Running tests in $TestTmpDir" + +# Cleanup +function Cleanup { + Remove-Item -Recurse -Force $TestTmpDir -ErrorAction SilentlyContinue +} + +try { + # 1. Mock Environment + $FakeHome = Join-Path $TestTmpDir "fake_home" + $MockParentDir = Join-Path $TestTmpDir "mock_parent" + $FakeProject = Join-Path $MockParentDir "google-ads-api-developer-assistant" + $FakeBin = Join-Path $FakeHome "bin" + + New-Item -ItemType Directory -Force -Path $FakeBin | Out-Null + New-Item -ItemType Directory -Force -Path $FakeProject | Out-Null + + # Add FakeBin to PATH + $env:PATH = "$FakeBin$([System.IO.Path]::PathSeparator)$env:PATH" + + # Create Mock Scripts + # git mock + Set-Content -Path (Join-Path $FakeBin "git") -Value "#!/bin/bash`nif [[ `"`$1`" == `"rev-parse`" ]]; then echo `"$FakeProject`"; else echo `"Mock git`"; fi" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "git") } + + # gemini mock + $UninstallLog = Join-Path $TestTmpDir "uninstall_log.txt" + Set-Content -Path (Join-Path $FakeBin "gemini") -Value "#!/bin/bash`necho `"MOCK: gemini `$*`" >> `"$UninstallLog`"" + if ($IsLinux) { chmod +x (Join-Path $FakeBin "gemini") } + + # 2. Setup Fake Project + Set-Content -Path (Join-Path $FakeProject "some_file.txt") -Value "test" + + # --- Test Case 1: Run uninstall.ps1 with 'n' --- + Write-Host "--- Running uninstall.ps1 with 'n' (Cancellation) ---" + # We use a temporary input file to simulate Read-Host input + # Actually, we can use a string array and pipe it + $Result = "n" | pwsh -File $UninstallScriptPath + + if (Test-Path $FakeProject) { + Write-Host "PASS: Cancellation respected" + } else { + throw "FAIL: project directory was deleted on cancellation" + } + + # --- Test Case 2: Run uninstall.ps1 with 'Y' --- + Write-Host "--- Running uninstall.ps1 with 'Y' (Success) ---" + $Result = "Y" | pwsh -File $UninstallScriptPath + + if (Test-Path $FakeProject) { + throw "FAIL: project directory still exists" + } else { + Write-Host "PASS: Directory removed" + } + + if (Get-Content $UninstallLog | Select-String "extensions uninstall google-ads-api-developer-assistant") { + Write-Host "PASS: gemini extensions uninstall called" + } else { + throw "FAIL: gemini extensions uninstall NOT called" + } + + Write-Host "ALL POWERSHELL UNINSTALL TESTS PASSED" + +} +catch { + Write-Error "Test Failed: $_" + exit 1 +} +finally { + Cleanup +} diff --git a/tests/test_uninstall.sh b/tests/test_uninstall.sh new file mode 100644 index 0000000..6d7f5aa --- /dev/null +++ b/tests/test_uninstall.sh @@ -0,0 +1,94 @@ +#!/bin/bash +set -u + +# --- Test Setup --- +TEST_TMP_DIR=$(mktemp -d) +UNINSTALL_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/uninstall.sh" + +echo "Running tests in ${TEST_TMP_DIR}" + +# 1. Mock Environment +FAKE_HOME=$(mktemp -d) +# We create a fake project directory inside another temp dir to simulate deletion +MOCK_PARENT_DIR=$(mktemp -d) +FAKE_PROJECT="${MOCK_PARENT_DIR}/google-ads-api-developer-assistant" +mkdir -p "${FAKE_PROJECT}" + +echo "FAKE_HOME: ${FAKE_HOME}" +echo "FAKE_PROJECT: ${FAKE_PROJECT}" + +export HOME="${FAKE_HOME}" +mkdir -p "${FAKE_HOME}/bin" +export PATH="${FAKE_HOME}/bin:${PATH}" + +# Cleanup function +cleanup() { + rm -rf "${TEST_TMP_DIR}" + rm -rf "${FAKE_HOME}" + rm -rf "${MOCK_PARENT_DIR}" +} +trap cleanup EXIT + +# Create mock git +cat > "${FAKE_HOME}/bin/git" < "${FAKE_HOME}/bin/gemini" <> "${TEST_TMP_DIR}/uninstall_log.txt" +EOF +chmod +x "${FAKE_HOME}/bin/gemini" + +# 2. Setup "Project" in Mock Dir +cd "${FAKE_PROJECT}" +touch "some_file.txt" +mkdir "some_dir" + +# --- Test Case 1: Run uninstall.sh with 'n' --- +echo "--- Running uninstall.sh with 'n' (Cancellation) ---" +if ! echo "n" | bash "${UNINSTALL_SCRIPT_PATH}"; then + echo "FAIL: uninstall.sh failed on cancellation check" + exit 1 +fi + +if [[ ! -d "${FAKE_PROJECT}" ]]; then + echo "FAIL: project directory was deleted on cancellation" + exit 1 +fi +echo "PASS: Cancellation respected" + +# --- Test Case 2: Run uninstall.sh with 'Y' --- +echo "--- Running uninstall.sh with 'Y' (Success) ---" +# We need to run it such that it can delete the directory it's "in" +# The script calls 'cd ${parent_dir}' before 'rm -rf' +if ! echo "Y" | bash "${UNINSTALL_SCRIPT_PATH}"; then + echo "FAIL: uninstall.sh failed" + exit 1 +fi + +# Check if directory deleted +if [[ -d "${FAKE_PROJECT}" ]]; then + echo "FAIL: project directory still exists" + exit 1 +fi +echo "PASS: Directory removed" + +# Check if gemini uninstall was called +if grep -q "gemini extensions uninstall google-ads-api-developer-assistant" "${TEST_TMP_DIR}/uninstall_log.txt"; then + echo "PASS: gemini extensions uninstall called" +else + echo "FAIL: gemini extensions uninstall NOT called" + cat "${TEST_TMP_DIR}/uninstall_log.txt" + exit 1 +fi + +echo "ALL BASH UNINSTALL TESTS PASSED" diff --git a/tests/test_update.sh b/tests/test_update.sh new file mode 100644 index 0000000..3273cb1 --- /dev/null +++ b/tests/test_update.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Description: +# Integration tests for update.sh. + +set -eu + +# --- Environment Setup --- +# Create a temporary directory for tests +TEST_DIR=$(mktemp -d "/tmp/test_update_sh_XXXXXX") +trap 'rm -rf "${TEST_DIR}"' EXIT + +echo "Running tests in ${TEST_DIR}" + +FAKE_HOME="${TEST_DIR}/fake_home" +FAKE_PROJECT="${TEST_DIR}/fake_project" +mkdir -p "${FAKE_HOME}/bin" +mkdir -p "${FAKE_PROJECT}/.gemini" + +# Resolve real script path before mocking git +REAL_UPDATE_SCRIPT="$(git rev-parse --show-toplevel)/update.sh" + +# Mock git +cat > "${FAKE_HOME}/bin/git" < "${FAKE_HOME}/bin/jq" < "${FAKE_PROJECT}/.gemini/settings.json" +mkdir -p "${FAKE_PROJECT}/client_libs/google-ads-python/.git" + +# Copy the real update.sh for testing +UPDATE_SCRIPT_PATH="${FAKE_PROJECT}/update.sh" +cp "${REAL_UPDATE_SCRIPT}" "${UPDATE_SCRIPT_PATH}" +chmod +x "${UPDATE_SCRIPT_PATH}" + +# --- Test Case 1: Run update.sh (no flags) --- +echo "--- Test Case 1: Default Update ---" +(cd "${FAKE_PROJECT}" && bash update.sh) + +# Verify python was "updated" +# (Mock pull output would be in stdout, but the script continues if it works) + +# --- Test Case 2: Run update.sh --php (Add new library) --- +echo "--- Test Case 2: Add PHP library ---" +(cd "${FAKE_PROJECT}" && bash update.sh --php) + +# Check if php cloned +if [[ ! -d "${FAKE_PROJECT}/client_libs/google-ads-php/.git" ]]; then + echo "FAIL: google-ads-php was not cloned" + exit 1 +fi + +# Check if settings.json updated +if /usr/bin/jq -r '.context.includeDirectories[]' "${FAKE_PROJECT}/.gemini/settings.json" | grep -q "google-ads-php"; then + echo "PASS: settings.json updated with php path" +else + echo "FAIL: settings.json missing php path" + cat "${FAKE_PROJECT}/.gemini/settings.json" + exit 1 +fi + +# --- Test Case 3: Run update.sh --php (Already exists) --- +echo "--- Test Case 3: Update existing PHP library ---" +# We just run it again, it should not clone but pull (mock handled) +(cd "${FAKE_PROJECT}" && bash update.sh --php) +echo "PASS: update.sh --php ran successfully with existing lib" + +echo "ALL TESTS PASSED" diff --git a/tests/test_update_logic.sh b/tests/test_update_logic.sh deleted file mode 100755 index 82cf1f7..0000000 --- a/tests/test_update_logic.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/bash -set -u - -# --- Test Update Logic --- -TEST_TMP_DIR=$(mktemp -d) -UPDATE_SCRIPT_PATH="$(cd "$(dirname "$0")/.." && pwd)/update.sh" - -echo "Running tests in ${TEST_TMP_DIR}" - -# Cleanup function -cleanup() { - rm -rf "${TEST_TMP_DIR}" -} -trap cleanup EXIT - -# 1. Mock Environment -FAKE_HOME=$(mktemp -d) -FAKE_PROJECT=$(mktemp -d) -echo "FAKE_HOME: ${FAKE_HOME}" -echo "FAKE_PROJECT: ${FAKE_PROJECT}" - -export HOME="${FAKE_HOME}" -mkdir -p "${FAKE_HOME}/bin" -export PATH="${FAKE_HOME}/bin:${PATH}" - -# Cleanup function (updated) -cleanup() { - rm -rf "${TEST_TMP_DIR}" - rm -rf "${FAKE_HOME}" - rm -rf "${FAKE_PROJECT}" -} -trap cleanup EXIT - -# Create mock git -cat > "${FAKE_HOME}/bin/git" < ".gemini/settings.json" - fi - # We don't touch customer_id.txt in repo usually, or maybe we do? - # If repo has customer_id.txt, it might overwrite. - if [[ -f "customer_id.txt" ]]; then - echo "REPO_CUSTOMER_ID" > "customer_id.txt" - fi -elif [[ "\$1" == "ls-files" ]]; then - exit 0 # everything matches for now -elif [[ "\$1" == "checkout" ]]; then - echo "Mock checkout \$2" - # Actually restore the file to "HEAD" state? - # logic: if git ls-files ...; then git checkout ...; fi - # We can just ignore checkout for this test as we want to test the MERGE/RESTORE logic primarily. -else - echo "Mock git: command \$* ignored" -fi -EOF -chmod +x "${FAKE_HOME}/bin/git" - -# Create mock jq if not present -if ! command -v jq &> /dev/null; then - echo "FAIL: real jq is required for this test" - exit 1 -fi - -# 2. Setup "Project" in Temp Dir -mkdir -p "${FAKE_PROJECT}/.gemini" -SETTINGS_JSON="${FAKE_PROJECT}/.gemini/settings.json" -CUSTOMER_ID_FILE="${FAKE_PROJECT}/customer_id.txt" - -# Initial "User" State -echo '{"user_setting": true, "common_setting": "user_value", "context": {"includeDirectories": []}}' > "${SETTINGS_JSON}" -echo "USER_CUSTOMER_ID" > "${CUSTOMER_ID_FILE}" - -echo "Initial settings:" -cat "${SETTINGS_JSON}" -echo "Initial customer_id:" -cat "${CUSTOMER_ID_FILE}" - -# 3. Run update.sh from within FAKE_PROJECT (update.sh expects to be in repo) -cd "${FAKE_PROJECT}" -echo "--- Running update.sh ---" -if ! bash "${UPDATE_SCRIPT_PATH}"; then - echo "FAIL: update.sh failed" - exit 1 -fi - -# 4. Verify Results -echo "Final settings:" -cat "${SETTINGS_JSON}" -echo "Final customer_id:" -cat "${CUSTOMER_ID_FILE}" - -# Verify Settings -USER_VAL=$(jq -r .user_setting "${SETTINGS_JSON}") -REPO_VAL=$(jq -r .repo_setting "${SETTINGS_JSON}") -COMMON_VAL=$(jq -r .common_setting "${SETTINGS_JSON}") - -if [[ "$USER_VAL" == "true" ]] && [[ "$REPO_VAL" == "true" ]] && [[ "$COMMON_VAL" == "user_value" ]]; then - echo "PASS: Settings merged correctly" -else - echo "FAIL: Settings merge incorrect" - exit 1 -fi - -# Verify Customer ID -CID_VAL=$(cat "${CUSTOMER_ID_FILE}") -if [[ "$CID_VAL" == "USER_CUSTOMER_ID" ]]; then - echo "PASS: Customer ID preserved" -else - echo "FAIL: Customer ID NOT preserved (Got: $CID_VAL)" - exit 1 -fi - -echo "ALL TESTS PASSED" diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..133c4d4 --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Uninstalls the Google Ads API Developer Assistant extension and removes the project directory. +#> + +$ErrorActionPreference = "Stop" + +# Determine project root +try { + $ProjectDirAbs = git rev-parse --show-toplevel 2>$null + if (-not $ProjectDirAbs) { throw "Not in a git repo" } + $ProjectDirAbs = (Get-Item -LiteralPath $ProjectDirAbs).FullName +} +catch { + Write-Error "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +} + +Write-Host "This will uninstall the Google Ads API Developer Assistant extension" +Write-Host "and DELETE the entire directory: $ProjectDirAbs" +$Confirm = Read-Host "Are you sure you want to proceed? (Y/n)" + +if ($Confirm -notmatch "^[Yy]$") { + Write-Host "Uninstallation cancelled." + exit 0 +} + +if (Get-Command gemini -ErrorAction SilentlyContinue) { + Write-Host "Uninstalling Gemini extension..." + try { + & gemini extensions uninstall "google-ads-api-developer-assistant" 2>&1 | Out-Null + } + catch { + Write-Warning "Extension was not registered or failed to uninstall. Continuing..." + } +} +else { + Write-Warning "'gemini' command not found. Skipping extension uninstallation." +} + +Write-Host "Removing project directory: $ProjectDirAbs..." +# Move out of the directory to allow deletion +Set-Location (Split-Path $ProjectDirAbs) +Remove-Item -Recurse -Force -LiteralPath $ProjectDirAbs + +Write-Host "Uninstallation complete." diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..3350339 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Description: +# This script uninstalls the Google Ads API Developer Assistant extension +# and removes the local project directory. + +set -eu + +# Determine project root +if ! PROJECT_DIR_ABS=$(git rev-parse --show-toplevel 2>/dev/null); then + echo "ERROR: This script must be run from within the google-ads-api-developer-assistant git repository." + exit 1 +fi + +echo "This will uninstall the Google Ads API Developer Assistant extension" +echo "and DELETE the entire directory: ${PROJECT_DIR_ABS}" +read -p "Are you sure you want to proceed? (Y/n): " confirm + +if [[ ! "${confirm}" =~ ^[Yy]$ ]]; then + echo "Uninstallation cancelled." + exit 0 +fi + +if command -v gemini &> /dev/null; then + echo "Uninstalling Gemini extension..." + gemini extensions uninstall "google-ads-api-developer-assistant" || echo "WARN: Extension was not registered or failed to uninstall. Continuing..." +else + echo "WARN: 'gemini' command not found. Skipping extension uninstallation." +fi + +echo "Removing project directory: ${PROJECT_DIR_ABS}..." +# Use a temporary script to remove the directory because the current script is inside it +# Actually on Linux we can usually delete the script while it's running, but to be safe: +parent_dir=$(dirname "${PROJECT_DIR_ABS}") +project_name=$(basename "${PROJECT_DIR_ABS}") + +cd "${parent_dir}" +rm -rf "${project_name}" + +echo "Uninstallation complete." diff --git a/update.ps1 b/update.ps1 index 8178a4d..3cb1abc 100644 --- a/update.ps1 +++ b/update.ps1 @@ -12,6 +12,36 @@ .\update.ps1 #> +param( + [switch]$Python, + [switch]$Php, + [switch]$Ruby, + [switch]$Java, + [switch]$Dotnet +) + +function Get-RepoUrl { + param($Lang) + switch ($Lang) { + "python" { return "https://github.com/googleads/google-ads-python.git" } + "php" { return "https://github.com/googleads/google-ads-php.git" } + "ruby" { return "https://github.com/googleads/google-ads-ruby.git" } + "java" { return "https://github.com/googleads/google-ads-java.git" } + "dotnet" { return "https://github.com/googleads/google-ads-dotnet.git" } + } +} + +function Get-RepoName { + param($Lang) + switch ($Lang) { + "python" { return "google-ads-python" } + "php" { return "google-ads-php" } + "ruby" { return "google-ads-ruby" } + "java" { return "google-ads-java" } + "dotnet" { return "google-ads-dotnet" } + } +} + $ErrorActionPreference = "Stop" # --- Dependency Check --- @@ -161,12 +191,57 @@ finally { } +# --- Handle Specific Library Additions --- +$SpecifiedLangs = @() +if ($Python) { $SpecifiedLangs += "python" } +if ($Php) { $SpecifiedLangs += "php" } +if ($Ruby) { $SpecifiedLangs += "ruby" } +if ($Java) { $SpecifiedLangs += "java" } +if ($Dotnet) { $SpecifiedLangs += "dotnet" } + +if ($SpecifiedLangs.Count -gt 0) { + $DefaultParentDir = Join-Path $ProjectDirAbs "client_libs" + + foreach ($Lang in $SpecifiedLangs) { + $RepoUrl = Get-RepoUrl $Lang + $RepoName = Get-RepoName $Lang + $LibPath = Join-Path $DefaultParentDir $RepoName + + if (-not (Test-Path -LiteralPath $LibPath)) { + Write-Host "Library $RepoName not found. Cloning into $LibPath..." + New-Item -ItemType Directory -Force -Path $DefaultParentDir | Out-Null + git clone $RepoUrl $LibPath + if ($LASTEXITCODE -ne 0) { throw "Failed to clone $RepoUrl" } + + # Add to settings.json if not present + if (Test-Path -LiteralPath $SettingsFile) { + # Ensure we have the most up to date settings after possible git pull + $SettingsJson = Get-Content -LiteralPath $SettingsFile -Raw | ConvertFrom-Json + $AbsPath = (Get-Item -LiteralPath $LibPath).FullName + + if ($null -eq $SettingsJson.context) { + $SettingsJson | Add-Member -MemberType NoteProperty -Name "context" -Value @{ includeDirectories = @() } + } + if ($null -eq $SettingsJson.context.includeDirectories) { + $SettingsJson.context | Add-Member -MemberType NoteProperty -Name "includeDirectories" -Value @() + } + + if (-not ($SettingsJson.context.includeDirectories -contains $AbsPath)) { + Write-Host "Registering $AbsPath in $SettingsFile..." + $SettingsJson.context.includeDirectories += $AbsPath + $SettingsJson | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $SettingsFile -Encoding UTF8 + } + } + } + } +} + # --- Locate and Update Client Libraries --- $SettingsFile = Join-Path $ProjectDirAbs ".gemini\settings.json" if (-not (Test-Path -LiteralPath $SettingsFile)) { Write-Error "ERROR: Settings file not found: $SettingsFile" - Write-Error "Please run setup.ps1 first." + Write-Error "Please run install.ps1 first." exit 1 } diff --git a/update.sh b/update.sh index 0a07236..fefc20a 100755 --- a/update.sh +++ b/update.sh @@ -41,14 +41,94 @@ usage() { echo "" echo " Options:" echo " -h, --help Show this help message and exit" + echo " --python Ensure google-ads-python is present and updated" + echo " --php Ensure google-ads-php is present and updated" + echo " --ruby Ensure google-ads-ruby is present and updated" + echo " --java Ensure google-ads-java is present and updated" + echo " --dotnet Ensure google-ads-dotnet is present and updated" + echo "" + echo " If flags are provided, the script will ensure those libraries are installed" + echo " (cloned) and registered in .gemini/settings.json if they weren't already." echo "" } +# --- Defaults --- +INSTALL_PYTHON=false +INSTALL_PHP=false +INSTALL_RUBY=false +INSTALL_JAVA=false +INSTALL_DOTNET=false +ANY_SELECTED=false + # --- Argument Parsing --- -if [[ "${1:-}" == "-h" ]] || [[ "${1:-}" == "--help" ]]; then - usage - exit 0 -fi +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --python) + INSTALL_PYTHON=true + ANY_SELECTED=true + shift + ;; + --php) + INSTALL_PHP=true + ANY_SELECTED=true + shift + ;; + --ruby) + INSTALL_RUBY=true + ANY_SELECTED=true + shift + ;; + --java) + INSTALL_JAVA=true + ANY_SELECTED=true + shift + ;; + --dotnet) + INSTALL_DOTNET=true + ANY_SELECTED=true + shift + ;; + *) + # Ignore unknown options or handle them + shift + ;; + esac +done + +# Helper functions for repo info (Matching setup.sh) +get_repo_url() { + case "$1" in + python) echo "https://github.com/googleads/google-ads-python.git" ;; + php) echo "https://github.com/googleads/google-ads-php.git" ;; + ruby) echo "https://github.com/googleads/google-ads-ruby.git" ;; + java) echo "https://github.com/googleads/google-ads-java.git" ;; + dotnet) echo "https://github.com/googleads/google-ads-dotnet.git" ;; + esac +} + +get_repo_name() { + case "$1" in + python) echo "google-ads-python" ;; + php) echo "google-ads-php" ;; + ruby) echo "google-ads-ruby" ;; + java) echo "google-ads-java" ;; + dotnet) echo "google-ads-dotnet" ;; + esac +} + +is_enabled() { + case "$1" in + python) [[ "${INSTALL_PYTHON}" == "true" ]] ;; + php) [[ "${INSTALL_PHP}" == "true" ]] ;; + ruby) [[ "${INSTALL_RUBY}" == "true" ]] ;; + java) [[ "${INSTALL_JAVA}" == "true" ]] ;; + dotnet) [[ "${INSTALL_DOTNET}" == "true" ]] ;; + esac +} # --- Dependency Check --- if ! command -v jq &> /dev/null; then @@ -151,12 +231,50 @@ fi echo "Successfully updated google-ads-api-developer-assistant." +# --- Handle Specific Library Additions --- +readonly ALL_LANGS="python php ruby java dotnet" +readonly DEFAULT_PARENT_DIR="${PROJECT_DIR_ABS}/client_libs" + +for lang in $ALL_LANGS; do + if is_enabled "$lang"; then + repo_url=$(get_repo_url "$lang") + repo_name=$(get_repo_name "$lang") + lib_path="${DEFAULT_PARENT_DIR}/${repo_name}" + + if [[ ! -d "${lib_path}" ]]; then + echo "Library ${repo_name} not found. Cloning into ${lib_path}..." + mkdir -p "${DEFAULT_PARENT_DIR}" + if ! git clone "${repo_url}" "${lib_path}"; then + err "ERROR: Failed to clone ${repo_url}" + exit 1 + fi + + # Add to settings.json if not present + if [[ -f "${SETTINGS_JSON}" ]]; then + # Ensure path is absolute for settings.json + ABS_PATH=$(realpath "${lib_path}" 2>/dev/null || echo "${lib_path}") + echo "Registering ${ABS_PATH} in ${SETTINGS_JSON}..." + if ! jq --arg new_path "${ABS_PATH}" ' + if (.context.includeDirectories | any(. == $new_path)) then + . + else + .context.includeDirectories += [$new_path] + end' "${SETTINGS_JSON}" > "${SETTINGS_JSON}.tmp"; then + err "ERROR: Failed to update ${SETTINGS_JSON}" + exit 1 + fi + mv "${SETTINGS_JSON}.tmp" "${SETTINGS_JSON}" + fi + fi + fi +done + # --- Locate and Update Client Libraries --- readonly SETTINGS_FILE="${PROJECT_DIR_ABS}/.gemini/settings.json" if [[ ! -f "${SETTINGS_FILE}" ]]; then err "ERROR: Settings file not found: ${SETTINGS_FILE}" - err "Please run setup.sh first." + err "Please run install.sh first." exit 1 fi