Skip to content

Commit 4496a45

Browse files
[core] feat: add support for simulating API responses
* Add `simulator` module with `ApiReportSimulator` class. Via its `simulate` method based on a query and `SimulatorSpecification` users can generate `n_rows` of simulated results based on provided ApiClient. * Ensure that `GarfApiResponse` has optional `results_placeholder` field that contains fallback results in case API produce no results. * Add `get_types` method to API that could be implemented in concrete ApiClients; provide an implementation for FakeApiClient. * In `ApiReportFetcher.fetch` is no results are produces by ApiClient, generate placeholder and return empty report with `results_placeholder`. * Create two custom simulators - for googleads and youtube-data-api fetchers * Propagate `results_placeholder` in `YouTubeDataApiReportFetcher.fetch` when performing sorting.
1 parent c8c1d1d commit 4496a45

File tree

18 files changed

+668
-25
lines changed

18 files changed

+668
-25
lines changed

.github/workflows/test_garf_google_ads.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ jobs:
2929
uv pip install pytest
3030
- name: Test ${{ matrix.library }}
3131
run: |
32+
uv pip install -e libs/core/.[all]
33+
uv pip install -e libs/io/.[test,all]
34+
uv pip install -e libs/executors/.[all]
3235
cd libs/community/google/ads/
3336
uv pip install -e .
3437
pytest tests/unit

.github/workflows/test_garf_youtube_data_api.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ jobs:
2929
uv pip install pytest python-dotenv
3030
- name: Test ${{ matrix.library }}
3131
run: |
32+
uv pip install -e libs/core/.[all]
33+
uv pip install -e libs/io/.[test,all]
34+
uv pip install -e libs/executors/.[all]
3235
cd libs/community/google/youtube/youtube-data-api/
3336
uv pip install -e .
3437
pytest tests/e2e

libs/community/google/ads/garf_google_ads/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@
2222
'GoogleAdsApiReportFetcher',
2323
]
2424

25-
__version__ = '0.0.4'
25+
__version__ = '0.0.5'

libs/community/google/ads/garf_google_ads/api_clients.py

Lines changed: 185 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@
1818
import importlib
1919
import logging
2020
import os
21+
import re
2122
from pathlib import Path
22-
from typing import Final
23+
from types import ModuleType
24+
from typing import Any, Final
2325

2426
import google.auth
27+
import proto
28+
import pydantic
2529
import smart_open
2630
import tenacity
2731
import yaml
2832
from garf_core import api_clients
33+
from google import protobuf
2934
from google.ads.googleads import client as googleads_client
3035
from google.api_core import exceptions as google_exceptions
3136
from typing_extensions import override
@@ -39,6 +44,11 @@
3944
)
4045

4146

47+
class FieldPossibleValues(pydantic.BaseModel):
48+
name: str
49+
values: set[Any]
50+
51+
4252
class 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('_', '')
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright 2026 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+
"""Simulates response from Google Ads API based on a query."""
16+
17+
from __future__ import annotations
18+
19+
import logging
20+
from typing import Any
21+
22+
import garf_core
23+
from garf_core import simulator
24+
25+
from garf_google_ads import parsers, query_editor
26+
from garf_google_ads.api_clients import GoogleAdsApiClient
27+
28+
logger = logging.getLogger(__name__)
29+
30+
31+
class GoogleAdsApiSimulatorSpecification(simulator.SimulatorSpecification):
32+
"""Google Ads API specific simulator specification."""
33+
34+
35+
class GoogleAdsApiReportSimulator(simulator.ApiReportSimulator):
36+
def __init__(
37+
self,
38+
api_client: GoogleAdsApiClient | None = None,
39+
parser: garf_core.parsers.ProtoParser = parsers.GoogleAdsRowParser,
40+
query_spec: query_editor.GoogleAdsApiQuery = (
41+
query_editor.GoogleAdsApiQuery
42+
),
43+
**kwargs: str,
44+
) -> None:
45+
if not api_client:
46+
api_client = GoogleAdsApiClient(**kwargs)
47+
super().__init__(api_client, parser, query_spec)
48+
49+
def _generate_random_values(
50+
self,
51+
response_types: dict[str, Any],
52+
) -> dict[str, Any]:
53+
return self.api_client._get_google_ads_row()
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Copyright 2026 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 os
15+
16+
import pytest
17+
from garf_google_ads import simulator
18+
19+
20+
@pytest.mark.skipif(
21+
not os.getenv('GOOGLE_ADS_CONFIGURATION_FILE_PATH'),
22+
reason='Env variable GOOGLE_ADS_CONFIGURATION_FILE_PATH is not set',
23+
)
24+
class TestGoogleAdsApiReportSimulator:
25+
def test_simulate(self):
26+
fake_simulator = simulator.GoogleAdsApiReportSimulator()
27+
query_spec = 'SELECT campaign.id, metrics.clicks AS clicks FROM campaign'
28+
simulator_spec = simulator.GoogleAdsApiSimulatorSpecification()
29+
simulated_report = fake_simulator.simulate(query_spec, simulator_spec)
30+
assert len(simulated_report) == simulator_spec.n_rows

libs/community/google/youtube/youtube-data-api/garf_youtube_data_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@
2424
'YouTubeDataApiReportFetcher',
2525
]
2626

27-
__version__ = '0.0.12'
27+
__version__ = '0.0.13'

0 commit comments

Comments
 (0)