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.
- garf_google_ads/__init__.py +25 -0
- garf_google_ads/api_clients.py +206 -0
- garf_google_ads/builtins/__init__.py +18 -0
- garf_google_ads/builtins/ocid_mapping.py +58 -0
- garf_google_ads/exceptions.py +19 -0
- garf_google_ads/parsers.py +288 -0
- garf_google_ads/query_editor.py +83 -0
- garf_google_ads/report_fetcher.py +192 -0
- garf_google_ads-0.0.3.dist-info/METADATA +84 -0
- garf_google_ads-0.0.3.dist-info/RECORD +13 -0
- garf_google_ads-0.0.3.dist-info/WHEEL +5 -0
- garf_google_ads-0.0.3.dist-info/entry_points.txt +2 -0
- garf_google_ads-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
[](https://pypi.org/project/garf-google-ads)
|
|
19
|
+
[](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 @@
|
|
|
1
|
+
garf_google_ads
|