1818import importlib
1919import logging
2020import os
21+ import re
2122from pathlib import Path
22- from typing import Final
23+ from types import ModuleType
24+ from typing import Any , Final
2325
2426import google .auth
27+ import proto
28+ import pydantic
2529import smart_open
2630import tenacity
2731import yaml
2832from garf_core import api_clients
33+ from google import protobuf
2934from google .ads .googleads import client as googleads_client
3035from google .api_core import exceptions as google_exceptions
3136from typing_extensions import override
3944)
4045
4146
47+ class FieldPossibleValues (pydantic .BaseModel ):
48+ name : str
49+ values : set [Any ]
50+
51+
4252class GoogleAdsApiClientError (exceptions .GoogleAdsApiError ):
4353 """Google Ads API client specific error."""
4454
@@ -91,6 +101,171 @@ def __init__(
91101 self .ads_service = self .client .get_service ('GoogleAdsService' )
92102 self .kwargs = kwargs
93103
104+ @property
105+ def _base_module (self ) -> str :
106+ """Name of Google Ads module for a given API version."""
107+ return f'google.ads.googleads.{ self .api_version } '
108+
109+ @property
110+ def _common_types_module (self ) -> str :
111+ """Name of module containing common data types."""
112+ return f'{ self ._base_module } .common.types'
113+
114+ @property
115+ def _metrics (self ) -> ModuleType :
116+ """Module containing metrics."""
117+ return importlib .import_module (f'{ self ._common_types_module } .metrics' )
118+
119+ @property
120+ def _segments (self ) -> ModuleType :
121+ """Module containing segments."""
122+ return importlib .import_module (f'{ self ._common_types_module } .segments' )
123+
124+ def _get_google_ads_row (self ) -> google_ads_service .GoogleAdsRow :
125+ """Gets GoogleAdsRow proto message for a given API version."""
126+ google_ads_service = importlib .import_module (
127+ f'google.ads.googleads.{ self .api_version } .'
128+ f'services.types.google_ads_service'
129+ )
130+ return google_ads_service .GoogleAdsRow ()
131+
132+ def get_types (self , request ):
133+ return []
134+
135+ def _get_types (self , request ):
136+ output = []
137+ for field_name in request .fields :
138+ try :
139+ descriptor = self ._get_descriptor (field_name )
140+ values = self ._get_possible_values_for_resource (descriptor )
141+ field = FieldPossibleValues (name = field_name , values = values )
142+ except (AttributeError , ModuleNotFoundError ):
143+ field = FieldPossibleValues (
144+ name = field_name ,
145+ values = {
146+ '' ,
147+ },
148+ )
149+ output .append (field )
150+ return output
151+
152+ def _get_descriptor (
153+ self , field : str
154+ ) -> protobuf .descriptor_pb2 .FieldDescriptorProto :
155+ """Gets descriptor for specified field.
156+
157+ Args:
158+ field: Valid field name to be sent to Ads API.
159+
160+ Returns:
161+ FieldDescriptorProto for specified field.
162+ """
163+ resource , * sub_resource , base_field = field .split ('.' )
164+ base_field = 'type_' if base_field == 'type' else base_field
165+ target_resource = self ._get_target_resource (resource , sub_resource )
166+ return target_resource .meta .fields .get (base_field ).descriptor
167+
168+ def _get_target_resource (
169+ self , resource : str , sub_resource : list [str ] | None = None
170+ ) -> proto .message .Message :
171+ """Gets Proto message for specified resource and its sub-resources.
172+
173+ Args:
174+ resource:
175+ Google Ads resource (campaign, ad_group, segments, etc.).
176+ sub_resource:
177+ Possible sub-resources (date for segments resource).
178+
179+ Returns:
180+ Proto describing combination of resource and sub-resource.
181+ """
182+ if resource == 'metrics' :
183+ target_resource = self ._metrics .Metrics
184+ elif resource == 'segments' :
185+ # If segment has name segments.something.something
186+ if sub_resource :
187+ target_resource = getattr (
188+ self ._segments , f'{ clean_resource (sub_resource [- 1 ])} '
189+ )
190+ else :
191+ target_resource = getattr (self ._segments , f'{ clean_resource (resource )} ' )
192+ else :
193+ resource_module = importlib .import_module (
194+ f'{ self ._base_module } .resources.types.{ resource } '
195+ )
196+
197+ target_resource = getattr (resource_module , f'{ clean_resource (resource )} ' )
198+ try :
199+ # If resource has name resource.something.something
200+ if sub_resource :
201+ target_resource = getattr (
202+ target_resource , f'{ clean_resource (sub_resource [- 1 ])} '
203+ )
204+ except AttributeError :
205+ try :
206+ resource_module = importlib .import_module (
207+ f'{ self ._base_module } .resources.types.{ sub_resource [0 ]} '
208+ )
209+ except ModuleNotFoundError :
210+ resource_module = importlib .import_module (
211+ f'{ self ._common_types_module } .{ sub_resource [0 ]} '
212+ )
213+ if len (sub_resource ) > 1 :
214+ if hasattr (resource_module , f'{ clean_resource (sub_resource [1 ])} ' ):
215+ target_resource = getattr (
216+ resource_module , f'{ clean_resource (sub_resource [- 1 ])} '
217+ )
218+ else :
219+ resource_module = importlib .import_module (
220+ f'{ self ._common_types_module } .ad_type_infos'
221+ )
222+
223+ target_resource = getattr (
224+ resource_module , f'{ clean_resource (sub_resource [1 ])} Info'
225+ )
226+ else :
227+ target_resource = getattr (
228+ resource_module , f'{ clean_resource (sub_resource [- 1 ])} '
229+ )
230+ return target_resource
231+
232+ def _get_possible_values_for_resource (
233+ self , descriptor : protobuf .descriptor_pb2 .FieldDescriptorProto
234+ ) -> set :
235+ """Identifies possible values for a given descriptor or field_type.
236+
237+ If descriptor's type is ENUM function gets all possible values for
238+ this Enum, otherwise the default value for descriptor type is taken
239+ (0 for int, '' for str, False for bool).
240+
241+ Args:
242+ descriptor: FieldDescriptorProto for specified field.
243+
244+ Returns:
245+ Possible values for a given descriptor.
246+ """
247+ mapping = {
248+ 'INT64' : int ,
249+ 'FLOAT' : float ,
250+ 'DOUBLE' : float ,
251+ 'BOOL' : bool ,
252+ }
253+ if descriptor .type == 14 : # 14 stands for ENUM
254+ enum_class , enum = descriptor .type_name .split ('.' )[- 2 :]
255+ file_name = re .sub (r'(?<!^)(?=[A-Z])' , '_' , enum ).lower ()
256+ enum_resource = importlib .import_module (
257+ f'{ self ._base_module } .enums.types.{ file_name } '
258+ )
259+ return {p .name for p in getattr (getattr (enum_resource , enum_class ), enum )}
260+
261+ field_type = mapping .get (
262+ proto .primitives .ProtoType (descriptor .type ).name , str
263+ )
264+ default_value = field_type ()
265+ return {
266+ default_value ,
267+ }
268+
94269 @override
95270 @tenacity .retry (
96271 stop = tenacity .stop_after_attempt (3 ),
@@ -103,24 +278,14 @@ def __init__(
103278 def get_response (
104279 self , request : query_editor .GoogleAdsApiQuery , account : int , ** kwargs : str
105280 ) -> api_clients .GarfApiResponse :
106- """Executes query for a given entity_id (customer_id).
107-
108- Args:
109- account: Google Ads customer_id.
110- query_text: GAQL query text.
111-
112- Returns:
113- SearchGoogleAdsStreamResponse for a given API version.
114-
115- Raises:
116- google_exceptions.InternalServerError:
117- When data cannot be fetched from Ads API.
118- """
281+ """Executes a single API request for a given customer_id and GAQL query."""
119282 response = self .ads_service .search_stream (
120283 customer_id = account , query = request .text
121284 )
122285 results = [result for batch in response for result in batch .results ]
123- return api_clients .GarfApiResponse (results = results )
286+ return api_clients .GarfApiResponse (
287+ results = results , results_placeholder = [self ._get_google_ads_row ()]
288+ )
124289
125290 def _init_client (
126291 self ,
@@ -204,3 +369,8 @@ def from_googleads_client(
204369 version = ads_client .version ,
205370 use_proto_plus = use_proto_plus ,
206371 )
372+
373+
374+ def clean_resource (resource : str ) -> str :
375+ """Converts nested resource to a TitleCase format."""
376+ return resource .title ().replace ('_' , '' )
0 commit comments