garf-google-ads 0.0.3__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,25 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Library for getting reports from Google Ads API."""
16
+
17
+ from garf_google_ads.api_clients import GoogleAdsApiClient
18
+ from garf_google_ads.report_fetcher import GoogleAdsApiReportFetcher
19
+
20
+ __all__ = [
21
+ 'GoogleAdsApiClient',
22
+ 'GoogleAdsApiReportFetcher',
23
+ ]
24
+
25
+ __version__ = '0.0.3'
@@ -0,0 +1,206 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the 'License');
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an 'AS IS' BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Creates API client for Google Ads API."""
15
+
16
+ from __future__ import annotations
17
+
18
+ import importlib
19
+ import logging
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Final
23
+
24
+ import google.auth
25
+ import smart_open
26
+ import tenacity
27
+ import yaml
28
+ from garf_core import api_clients
29
+ from google.ads.googleads import client as googleads_client
30
+ from google.api_core import exceptions as google_exceptions
31
+ from typing_extensions import override
32
+
33
+ from garf_google_ads import exceptions, query_editor
34
+
35
+ GOOGLE_ADS_API_VERSION: Final = googleads_client._DEFAULT_VERSION
36
+ google_ads_service = importlib.import_module(
37
+ f'google.ads.googleads.{GOOGLE_ADS_API_VERSION}.'
38
+ 'services.types.google_ads_service'
39
+ )
40
+
41
+
42
+ class GoogleAdsApiClientError(exceptions.GoogleAdsApiError):
43
+ """Google Ads API client specific error."""
44
+
45
+
46
+ class GoogleAdsApiClient(api_clients.BaseClient):
47
+ """Client to interact with Google Ads API.
48
+
49
+ Attributes:
50
+ default_google_ads_yaml: Default location for google-ads.yaml file.
51
+ client: GoogleAdsClient to perform stream and mutate operations.
52
+ ads_service: GoogleAdsService to perform stream operations.
53
+ """
54
+
55
+ default_google_ads_yaml = str(Path.home() / 'google-ads.yaml')
56
+
57
+ def __init__(
58
+ self,
59
+ path_to_config: str | os.PathLike[str] = os.getenv(
60
+ 'GOOGLE_ADS_CONFIGURATION_FILE_PATH', default_google_ads_yaml
61
+ ),
62
+ config_dict: dict[str, str] | None = None,
63
+ yaml_str: str | None = None,
64
+ version: str = GOOGLE_ADS_API_VERSION,
65
+ use_proto_plus: bool = True,
66
+ ads_client: googleads_client.GoogleAdsClient | None = None,
67
+ **kwargs: str,
68
+ ) -> None:
69
+ """Initializes GoogleAdsApiClient based on one of the methods.
70
+
71
+ Args:
72
+ path_to_config: Path to google-ads.yaml file.
73
+ config_dict: A dictionary containing authentication details.
74
+ yaml_str: Strings representation of google-ads.yaml.
75
+ version: Ads API version.
76
+ use_proto_plus: Whether to convert Enums to names in response.
77
+ ads_client: Instantiated GoogleAdsClient.
78
+
79
+ Raises:
80
+ GoogleAdsApiClientError:
81
+ When GoogleAdsClient cannot be instantiated due to missing
82
+ credentials.
83
+ """
84
+ self.api_version = (
85
+ str(version) if str(version).startswith('v') else f'v{version}'
86
+ )
87
+ self.client = ads_client or self._init_client(
88
+ path=path_to_config, config_dict=config_dict, yaml_str=yaml_str
89
+ )
90
+ self.client.use_proto_plus = use_proto_plus
91
+ self.ads_service = self.client.get_service('GoogleAdsService')
92
+ self.kwargs = kwargs
93
+
94
+ @override
95
+ @tenacity.retry(
96
+ stop=tenacity.stop_after_attempt(3),
97
+ wait=tenacity.wait_exponential(),
98
+ retry=tenacity.retry_if_exception_type(
99
+ google_exceptions.InternalServerError
100
+ ),
101
+ reraise=True,
102
+ )
103
+ def get_response(
104
+ self, request: query_editor.GoogleAdsApiQuery, account: int, **kwargs: str
105
+ ) -> 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
+ """
119
+ response = self.ads_service.search_stream(
120
+ customer_id=account, query=request.text
121
+ )
122
+ results = [result for batch in response for result in batch.results]
123
+ return api_clients.GarfApiResponse(results=results)
124
+
125
+ def _init_client(
126
+ self,
127
+ path: str | None = None,
128
+ config_dict: dict[str, str] | None = None,
129
+ yaml_str: str | None = None,
130
+ ) -> googleads_client.GoogleAdsClient | None:
131
+ """Initializes GoogleAdsClient based on one of the methods.
132
+
133
+ Args:
134
+ path: Path to google-ads.yaml file.
135
+ config_dict: A dictionary containing authentication details.
136
+ yaml_str: Strings representation of google-ads.yaml.
137
+
138
+ Returns:
139
+ Instantiated GoogleAdsClient;
140
+ None if instantiation hasn't been done.
141
+
142
+ Raises:
143
+ GoogleAdsApiClientError:
144
+ if google-ads.yaml wasn't found or missing crucial parts.
145
+ """
146
+ if config_dict:
147
+ if not (developer_token := config_dict.get('developer_token')):
148
+ raise GoogleAdsApiClientError('developer_token is missing.')
149
+ if (
150
+ 'refresh_token' not in config_dict
151
+ and 'json_key_file_path' not in config_dict
152
+ ):
153
+ credentials, _ = google.auth.default(
154
+ scopes=['https://www.googleapis.com/auth/adwords']
155
+ )
156
+ if login_customer_id := config_dict.get('login_customer_id'):
157
+ login_customer_id = str(login_customer_id)
158
+
159
+ return googleads_client.GoogleAdsClient(
160
+ credentials=credentials,
161
+ developer_token=developer_token,
162
+ login_customer_id=login_customer_id,
163
+ )
164
+ return googleads_client.GoogleAdsClient.load_from_dict(
165
+ config_dict, self.api_version
166
+ )
167
+ if yaml_str:
168
+ return googleads_client.GoogleAdsClient.load_from_string(
169
+ yaml_str, self.api_version
170
+ )
171
+ if path:
172
+ with smart_open.open(path, 'r', encoding='utf-8') as f:
173
+ google_ads_config_dict = yaml.safe_load(f)
174
+ return self._init_client(config_dict=google_ads_config_dict)
175
+ try:
176
+ return googleads_client.GoogleAdsClient.load_from_env(self.api_version)
177
+ except ValueError as e:
178
+ raise GoogleAdsApiClientError(
179
+ f'Cannot instantiate GoogleAdsClient: {str(e)}'
180
+ ) from e
181
+
182
+ @classmethod
183
+ def from_googleads_client(
184
+ cls,
185
+ ads_client: googleads_client.GoogleAdsClient,
186
+ use_proto_plus: bool = True,
187
+ ) -> GoogleAdsApiClient:
188
+ """Builds GoogleAdsApiClient from instantiated GoogleAdsClient.
189
+
190
+ ads_client: Instantiated GoogleAdsClient.
191
+ use_proto_plus: Whether to convert Enums to names in response.
192
+
193
+ Returns:
194
+ Instantiated GoogleAdsApiClient.
195
+ """
196
+ if use_proto_plus != ads_client.use_proto_plus:
197
+ logging.warning(
198
+ 'Mismatch between values of "use_proto_plus" in '
199
+ 'GoogleAdsClient and GoogleAdsApiClient, setting '
200
+ f'"use_proto_plus={use_proto_plus}"'
201
+ )
202
+ return cls(
203
+ ads_client=ads_client,
204
+ version=ads_client.version,
205
+ use_proto_plus=use_proto_plus,
206
+ )
@@ -0,0 +1,18 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ from garf_google_ads.builtins import ocid_mapping
15
+
16
+ BUILTIN_QUERIES = {
17
+ 'ocid_mapping': ocid_mapping.get_ocid_mapping,
18
+ }
@@ -0,0 +1,58 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import re
15
+
16
+ import garf_core
17
+
18
+
19
+ def get_ocid_mapping(
20
+ report_fetcher: 'garf_google_ads.GoogleAdsApiReportFetcher',
21
+ account: str | list[str],
22
+ **kwargs: str,
23
+ ):
24
+ """Returns mapping between external customer_id and OCID parameter.
25
+
26
+ OCID parameter is used to build links to Google Ads entities in UI.
27
+
28
+ Args:
29
+ report_fetcher: An instance of GoogleAdsApiReportFetcher.
30
+ accounts: Google Ads accounts to get data on OCID mapping.
31
+
32
+ Returns:
33
+ Report with mapping between external customer_id and OCID parameter.
34
+ """
35
+ query = (
36
+ 'SELECT customer.id AS account_id, '
37
+ 'metrics.optimization_score_url AS url FROM customer'
38
+ )
39
+ mapping = []
40
+ if isinstance(account, str):
41
+ account = account.split(',')
42
+ for acc in account:
43
+ if report := report_fetcher.fetch(query_specification=query, account=acc):
44
+ for row in report:
45
+ if ocid := re.findall(r'ocid=(\w+)', row.url):
46
+ mapping.append([row.account_id, ocid[0]])
47
+ break
48
+ if not ocid:
49
+ mapping.append([int(acc), '0'])
50
+ else:
51
+ mapping.append([int(acc), '0'])
52
+ return garf_core.GarfReport(
53
+ results=mapping,
54
+ column_names=[
55
+ 'account_id',
56
+ 'ocid',
57
+ ],
58
+ )
@@ -0,0 +1,19 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from garf_core import exceptions
16
+
17
+
18
+ class GoogleAdsApiError(exceptions.GarfError):
19
+ """Base class for all library exceptions."""
@@ -0,0 +1,288 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import importlib
20
+ import operator
21
+ import re
22
+ from collections import abc
23
+ from typing import Union, get_args
24
+
25
+ import proto # type: ignore
26
+ from garf_core import parsers
27
+ from google import protobuf
28
+ from proto.marshal.collections import repeated
29
+ from typing_extensions import Self, TypeAlias
30
+
31
+ from garf_google_ads import api_clients, query_editor
32
+
33
+ google_ads_service = importlib.import_module(
34
+ f'google.ads.googleads.{api_clients.GOOGLE_ADS_API_VERSION}.'
35
+ 'services.types.google_ads_service'
36
+ )
37
+
38
+ GoogleAdsRowElement: TypeAlias = Union[int, float, str, bool, list, None]
39
+
40
+ _REPEATED: TypeAlias = Union[
41
+ repeated.Repeated,
42
+ protobuf.internal.containers.RepeatedScalarFieldContainer,
43
+ ]
44
+ _REPEATED_COMPOSITE: TypeAlias = Union[
45
+ repeated.RepeatedComposite,
46
+ protobuf.internal.containers.RepeatedCompositeFieldContainer,
47
+ ]
48
+
49
+ _NESTED_FIELD: TypeAlias = Union[
50
+ _REPEATED,
51
+ _REPEATED_COMPOSITE,
52
+ ]
53
+
54
+
55
+ class BaseParser:
56
+ """Base class for defining parsers.
57
+
58
+ Attributes:
59
+ _successor: Indicates the previous parser in the chain.
60
+ """
61
+
62
+ def __init__(self, successor: type[Self]) -> None:
63
+ self._successor = successor
64
+
65
+ def parse(self, element: GoogleAdsRowElement) -> GoogleAdsRowElement:
66
+ """Parses GoogleAdsRow by using a successor parser.
67
+
68
+ Args:
69
+ element: An element of a GoogleAdsRow.
70
+
71
+ Returns:
72
+ Parsed GoogleAdsRow element.
73
+ """
74
+ if self._successor:
75
+ return self._successor.parse(element)
76
+ return None
77
+
78
+
79
+ class RepeatedParser(BaseParser):
80
+ """Parses repeated.Repeated resources."""
81
+
82
+ def parse(self, element: GoogleAdsRowElement) -> GoogleAdsRowElement:
83
+ """Parses only repeated elements from GoogleAdsRow.
84
+
85
+ If there a repeated resource, applies transformations to each element;
86
+ otherwise delegates parsing of the element to the next parser
87
+ in the chain.
88
+
89
+ Args:
90
+ element: An element of a GoogleAdsRow.
91
+
92
+ Returns:
93
+ Parsed GoogleAdsRow element.
94
+ """
95
+ if isinstance(element, get_args(_REPEATED)) and 'customer' in str(element):
96
+ items: list[GoogleAdsRowElement] = []
97
+ for item in element:
98
+ items.append(
99
+ ResourceFormatter(item).get_resource_id().clean_resource_id().format()
100
+ )
101
+ return items
102
+ return super().parse(element)
103
+
104
+
105
+ class RepeatedCompositeParser(BaseParser):
106
+ """Parses repeated.RepeatedComposite elements."""
107
+
108
+ def parse(self, element):
109
+ """Parses only repeated composite resources from GoogleAdsRow.
110
+
111
+ If there a repeated composited resource, applies transformations
112
+ to each element; otherwise delegates parsing of the element
113
+ to the next parser in the chain.
114
+
115
+ Args:
116
+ element: An element of a GoogleAdsRow.
117
+
118
+ Returns:
119
+ Parsed GoogleAdsRow element.
120
+ """
121
+ if isinstance(element, get_args(_REPEATED_COMPOSITE)):
122
+ items = []
123
+ for item in element:
124
+ items.append(
125
+ ResourceFormatter(item)
126
+ .get_nested_resource()
127
+ .get_resource_id()
128
+ .clean_resource_id()
129
+ .format()
130
+ )
131
+ return items
132
+ return super().parse(element)
133
+
134
+
135
+ class AttributeParser(BaseParser):
136
+ """Parses elements that have attributes."""
137
+
138
+ def parse(self, element: GoogleAdsRowElement) -> GoogleAdsRowElement:
139
+ """Parses only elements that have attributes.
140
+
141
+ If there a repeated composited resource, applies transformations
142
+ to each element; otherwise delegates parsing of the element
143
+ to the next parser in the chain.
144
+
145
+ Args:
146
+ element: An element of a GoogleAdsRow.
147
+
148
+ Returns:
149
+ Parsed GoogleAdsRow element.
150
+ """
151
+ if hasattr(element, 'name'):
152
+ return element.name
153
+ if hasattr(element, 'text'):
154
+ return element.text
155
+ if hasattr(element, 'asset'):
156
+ return element.asset
157
+ if hasattr(element, 'value'):
158
+ return element.value
159
+ return super().parse(element)
160
+
161
+
162
+ class EmptyMessageParser(BaseParser):
163
+ """Generates placeholder for empty Message objects."""
164
+
165
+ def parse(self, element: GoogleAdsRowElement) -> GoogleAdsRowElement:
166
+ """Checks if an element is an empty proto.Message.
167
+
168
+ If an element is empty message, returns 'Not set' placeholder;
169
+ otherwise delegates parsing of the element to the next parser
170
+ in the chain.
171
+
172
+ Args:
173
+ element: An element of a GoogleAdsRow.
174
+
175
+ Returns:
176
+ Parsed GoogleAdsRow element.
177
+ """
178
+ if issubclass(type(element), proto.Message):
179
+ return 'Not set'
180
+ return super().parse(element)
181
+
182
+
183
+ class GoogleAdsRowParser(parsers.ProtoParser):
184
+ """Performs parsing of a single GoogleAdsRow.
185
+
186
+ Attributes:
187
+ fields: Expected fields in GoogleAdsRow.
188
+ customizers: Customizing behaviour performed on a field.
189
+ virtual_columns: Elements that are not directly present in GoogleAdsRow.
190
+ parser: Chain of parsers to execute on a single GoogleAdsRow.
191
+ row_getter: Helper to easily extract fields from GogleAdsRow.
192
+ respect_nulls: Whether or not convert nulls to zeros.
193
+ """
194
+
195
+ def __init__(
196
+ self, query_specification: query_editor.QueryElements, **kwargs: str
197
+ ) -> None:
198
+ """Initializes GoogleAdsRowParser.
199
+
200
+ Args:
201
+ query_specification: All elements forming gaarf query.
202
+ """
203
+ super().__init__(query_specification, **kwargs)
204
+ self.fields = query_specification.fields
205
+ self.customizers = query_specification.customizers
206
+ self.virtual_columns = query_specification.virtual_columns
207
+ self.column_names = query_specification.column_names
208
+ self.parser_chain = self._init_parsers_chain()
209
+ self.row_getter = operator.attrgetter(*query_specification.fields)
210
+ # Some segments are automatically converted to 0 when not present
211
+ # For this case we specify attribute `respect_null` which converts
212
+ # such attributes to None rather than 0
213
+ self.respect_nulls = (
214
+ 'segments.sk_ad_network_conversion_value' in self.fields
215
+ )
216
+
217
+ def _init_parsers_chain(self):
218
+ """Initializes chain of parsers."""
219
+ parser_chain = BaseParser(None)
220
+ for parser in [
221
+ EmptyMessageParser,
222
+ AttributeParser,
223
+ RepeatedCompositeParser,
224
+ RepeatedParser,
225
+ ]:
226
+ new_parser = parser(parser_chain)
227
+ parser_chain = new_parser
228
+ return parser_chain
229
+
230
+ def parse_row_element(
231
+ self, row: GoogleAdsRowElement, key: str
232
+ ) -> GoogleAdsRowElement:
233
+ """Parses a single element from row.
234
+
235
+ Args:
236
+ extracted_attribute: A single element from GoogleAdsRow.
237
+ column: Corresponding name of the element.
238
+
239
+ Returns:
240
+ Parsed element.
241
+ """
242
+ getter = operator.attrgetter(key)
243
+ row = getter(row)
244
+ if isinstance(row, abc.MutableSequence):
245
+ parsed_element = [
246
+ self.parser_chain.parse(element) or element for element in row
247
+ ]
248
+ else:
249
+ parsed_element = self.parser_chain.parse(row) or row
250
+
251
+ return parsed_element
252
+
253
+
254
+ class ResourceFormatter:
255
+ """Helper class for formatting resources strings."""
256
+
257
+ def __init__(self, element: str) -> None:
258
+ """Initializes ResourceFormatter based on element."""
259
+ self.element = str(element).strip()
260
+
261
+ def get_nested_resource(self) -> Self:
262
+ """Extract nested resources from the API response field."""
263
+ self.element = re.split(': ', self.element)[1]
264
+ return self
265
+
266
+ def get_resource_id(self) -> Self:
267
+ """Extracts last id of resource_name.
268
+
269
+ Resource name looks like `customer/123/campaigns/321`.
270
+ `get_resource_id` returns `321`.
271
+ """
272
+ self.element = re.split('/', self.element)[-1]
273
+ return self
274
+
275
+ def clean_resource_id(self) -> Self:
276
+ """Ensures that resource_id is cleaned up and converted to int."""
277
+ self.element = re.sub('"', '', self.element)
278
+ with contextlib.suppress(ValueError):
279
+ self.element = int(self.element)
280
+ return self
281
+
282
+ def format(self) -> str | int:
283
+ """Final method to return formatted resource.
284
+
285
+ Returns:
286
+ Formatted resource.
287
+ """
288
+ return self.element
@@ -0,0 +1,83 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Defines Google Ads API specific query parser."""
15
+
16
+ import re
17
+
18
+ from garf_core import query_editor
19
+
20
+
21
+ class GoogleAdsApiQuery(query_editor.QuerySpecification):
22
+ """Query to Google Ads API."""
23
+
24
+ def generate(self):
25
+ base_query = super().generate()
26
+ for field in base_query.fields:
27
+ field = _format_type_field_name(field)
28
+ for customizer in base_query.customizers.values():
29
+ if customizer.type == 'nested_field':
30
+ customizer.value = _format_type_field_name(customizer.value)
31
+ base_query.text = self._create_gaql_query()
32
+ return base_query
33
+
34
+ def _create_gaql_query(
35
+ self,
36
+ ) -> str:
37
+ """Generate valid GAQL query.
38
+
39
+ Based on original query text, a set of field and virtual columns
40
+ constructs new GAQL query to be sent to Ads API.
41
+
42
+ Returns:
43
+ Valid GAQL query.
44
+ """
45
+ virtual_fields = [
46
+ field
47
+ for name, column in self.query.virtual_columns.items()
48
+ if column.type == 'expression'
49
+ for field in column.fields
50
+ ]
51
+ fields = self.query.fields
52
+ if virtual_fields:
53
+ fields = self.query.fields + virtual_fields
54
+ joined_fields = ', '.join(fields)
55
+ if filters := self.query.filters:
56
+ filter_conditions = ' AND '.join(filters)
57
+ filters = f'WHERE {filter_conditions}'
58
+ else:
59
+ filters = ''
60
+ if sorts := self.query.sorts:
61
+ sort_conditions = ' AND '.join(sorts)
62
+ sorts = f'ORDER BY {sort_conditions}'
63
+ else:
64
+ sorts = ''
65
+ query_text = (
66
+ f'SELECT {joined_fields} '
67
+ f'FROM {self.query.resource_name} '
68
+ f'{filters} {sorts}'
69
+ )
70
+ query_text = _unformat_type_field_name(query_text)
71
+ return re.sub(r'\s+', ' ', query_text).strip()
72
+
73
+
74
+ def _unformat_type_field_name(query: str) -> str:
75
+ if query == 'type_':
76
+ return 'type'
77
+ return re.sub(r'\.type_', '.type', query)
78
+
79
+
80
+ def _format_type_field_name(query: str) -> str:
81
+ if query == 'type':
82
+ return 'type_'
83
+ return re.sub(r'\.type', '.type_', query)
@@ -0,0 +1,192 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Defines report fetcher for Google Ads API."""
16
+
17
+ import asyncio
18
+ import functools
19
+ import operator
20
+
21
+ import garf_core
22
+
23
+ from garf_google_ads import (
24
+ GoogleAdsApiClient,
25
+ builtins,
26
+ exceptions,
27
+ parsers,
28
+ query_editor,
29
+ )
30
+
31
+
32
+ class GoogleAdsApiReportFetcherError(exceptions.GoogleAdsApiError):
33
+ """Report fetcher specific error."""
34
+
35
+
36
+ class GoogleAdsApiReportFetcher(garf_core.ApiReportFetcher):
37
+ """Defines report fetcher."""
38
+
39
+ def __init__(
40
+ self,
41
+ api_client: GoogleAdsApiClient | None = None,
42
+ parser: garf_core.parsers.ProtoParser = parsers.GoogleAdsRowParser,
43
+ query_spec: query_editor.GoogleAdsApiQuery = (
44
+ query_editor.GoogleAdsApiQuery
45
+ ),
46
+ builtin_queries=builtins.BUILTIN_QUERIES,
47
+ parallel_threshold: int = 10,
48
+ **kwargs: str,
49
+ ) -> None:
50
+ """Initializes GoogleAdsApiReportFetcher."""
51
+ if not api_client:
52
+ api_client = GoogleAdsApiClient(**kwargs)
53
+ self.parallel_threshold = parallel_threshold
54
+ super().__init__(api_client, parser, query_spec, builtin_queries, **kwargs)
55
+
56
+ def fetch(
57
+ self,
58
+ query_specification: str | query_editor.GoogleAdsApiQuery,
59
+ args: garf_core.query_editor.GarfQueryParameters | None = None,
60
+ account: str | list[str] | None = None,
61
+ expand_mcc: bool = False,
62
+ customer_ids_query: str | None = None,
63
+ **kwargs: str,
64
+ ) -> garf_core.GarfReport:
65
+ """Fetches data from Google Ads API.
66
+
67
+ Args:
68
+ query_specification: Query to execute.
69
+ args: Optional parameters to fine-tune the query.
70
+ account: Account(s) to get data from.
71
+ expand_mcc: Whether to perform account expansion (MCC to Account).
72
+ customer_ids_query: Query to reduce number of accounts based a condition.
73
+
74
+ Returns:
75
+ Fetched report for provided accounts.
76
+
77
+ Raises:
78
+ GoogleAdsApiReportFetcherError: If not account provided or found.
79
+ """
80
+ if not account:
81
+ raise GoogleAdsApiReportFetcherError(
82
+ 'Provide an account to get data from.'
83
+ )
84
+ if isinstance(account, str):
85
+ account = account.replace('-', '')
86
+ account = account.split(',')
87
+ else:
88
+ account = [a.replace('-', '') for a in account]
89
+ if not args:
90
+ args = {}
91
+ if expand_mcc or customer_ids_query:
92
+ account = self.expand_mcc(
93
+ customer_ids=account, customer_ids_query=customer_ids_query
94
+ )
95
+ if not account:
96
+ raise GoogleAdsApiReportFetcherError(
97
+ 'No account found satisfying the condition {customer_ids_query}.'
98
+ )
99
+ if len(account) == 1:
100
+ return super().fetch(
101
+ query_specification=query_specification,
102
+ args=args,
103
+ account=str(account[0]),
104
+ **kwargs,
105
+ )
106
+ reports = asyncio.run(
107
+ self._process_accounts(
108
+ query=query_specification,
109
+ account=account,
110
+ args=args,
111
+ )
112
+ )
113
+ return functools.reduce(operator.add, reports)
114
+
115
+ async def _process_accounts(
116
+ self,
117
+ query,
118
+ account: list[str],
119
+ args,
120
+ ):
121
+ semaphore = asyncio.Semaphore(value=self.parallel_threshold)
122
+
123
+ async def run_with_semaphore(fn):
124
+ async with semaphore:
125
+ return await fn
126
+
127
+ tasks = [
128
+ self.afetch(query_specification=query, account=str(acc), args=args)
129
+ for acc in account
130
+ ]
131
+ return await asyncio.gather(*(run_with_semaphore(task) for task in tasks))
132
+
133
+ def expand_mcc(
134
+ self,
135
+ customer_ids: str | list[str],
136
+ customer_ids_query: str | None = None,
137
+ ) -> list[str]:
138
+ """Performs Manager account(s) expansion to child accounts.
139
+
140
+ Args:
141
+ customer_ids: Manager account(s) to be expanded.
142
+ customer_ids_query: GAQL query used to reduce the number of customer_ids.
143
+
144
+ Returns:
145
+ All child accounts under provided customer_ids.
146
+ """
147
+ return self._get_customer_ids(
148
+ seed_customer_ids=customer_ids, customer_ids_query=customer_ids_query
149
+ )
150
+
151
+ def _get_customer_ids(
152
+ self,
153
+ seed_customer_ids: str | list[str],
154
+ customer_ids_query: str | None = None,
155
+ ) -> list[str]:
156
+ """Gets list of customer_ids from an MCC account.
157
+
158
+ Args:
159
+ seed_customer_ids: MCC account_id(s).
160
+ customer_ids_query: GAQL query used to reduce the number of customer_ids.
161
+
162
+ Returns:
163
+ All customer_ids from MCC satisfying the condition.
164
+ """
165
+ query = """
166
+ SELECT customer_client.id FROM customer_client
167
+ WHERE customer_client.manager = FALSE
168
+ AND customer_client.status = ENABLED
169
+ AND customer_client.hidden = FALSE
170
+ """
171
+ if isinstance(seed_customer_ids, str):
172
+ seed_customer_ids = seed_customer_ids.split(',')
173
+ child_customer_ids = self.fetch(
174
+ query_specification=query, account=seed_customer_ids
175
+ ).to_list()
176
+ if customer_ids_query:
177
+ child_customer_ids = self.fetch(
178
+ query_specification=customer_ids_query,
179
+ account=[str(a) for a in child_customer_ids],
180
+ )
181
+ child_customer_ids = [
182
+ row[0] if isinstance(row, garf_core.report.GarfRow) else row
183
+ for row in child_customer_ids
184
+ ]
185
+
186
+ return list(
187
+ {
188
+ str(customer_id)
189
+ for customer_id in child_customer_ids
190
+ if customer_id != 0
191
+ }
192
+ )
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: garf-google-ads
3
+ Version: 0.0.3
4
+ Summary: Garf implementation for Google Ads API
5
+ Author-email: Andrei Markin <amarkin@google.com>, "Google Inc. (gTech gPS CSE team)" <no-reply@google.com>
6
+ License: Apache 2.0
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Programming Language :: Python
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: garf-core
12
+ Requires-Dist: garf-io
13
+ Requires-Dist: google-ads
14
+ Requires-Dist: tenacity
15
+
16
+ # `garf` for Google Ads API
17
+
18
+ [![PyPI](https://img.shields.io/pypi/v/garf-google-ads?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/garf-google-ads)
19
+ [![Downloads PyPI](https://img.shields.io/pypi/dw/garf-google-ads?logo=pypi)](https://pypi.org/project/garf-google-ads/)
20
+
21
+ `garf-google-ads` simplifies fetching data from Google Ads API using SQL-like queries.
22
+
23
+ ## Prerequisites
24
+
25
+ * [Google Ads API](https://console.cloud.google.com/apis/library/googleads.googleapis.com) enabled.
26
+
27
+ ## Installation
28
+
29
+ `pip install garf-google-ads`
30
+
31
+ ## Usage
32
+
33
+ ### Run as a library
34
+ ```
35
+ import os
36
+
37
+ from garf_io import writer
38
+ from garf_google_ads import GoogleAdsApiReportFetcher
39
+
40
+ query = """
41
+ SELECT
42
+ campaign.id,
43
+ metrics.clicks AS clicks
44
+ FROM campaign
45
+ WHERE segments.date DURING LAST_7_DAYS
46
+ """
47
+
48
+ fetched_report = (
49
+ GoogleAdsApiReportFetcher(
50
+ path_to_config=os.getenv('GOOGLE_ADS_CONFIGURATION_FILE_PATH')
51
+ )
52
+ .fetch(query, account=os.getenv('GOOGLE_ADS_ACCOUNT'))
53
+ )
54
+
55
+ console_writer = writer.create_writer('console')
56
+ console_writer.write(fetched_report, 'query')
57
+ ```
58
+
59
+ ### Run via CLI
60
+
61
+ > Install `garf-executors` package to run queries via CLI (`pip install garf-executors`).
62
+
63
+ ```
64
+ garf <PATH_TO_QUERIES> --source google-ads \
65
+ --output <OUTPUT_TYPE> \
66
+ --source.account=GOOGLE_ADS_ACCOUNT \
67
+ --source.path-to-config=./google-ads.yaml
68
+ ```
69
+
70
+ where:
71
+
72
+ * `<PATH_TO_QUERIES>` - local or remove files containing queries
73
+ * `<OUTPUT_TYPE>` - output supported by [`garf-io` library](../garf_io/README.md).
74
+ * `<SOURCE_PARAMETER=VALUE` - key-value pairs to refine fetching, check [available source parameters](#available-source-parameters).
75
+
76
+ ### Available source parameters
77
+
78
+ | name | values| comments |
79
+ |----- | ----- | -------- |
80
+ | `account` | Account(s) to get data from | Can be MCC(s) as well |
81
+ | `path-to-config` | Path to `google-ads.yaml` file | `~/google-ads.yaml` is a default location |
82
+ | `expand-mcc` | Whether to force account expansion if MCC is provided | `False` by default |
83
+ | `customer-ids-query` | Optional query to find account satisfying specific condition | |
84
+ | `version` | Version of Google Ads API | |
@@ -0,0 +1,13 @@
1
+ garf_google_ads/__init__.py,sha256=OQ3zpFOx3vo4bQpvL6Zw1bB-mWarn5SorH7PVfQkSmY,853
2
+ garf_google_ads/api_clients.py,sha256=PbGka-vHJtpry_1vSojNUhpVFU9c8mucq7APKB9Mn1k,6794
3
+ garf_google_ads/exceptions.py,sha256=CJ0n3kwYP2wLLZ7TGtlaIxWWXXxcKEkZXZtvfvoCQmI,705
4
+ garf_google_ads/parsers.py,sha256=bPgUqbND8YCfKpV7yv97gKx-N44y5bqYPsnZbAGcTsY,8485
5
+ garf_google_ads/query_editor.py,sha256=0ScKZJ-_FMCZDcnDjdk3lY0Nzs8KQXMJT5sZJf8gOAk,2525
6
+ garf_google_ads/report_fetcher.py,sha256=I8S3G-AIDWhB7RI5uXCxKuu9qnTPL630C8gDU84jNog,5708
7
+ garf_google_ads/builtins/__init__.py,sha256=8cU6UJo1BQYQ6gYfZwkbtBfmAGhclH3yDjvwdnR8J5Y,697
8
+ garf_google_ads/builtins/ocid_mapping.py,sha256=VWuISWmnHXhX-jf22Di1V5-fIPCe2rr_D-JrL63e8aU,1773
9
+ garf_google_ads-0.0.3.dist-info/METADATA,sha256=GYKn6JS2NG5SwyMiiZpn0RIJ9tLrCVSw5d8PWh47gJk,2620
10
+ garf_google_ads-0.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ garf_google_ads-0.0.3.dist-info/entry_points.txt,sha256=X59pHlYZJu4SDH9YIQccab4bvUBR17wKBYhhn1NL8wk,51
12
+ garf_google_ads-0.0.3.dist-info/top_level.txt,sha256=q_Pd36wShRJUtFmU8b9dEkThSefbXPOMBjgIZyU46N4,16
13
+ garf_google_ads-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [garf]
2
+ google-ads = garf_google_ads.report_fetcher
@@ -0,0 +1 @@
1
+ garf_google_ads