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.
@@ -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