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,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)