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,308 @@
|
|
|
1
|
+
"""Generische JSON<->Protobuf-Bruecke fuer beliebige Google-Ads-API-Ressourcen/Services.
|
|
2
|
+
|
|
3
|
+
Kernidee: Statt fuer jede der >50 Services / hunderte Ressourcen eigenen Code zu
|
|
4
|
+
schreiben, wird die GAPIC-Namenskonvention der Google Ads API per Reflection
|
|
5
|
+
ausgenutzt:
|
|
6
|
+
|
|
7
|
+
- Jede mutierbare Ressource ``x`` (snake_case) hat einen eponymen Service
|
|
8
|
+
``XService`` mit genau einer ``mutate_*``-Methode, deren Request ein
|
|
9
|
+
``operations``-Feld vom Typ ``XOperation`` besitzt.
|
|
10
|
+
- Jede generierte Service-Methode hat die Signatur
|
|
11
|
+
``method(request: Union[XyzRequest, dict, None] = None, ...)`` -- daraus laesst
|
|
12
|
+
sich der Request-Typ dynamisch extrahieren.
|
|
13
|
+
|
|
14
|
+
Alle Messages sind proto-plus-Typen (``use_proto_plus=True``, siehe config.py),
|
|
15
|
+
die eigene ``to_dict``/``from_json``/dict-Konstruktoren mitbringen und damit eine
|
|
16
|
+
robuste generische JSON<->Message-Konvertierung ohne Sonderfaelle ermoeglichen.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import inspect
|
|
22
|
+
import pkgutil
|
|
23
|
+
import typing
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import proto
|
|
28
|
+
from google.api_core import protobuf_helpers
|
|
29
|
+
from google.api_core.operation import Operation as LRO
|
|
30
|
+
|
|
31
|
+
DEFAULT_VERSION = "v25"
|
|
32
|
+
|
|
33
|
+
_TO_DICT_KWARGS = {
|
|
34
|
+
"use_integers_for_enums": False,
|
|
35
|
+
"preserving_proto_field_name": True,
|
|
36
|
+
"always_print_fields_with_no_presence": False,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BridgeError(Exception):
|
|
41
|
+
"""Fehler bei der Aufloesung oder Ausfuehrung eines generischen API-Aufrufs."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_camel(resource_key: str) -> str:
|
|
45
|
+
return "".join(part.capitalize() for part in resource_key.split("_"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_message_class(annotation: Any) -> type | None:
|
|
49
|
+
for arg in typing.get_args(annotation):
|
|
50
|
+
if isinstance(arg, type) and issubclass(arg, proto.Message):
|
|
51
|
+
return arg
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _request_class_of(method: Any) -> type | None:
|
|
56
|
+
sig = inspect.signature(method)
|
|
57
|
+
request_param = sig.parameters.get("request")
|
|
58
|
+
if request_param is None:
|
|
59
|
+
return None
|
|
60
|
+
return _extract_message_class(request_param.annotation)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class MutateTarget:
|
|
65
|
+
service: Any
|
|
66
|
+
method_name: str
|
|
67
|
+
operation_cls: type
|
|
68
|
+
request_cls: type
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def method(self) -> Any:
|
|
72
|
+
return getattr(self.service, self.method_name)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _services_package(version: str):
|
|
76
|
+
return __import__(
|
|
77
|
+
f"google.ads.googleads.{version}.services.services",
|
|
78
|
+
fromlist=["services"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _iter_all_service_names(version: str) -> list[str]:
|
|
83
|
+
"""Listet alle Service-Namen (PascalCase) der gegebenen API-Version auf."""
|
|
84
|
+
pkg = _services_package(version)
|
|
85
|
+
names = []
|
|
86
|
+
for module_info in pkgutil.iter_modules(pkg.__path__):
|
|
87
|
+
snake = module_info.name
|
|
88
|
+
camel = "".join(part.capitalize() for part in snake.split("_"))
|
|
89
|
+
names.append(camel)
|
|
90
|
+
return sorted(set(names))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_mutate_method(service: Any, operation_type_name: str) -> tuple[str, type] | None:
|
|
94
|
+
for attr in dir(service):
|
|
95
|
+
if not attr.startswith("mutate_"):
|
|
96
|
+
continue
|
|
97
|
+
method = getattr(service, attr)
|
|
98
|
+
if not callable(method):
|
|
99
|
+
continue
|
|
100
|
+
request_cls = _request_class_of(method)
|
|
101
|
+
if request_cls is None:
|
|
102
|
+
continue
|
|
103
|
+
operations_field = request_cls.pb().DESCRIPTOR.fields_by_name.get("operations")
|
|
104
|
+
if operations_field is None:
|
|
105
|
+
continue
|
|
106
|
+
if operations_field.message_type.name == operation_type_name:
|
|
107
|
+
return attr, request_cls
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_mutate_target(client, resource_key: str, version: str = DEFAULT_VERSION) -> MutateTarget:
|
|
112
|
+
"""Findet Service + Mutate-Methode + Operation-Typ fuer eine Ressource.
|
|
113
|
+
|
|
114
|
+
Versucht zuerst den eponymen Service (schnell, funktioniert fuer praktisch
|
|
115
|
+
alle Ressourcen). Faellt andernfalls auf einen Scan ueber alle Services
|
|
116
|
+
zurueck (Robustheit gegen zukuenftige Namenskonvention-Ausnahmen).
|
|
117
|
+
"""
|
|
118
|
+
camel = to_camel(resource_key)
|
|
119
|
+
operation_type_name = f"{camel}Operation"
|
|
120
|
+
service_name = f"{camel}Service"
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
service = client.get_service(service_name, version=version)
|
|
124
|
+
except ValueError:
|
|
125
|
+
service = None
|
|
126
|
+
|
|
127
|
+
if service is not None:
|
|
128
|
+
found = _find_mutate_method(service, operation_type_name)
|
|
129
|
+
if found:
|
|
130
|
+
method_name, request_cls = found
|
|
131
|
+
operation_cls = type(client.get_type(operation_type_name, version=version))
|
|
132
|
+
return MutateTarget(
|
|
133
|
+
service=service, method_name=method_name, operation_cls=operation_cls, request_cls=request_cls
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for candidate_service_name in _iter_all_service_names(version):
|
|
137
|
+
try:
|
|
138
|
+
candidate_service = client.get_service(candidate_service_name, version=version)
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
found = _find_mutate_method(candidate_service, operation_type_name)
|
|
142
|
+
if found:
|
|
143
|
+
method_name, request_cls = found
|
|
144
|
+
operation_cls = type(client.get_type(operation_type_name, version=version))
|
|
145
|
+
return MutateTarget(
|
|
146
|
+
service=candidate_service,
|
|
147
|
+
method_name=method_name,
|
|
148
|
+
operation_cls=operation_cls,
|
|
149
|
+
request_cls=request_cls,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
raise BridgeError(
|
|
153
|
+
f"Keine mutate-faehige Ressource '{resource_key}' gefunden "
|
|
154
|
+
f"(erwarteter Operation-Typ: {operation_type_name})."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_operation(target: MutateTarget, op_dict: dict) -> Any:
|
|
159
|
+
"""Baut eine <Resource>Operation-Instanz aus einem JSON-Operationsdict.
|
|
160
|
+
|
|
161
|
+
Erwartetes Format: {"create": {...}} | {"update": {...}, "update_mask": [...]}
|
|
162
|
+
| {"remove": "resource_name"}. Bei "update" ohne explizite update_mask wird
|
|
163
|
+
die Maske automatisch aus den gesetzten Feldern abgeleitet (wie in den
|
|
164
|
+
offiziellen Google-Ads-Python-Beispielen).
|
|
165
|
+
"""
|
|
166
|
+
keys = {"create", "update", "remove"} & op_dict.keys()
|
|
167
|
+
if len(keys) != 1:
|
|
168
|
+
raise BridgeError(
|
|
169
|
+
"Jede Operation braucht genau eines von 'create', 'update', 'remove': "
|
|
170
|
+
f"erhalten wurde {sorted(op_dict.keys())}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if "create" in op_dict:
|
|
174
|
+
return target.operation_cls(create=op_dict["create"])
|
|
175
|
+
|
|
176
|
+
if "remove" in op_dict:
|
|
177
|
+
return target.operation_cls(remove=op_dict["remove"])
|
|
178
|
+
|
|
179
|
+
update_payload = op_dict["update"]
|
|
180
|
+
if "resource_name" not in update_payload:
|
|
181
|
+
raise BridgeError("'update'-Operationen benoetigen ein 'resource_name'-Feld im Payload.")
|
|
182
|
+
operation = target.operation_cls(update=update_payload)
|
|
183
|
+
update_mask = op_dict.get("update_mask")
|
|
184
|
+
if update_mask is not None:
|
|
185
|
+
operation.update_mask.paths.extend(update_mask)
|
|
186
|
+
else:
|
|
187
|
+
resource_cls = type(operation.update)
|
|
188
|
+
derived_mask = protobuf_helpers.field_mask(None, resource_cls.pb(operation.update))
|
|
189
|
+
operation.update_mask.paths.extend(derived_mask.paths)
|
|
190
|
+
return operation
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def message_to_dict(message: Any) -> dict:
|
|
194
|
+
return type(message).to_dict(message, **_TO_DICT_KWARGS)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def run_mutate(
|
|
198
|
+
client,
|
|
199
|
+
resource_key: str,
|
|
200
|
+
customer_id: str,
|
|
201
|
+
operations: list[dict],
|
|
202
|
+
*,
|
|
203
|
+
partial_failure: bool = False,
|
|
204
|
+
validate_only: bool = False,
|
|
205
|
+
version: str = DEFAULT_VERSION,
|
|
206
|
+
) -> dict:
|
|
207
|
+
target = resolve_mutate_target(client, resource_key, version=version)
|
|
208
|
+
built_operations = [build_operation(target, op) for op in operations]
|
|
209
|
+
request = target.request_cls(
|
|
210
|
+
customer_id=customer_id,
|
|
211
|
+
operations=built_operations,
|
|
212
|
+
partial_failure=partial_failure,
|
|
213
|
+
validate_only=validate_only,
|
|
214
|
+
)
|
|
215
|
+
response = target.method(request=request)
|
|
216
|
+
return message_to_dict(response)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def resolve_call_target(client, service_name: str, method_name: str, version: str = DEFAULT_VERSION):
|
|
220
|
+
try:
|
|
221
|
+
service = client.get_service(service_name, version=version)
|
|
222
|
+
except ValueError as exc:
|
|
223
|
+
raise BridgeError(str(exc)) from exc
|
|
224
|
+
|
|
225
|
+
method = getattr(service, method_name, None)
|
|
226
|
+
if method is None or not callable(method):
|
|
227
|
+
available = sorted(a for a in dir(service) if not a.startswith("_") and callable(getattr(service, a)))
|
|
228
|
+
raise BridgeError(
|
|
229
|
+
f"Service '{service_name}' hat keine Methode '{method_name}'. "
|
|
230
|
+
f"Verfuegbare Methoden: {available}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
request_cls = _request_class_of(method)
|
|
234
|
+
return service, method, request_cls
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def normalize_response(
|
|
238
|
+
response: Any,
|
|
239
|
+
*,
|
|
240
|
+
wait_for_operation: bool = True,
|
|
241
|
+
operation_timeout: float | None = None,
|
|
242
|
+
) -> Any:
|
|
243
|
+
if isinstance(response, LRO):
|
|
244
|
+
if not wait_for_operation:
|
|
245
|
+
return {"long_running_operation": response.operation.name, "done": response.done()}
|
|
246
|
+
return normalize_response(
|
|
247
|
+
response.result(timeout=operation_timeout),
|
|
248
|
+
wait_for_operation=wait_for_operation,
|
|
249
|
+
operation_timeout=operation_timeout,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if isinstance(response, proto.Message):
|
|
253
|
+
as_dict = message_to_dict(response)
|
|
254
|
+
if "results" in as_dict and isinstance(as_dict["results"], list):
|
|
255
|
+
return as_dict["results"]
|
|
256
|
+
return as_dict
|
|
257
|
+
|
|
258
|
+
if isinstance(response, (str, bytes, dict)):
|
|
259
|
+
return response
|
|
260
|
+
|
|
261
|
+
if hasattr(response, "__iter__"):
|
|
262
|
+
rows: list[Any] = []
|
|
263
|
+
for item in response:
|
|
264
|
+
normalized = normalize_response(
|
|
265
|
+
item, wait_for_operation=wait_for_operation, operation_timeout=operation_timeout
|
|
266
|
+
)
|
|
267
|
+
if isinstance(normalized, list):
|
|
268
|
+
rows.extend(normalized)
|
|
269
|
+
else:
|
|
270
|
+
rows.append(normalized)
|
|
271
|
+
return rows
|
|
272
|
+
|
|
273
|
+
return response
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def invoke_call(
|
|
277
|
+
client,
|
|
278
|
+
service_name: str,
|
|
279
|
+
method_name: str,
|
|
280
|
+
payload: dict | None = None,
|
|
281
|
+
*,
|
|
282
|
+
wait_for_operation: bool = True,
|
|
283
|
+
operation_timeout: float | None = None,
|
|
284
|
+
version: str = DEFAULT_VERSION,
|
|
285
|
+
) -> Any:
|
|
286
|
+
_service, method, request_cls = resolve_call_target(client, service_name, method_name, version=version)
|
|
287
|
+
payload = payload or {}
|
|
288
|
+
request = request_cls(payload) if request_cls is not None else payload
|
|
289
|
+
response = method(request=request)
|
|
290
|
+
return normalize_response(
|
|
291
|
+
response, wait_for_operation=wait_for_operation, operation_timeout=operation_timeout
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def list_service_names(version: str = DEFAULT_VERSION) -> list[str]:
|
|
296
|
+
return _iter_all_service_names(version)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def list_service_methods(client, service_name: str, version: str = DEFAULT_VERSION) -> list[str]:
|
|
300
|
+
try:
|
|
301
|
+
service = client.get_service(service_name, version=version)
|
|
302
|
+
except ValueError as exc:
|
|
303
|
+
raise BridgeError(str(exc)) from exc
|
|
304
|
+
return sorted(
|
|
305
|
+
attr
|
|
306
|
+
for attr in dir(service)
|
|
307
|
+
if not attr.startswith("_") and callable(getattr(service, attr))
|
|
308
|
+
)
|
googleadscli/utils.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Kleine Hilfsfunktionen."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_customer_id(customer_id: str) -> str:
|
|
10
|
+
"""Entfernt Bindestriche aus einer CID-Eingabe wie '123-456-7890'."""
|
|
11
|
+
return customer_id.replace("-", "").strip()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_json_arg(value: str | None) -> dict | list | None:
|
|
15
|
+
"""Laedt JSON entweder aus einem literalen String oder, falls value ein
|
|
16
|
+
existierender Dateipfad ist (mit '@' Praefix), aus dieser Datei."""
|
|
17
|
+
if value is None:
|
|
18
|
+
return None
|
|
19
|
+
if value.startswith("@"):
|
|
20
|
+
path = Path(value[1:]).expanduser()
|
|
21
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
22
|
+
return json.loads(value)
|