google-ads-cli 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- google_ads_cli-0.0.1.dist-info/METADATA +212 -0
- google_ads_cli-0.0.1.dist-info/RECORD +28 -0
- google_ads_cli-0.0.1.dist-info/WHEEL +4 -0
- google_ads_cli-0.0.1.dist-info/entry_points.txt +2 -0
- google_ads_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
- googleadscli/__init__.py +1 -0
- googleadscli/cli.py +81 -0
- googleadscli/client_factory.py +24 -0
- googleadscli/commands/__init__.py +0 -0
- googleadscli/commands/_common.py +26 -0
- googleadscli/commands/accounts.py +49 -0
- googleadscli/commands/auth.py +150 -0
- googleadscli/commands/call.py +76 -0
- googleadscli/commands/fields.py +94 -0
- googleadscli/commands/highlevel/__init__.py +18 -0
- googleadscli/commands/highlevel/ad.py +49 -0
- googleadscli/commands/highlevel/ad_group.py +46 -0
- googleadscli/commands/highlevel/budget.py +46 -0
- googleadscli/commands/highlevel/campaign.py +106 -0
- googleadscli/commands/highlevel/keyword.py +46 -0
- googleadscli/commands/mutate.py +53 -0
- googleadscli/commands/query.py +35 -0
- googleadscli/config.py +128 -0
- googleadscli/errors.py +81 -0
- googleadscli/formatting.py +55 -0
- googleadscli/gaql.py +37 -0
- googleadscli/proto_bridge.py +308 -0
- googleadscli/utils.py +22 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""`gads mutate <resource>` -- generischer Create/Update/Remove fuer jede Ressource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from googleadscli import formatting, proto_bridge
|
|
8
|
+
from googleadscli.commands import _common
|
|
9
|
+
from googleadscli.utils import load_json_arg, normalize_customer_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
@app.command("mutate")
|
|
14
|
+
def mutate_cmd(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
resource: str = typer.Argument(
|
|
17
|
+
..., help="Ressourcen-Key in snake_case, z.B. 'campaign', 'ad_group_criterion'"
|
|
18
|
+
),
|
|
19
|
+
customer_id: str = typer.Option(..., "--customer-id", "-c"),
|
|
20
|
+
operations_json: str = typer.Option(
|
|
21
|
+
...,
|
|
22
|
+
"--operations-json",
|
|
23
|
+
"-o",
|
|
24
|
+
help="JSON-Array von Operationen ({'create'|'update'|'remove': ...}); "
|
|
25
|
+
"mit '@pfad.json' aus Datei laden",
|
|
26
|
+
),
|
|
27
|
+
dry_run: bool = typer.Option(
|
|
28
|
+
False, "--dry-run", help="validate_only: nur validieren, nichts aendern"
|
|
29
|
+
),
|
|
30
|
+
partial_failure: bool = typer.Option(
|
|
31
|
+
False, "--partial-failure", help="Einzelne fehlschlagende Operationen nicht die ganze Anfrage abbrechen lassen"
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Fuehrt create/update/remove-Operationen fuer eine Ressource aus."""
|
|
35
|
+
|
|
36
|
+
def _run() -> None:
|
|
37
|
+
operations = load_json_arg(operations_json)
|
|
38
|
+
if not isinstance(operations, list):
|
|
39
|
+
raise ValueError("--operations-json muss ein JSON-Array von Operationen sein.")
|
|
40
|
+
|
|
41
|
+
client = _common.build_client(ctx)
|
|
42
|
+
result = proto_bridge.run_mutate(
|
|
43
|
+
client,
|
|
44
|
+
resource,
|
|
45
|
+
normalize_customer_id(customer_id),
|
|
46
|
+
operations,
|
|
47
|
+
partial_failure=partial_failure,
|
|
48
|
+
validate_only=dry_run,
|
|
49
|
+
version=ctx.obj["version"],
|
|
50
|
+
)
|
|
51
|
+
formatting.render(result, fmt=ctx.obj["format"])
|
|
52
|
+
|
|
53
|
+
_common.run_guarded(ctx, _run)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""`gads query` -- generische GAQL-Ausfuehrung."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from googleadscli import formatting, gaql
|
|
8
|
+
from googleadscli.commands import _common
|
|
9
|
+
from googleadscli.utils import normalize_customer_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(app: typer.Typer) -> None:
|
|
13
|
+
@app.command("query")
|
|
14
|
+
def query_cmd(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
customer_id: str = typer.Option(..., "--customer-id", "-c", help="Google Ads Customer ID (CID)"),
|
|
17
|
+
gaql_query: str = typer.Option(..., "--gaql", "-q", help="GAQL-Query-String"),
|
|
18
|
+
paged: bool = typer.Option(
|
|
19
|
+
False, "--paged", help="search/SearchPager statt search_stream verwenden"
|
|
20
|
+
),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Fuehrt eine beliebige GAQL-Abfrage aus (Reporting/Lesen)."""
|
|
23
|
+
|
|
24
|
+
def _run() -> None:
|
|
25
|
+
client = _common.build_client(ctx)
|
|
26
|
+
rows = gaql.run_query(
|
|
27
|
+
client,
|
|
28
|
+
normalize_customer_id(customer_id),
|
|
29
|
+
gaql_query,
|
|
30
|
+
paged=paged,
|
|
31
|
+
version=ctx.obj["version"],
|
|
32
|
+
)
|
|
33
|
+
formatting.render(rows, fmt=ctx.obj["format"])
|
|
34
|
+
|
|
35
|
+
_common.run_guarded(ctx, _run)
|
googleadscli/config.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Konfigurations-Precedence: CLI-Flags > GOOGLE_ADS_*-Env-Vars > Config-Datei."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "google-ads-cli" / "google-ads.yaml"
|
|
12
|
+
LEGACY_CONFIG_PATH = Path.home() / "google-ads.yaml"
|
|
13
|
+
|
|
14
|
+
# Feldname (google-ads.yaml / GoogleAdsClient) -> Env-Var-Name
|
|
15
|
+
ENV_VAR_MAP = {
|
|
16
|
+
"developer_token": "GOOGLE_ADS_DEVELOPER_TOKEN",
|
|
17
|
+
# OAuth2 Installed-App-Flow (interaktiver Consent, siehe 'gads auth login').
|
|
18
|
+
"client_id": "GOOGLE_ADS_CLIENT_ID",
|
|
19
|
+
"client_secret": "GOOGLE_ADS_CLIENT_SECRET",
|
|
20
|
+
"refresh_token": "GOOGLE_ADS_REFRESH_TOKEN",
|
|
21
|
+
# OAuth2 Service-Account-Flow: JSON-Schluesseldatei aus der Google Cloud
|
|
22
|
+
# Console, deren client_email direkt als Nutzer auf dem Google-Ads-Konto
|
|
23
|
+
# (oder MCC) hinterlegt wird -- kein interaktiver Consent noetig.
|
|
24
|
+
"json_key_file_path": "GOOGLE_ADS_JSON_KEY_FILE_PATH",
|
|
25
|
+
# Nur fuer Domain-Wide-Delegation noetig (Service Account impersoniert
|
|
26
|
+
# einen echten Workspace-Nutzer). Bei direkt freigeschaltetem Service-
|
|
27
|
+
# Account-Zugriff auf das Ads-Konto bleibt dies leer.
|
|
28
|
+
"impersonated_email": "GOOGLE_ADS_IMPERSONATED_EMAIL",
|
|
29
|
+
# Application Default Credentials (z.B. GOOGLE_APPLICATION_CREDENTIALS
|
|
30
|
+
# oder 'gcloud auth application-default login').
|
|
31
|
+
"use_application_default_credentials": "GOOGLE_ADS_USE_APPLICATION_DEFAULT_CREDENTIALS",
|
|
32
|
+
"login_customer_id": "GOOGLE_ADS_LOGIN_CUSTOMER_ID",
|
|
33
|
+
"linked_customer_id": "GOOGLE_ADS_LINKED_CUSTOMER_ID",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
CONFIG_PATH_ENV_VAR = "GOOGLE_ADS_CONFIGURATION_FILE_PATH"
|
|
37
|
+
|
|
38
|
+
_TRUTHY = {"true", "1", "yes"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigError(Exception):
|
|
42
|
+
"""Fehlerhafte oder fehlende Google-Ads-Konfiguration."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_config_path(explicit_path: str | None) -> Path | None:
|
|
46
|
+
if explicit_path:
|
|
47
|
+
return Path(explicit_path).expanduser()
|
|
48
|
+
env_path = os.environ.get(CONFIG_PATH_ENV_VAR)
|
|
49
|
+
if env_path:
|
|
50
|
+
return Path(env_path).expanduser()
|
|
51
|
+
if DEFAULT_CONFIG_PATH.exists():
|
|
52
|
+
return DEFAULT_CONFIG_PATH
|
|
53
|
+
if LEGACY_CONFIG_PATH.exists():
|
|
54
|
+
return LEGACY_CONFIG_PATH
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_config_file(path: Path | None) -> dict[str, Any]:
|
|
59
|
+
if path is None or not path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
62
|
+
data = yaml.safe_load(fh) or {}
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
raise ConfigError(f"Ungueltiges Format in Config-Datei: {path}")
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_merged_config(
|
|
69
|
+
*,
|
|
70
|
+
config_path: str | None = None,
|
|
71
|
+
cli_overrides: dict[str, Any] | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""Baut das finale Config-Dict fuer GoogleAdsClient.load_from_dict.
|
|
74
|
+
|
|
75
|
+
Precedence (niedrig -> hoch): Config-Datei < Env-Vars < CLI-Flags.
|
|
76
|
+
"""
|
|
77
|
+
merged: dict[str, Any] = _load_config_file(_resolve_config_path(config_path))
|
|
78
|
+
|
|
79
|
+
for field, env_var in ENV_VAR_MAP.items():
|
|
80
|
+
value = os.environ.get(env_var)
|
|
81
|
+
if value:
|
|
82
|
+
merged[field] = value
|
|
83
|
+
|
|
84
|
+
if cli_overrides:
|
|
85
|
+
merged.update({k: v for k, v in cli_overrides.items() if v is not None})
|
|
86
|
+
|
|
87
|
+
if isinstance(merged.get("use_application_default_credentials"), str):
|
|
88
|
+
merged["use_application_default_credentials"] = (
|
|
89
|
+
merged["use_application_default_credentials"].strip().lower() in _TRUTHY
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Erzwingt die Bridge-Invariante: proto-plus-Wrapper (nicht rohe Protobuf-Messages).
|
|
93
|
+
# proto-plus liefert mit .to_dict()/.from_json() und Dict-Konstruktoren eine deutlich
|
|
94
|
+
# einfachere generische JSON<->Message-Konvertierung als rohe pb2-Messages, und die
|
|
95
|
+
# GAPIC-Methodensignaturen sind ohnehin stets gegen proto-plus-Typen annotiert.
|
|
96
|
+
merged["use_proto_plus"] = True
|
|
97
|
+
|
|
98
|
+
has_installed_app = all(merged.get(f) for f in ("client_id", "client_secret", "refresh_token"))
|
|
99
|
+
has_service_account = bool(merged.get("json_key_file_path"))
|
|
100
|
+
has_adc = bool(merged.get("use_application_default_credentials"))
|
|
101
|
+
|
|
102
|
+
if not (has_installed_app or has_service_account or has_adc):
|
|
103
|
+
raise ConfigError(
|
|
104
|
+
"Keine Google-Ads-Zugangsdaten gefunden. Eine der folgenden Methoden wird benoetigt: "
|
|
105
|
+
"(1) OAuth Installed-App-Flow (client_id, client_secret, refresh_token -- via "
|
|
106
|
+
"'gads auth login' einrichten), (2) Service-Account-JSON-Schluesseldatei "
|
|
107
|
+
"(json_key_file_path, z.B. per --json-key-file-path/GOOGLE_ADS_JSON_KEY_FILE_PATH; "
|
|
108
|
+
"die client_email der Schluesseldatei muss als Nutzer auf dem Google-Ads-Konto "
|
|
109
|
+
"hinterlegt sein), oder (3) Application Default Credentials "
|
|
110
|
+
"(use_application_default_credentials=true). "
|
|
111
|
+
f"Alternativ eine Config-Datei unter {DEFAULT_CONFIG_PATH} anlegen. "
|
|
112
|
+
"'developer_token' ist optional (nicht noetig bei Cloud-managed Access ohne "
|
|
113
|
+
"klassischen Developer Token)."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return merged
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def write_config_file(data: dict[str, Any], path: Path | None = None) -> Path:
|
|
120
|
+
"""Schreibt die Config-Datei mit restriktiven Dateirechten (0600)."""
|
|
121
|
+
target = path or DEFAULT_CONFIG_PATH
|
|
122
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
to_write = dict(data)
|
|
124
|
+
to_write.setdefault("use_proto_plus", True)
|
|
125
|
+
with target.open("w", encoding="utf-8") as fh:
|
|
126
|
+
yaml.safe_dump(to_write, fh, default_flow_style=False)
|
|
127
|
+
target.chmod(0o600)
|
|
128
|
+
return target
|
googleadscli/errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Strukturierte Fehlerbehandlung fuer Agenten: JSON auf stderr, klare Exit-Codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import NoReturn
|
|
8
|
+
|
|
9
|
+
from google.ads.googleads.errors import GoogleAdsException
|
|
10
|
+
|
|
11
|
+
from googleadscli.config import ConfigError
|
|
12
|
+
from googleadscli.proto_bridge import BridgeError
|
|
13
|
+
|
|
14
|
+
EXIT_OK = 0
|
|
15
|
+
EXIT_CLI_ERROR = 1
|
|
16
|
+
EXIT_API_ERROR = 2
|
|
17
|
+
EXIT_INTERNAL_ERROR = 70
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_google_ads_exception(ex: GoogleAdsException) -> dict:
|
|
21
|
+
errors = []
|
|
22
|
+
if ex.failure is not None:
|
|
23
|
+
failure_dict = type(ex.failure).to_dict(
|
|
24
|
+
ex.failure,
|
|
25
|
+
use_integers_for_enums=False,
|
|
26
|
+
preserving_proto_field_name=True,
|
|
27
|
+
always_print_fields_with_no_presence=False,
|
|
28
|
+
)
|
|
29
|
+
for err in failure_dict.get("errors", []):
|
|
30
|
+
error_code = err.get("error_code", {}) or {}
|
|
31
|
+
code_type = next(iter(error_code), None)
|
|
32
|
+
field_path = None
|
|
33
|
+
elements = err.get("location", {}).get("field_path_elements", [])
|
|
34
|
+
if elements:
|
|
35
|
+
parts = []
|
|
36
|
+
for el in elements:
|
|
37
|
+
part = el.get("field_name", "")
|
|
38
|
+
if "index" in el:
|
|
39
|
+
part += f"[{el['index']}]"
|
|
40
|
+
parts.append(part)
|
|
41
|
+
field_path = ".".join(parts)
|
|
42
|
+
errors.append(
|
|
43
|
+
{
|
|
44
|
+
"error_code_type": code_type,
|
|
45
|
+
"error_code": error_code.get(code_type) if code_type else None,
|
|
46
|
+
"message": err.get("message"),
|
|
47
|
+
"field_path": field_path,
|
|
48
|
+
"trigger": err.get("trigger"),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
status_code = None
|
|
53
|
+
if getattr(ex, "call", None) is not None and hasattr(ex.call, "code"):
|
|
54
|
+
code = ex.call.code()
|
|
55
|
+
status_code = getattr(code, "name", str(code))
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"type": "GoogleAdsException",
|
|
59
|
+
"request_id": ex.request_id,
|
|
60
|
+
"status_code": status_code,
|
|
61
|
+
"errors": errors,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def emit_error(payload: dict, *, exit_code: int) -> NoReturn:
|
|
66
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False), file=sys.stderr)
|
|
67
|
+
raise SystemExit(exit_code)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def handle_exception(exc: Exception, *, debug: bool = False) -> NoReturn:
|
|
71
|
+
if isinstance(exc, GoogleAdsException):
|
|
72
|
+
emit_error(format_google_ads_exception(exc), exit_code=EXIT_API_ERROR)
|
|
73
|
+
if isinstance(exc, (ConfigError, BridgeError, ValueError)):
|
|
74
|
+
emit_error({"type": type(exc).__name__, "message": str(exc)}, exit_code=EXIT_CLI_ERROR)
|
|
75
|
+
|
|
76
|
+
if debug:
|
|
77
|
+
raise exc
|
|
78
|
+
emit_error(
|
|
79
|
+
{"type": "InternalError", "message": str(exc)},
|
|
80
|
+
exit_code=EXIT_INTERNAL_ERROR,
|
|
81
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Ausgabeformatierung: json (Standard, agentenfreundlich), table, csv."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _flatten(d: dict, parent_key: str = "") -> dict:
|
|
12
|
+
items: dict[str, Any] = {}
|
|
13
|
+
for key, value in d.items():
|
|
14
|
+
full_key = f"{parent_key}.{key}" if parent_key else key
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
items.update(_flatten(value, full_key))
|
|
17
|
+
else:
|
|
18
|
+
items[full_key] = value
|
|
19
|
+
return items
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render(data: Any, *, fmt: str = "json") -> None:
|
|
23
|
+
if fmt == "json":
|
|
24
|
+
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
rows = data if isinstance(data, list) else [data]
|
|
28
|
+
flat_rows = [_flatten(row) if isinstance(row, dict) else {"value": row} for row in rows]
|
|
29
|
+
|
|
30
|
+
if fmt == "csv":
|
|
31
|
+
if not flat_rows:
|
|
32
|
+
return
|
|
33
|
+
fieldnames = list({key for row in flat_rows for key in row})
|
|
34
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames)
|
|
35
|
+
writer.writeheader()
|
|
36
|
+
writer.writerows(flat_rows)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
if fmt == "table":
|
|
40
|
+
from rich.console import Console
|
|
41
|
+
from rich.table import Table
|
|
42
|
+
|
|
43
|
+
if not flat_rows:
|
|
44
|
+
print("(keine Ergebnisse)")
|
|
45
|
+
return
|
|
46
|
+
fieldnames = list({key for row in flat_rows for key in row})
|
|
47
|
+
table = Table(show_header=True, header_style="bold")
|
|
48
|
+
for name in fieldnames:
|
|
49
|
+
table.add_column(name)
|
|
50
|
+
for row in flat_rows:
|
|
51
|
+
table.add_row(*(str(row.get(name, "")) for name in fieldnames))
|
|
52
|
+
Console().print(table)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
raise ValueError(f"Unbekanntes Ausgabeformat: {fmt}")
|
googleadscli/gaql.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Ausfuehrung beliebiger GAQL-Abfragen ueber GoogleAdsService."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from googleadscli.proto_bridge import DEFAULT_VERSION, message_to_dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_query(
|
|
9
|
+
client,
|
|
10
|
+
customer_id: str,
|
|
11
|
+
gaql: str,
|
|
12
|
+
*,
|
|
13
|
+
paged: bool = False,
|
|
14
|
+
version: str = DEFAULT_VERSION,
|
|
15
|
+
) -> list[dict]:
|
|
16
|
+
"""Fuehrt eine GAQL-Abfrage aus und gibt eine flache Liste von Row-Dicts zurueck.
|
|
17
|
+
|
|
18
|
+
Standard: GoogleAdsService.search_stream (Batches werden zu einer flachen
|
|
19
|
+
Liste zusammengefuehrt). Mit paged=True wird stattdessen search/SearchPager
|
|
20
|
+
verwendet (Kompatibilitaets-/Debug-Option).
|
|
21
|
+
"""
|
|
22
|
+
service = client.get_service("GoogleAdsService", version=version)
|
|
23
|
+
rows: list[dict] = []
|
|
24
|
+
|
|
25
|
+
if paged:
|
|
26
|
+
request_cls = type(client.get_type("SearchGoogleAdsRequest", version=version))
|
|
27
|
+
request = request_cls(customer_id=customer_id, query=gaql)
|
|
28
|
+
for row in service.search(request=request):
|
|
29
|
+
rows.append(message_to_dict(row))
|
|
30
|
+
return rows
|
|
31
|
+
|
|
32
|
+
request_cls = type(client.get_type("SearchGoogleAdsStreamRequest", version=version))
|
|
33
|
+
request = request_cls(customer_id=customer_id, query=gaql)
|
|
34
|
+
for batch in service.search_stream(request=request):
|
|
35
|
+
for row in batch.results:
|
|
36
|
+
rows.append(message_to_dict(row))
|
|
37
|
+
return rows
|