Skip to content

Commit 01b1c6d

Browse files
[dbm] feat: initial commit for Bid Manager API
1 parent f7712af commit 01b1c6d

File tree

12 files changed

+579
-0
lines changed

12 files changed

+579
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Run tests for garf-bid-manager
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
branches: [main]
7+
paths:
8+
- 'libs/community/google/bid-manager/garf_bid_manager/**'
9+
10+
env:
11+
UV_SYSTEM_PYTHON: 1
12+
13+
jobs:
14+
test:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v3
18+
- name: Set up Python 3.13
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.13"
22+
- name: Setup uv
23+
uses: astral-sh/setup-uv@v5
24+
with:
25+
version: "0.5.4"
26+
enable-cache: true
27+
- name: Install dependencies
28+
run: |
29+
uv pip install pytest
30+
- name: Test ${{ matrix.library }}
31+
run: |
32+
cd libs/community/google/bid-manager/
33+
uv pip install -e .
34+
pytest tests/unit
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# `garf` for Bid Manager API
2+
3+
[![PyPI](https://img.shields.io/pypi/v/garf-bid-manager?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/garf-bid-manager)
4+
[![Downloads PyPI](https://img.shields.io/pypi/dw/garf-bid-manager?logo=pypi)](https://pypi.org/project/garf-bid-manager/)
5+
6+
`garf-bid-manager` simplifies fetching data from Bid Manager API using SQL-like queries.
7+
8+
## Prerequisites
9+
10+
* [Bid Manager API](https://console.cloud.google.com/apis/library/analytics.googleapis.com) enabled.
11+
* [Credentials](https://developers.google.com/bid-manager/guides/get-started/generate-credentials) configured.
12+
13+
## Installation
14+
15+
`pip install garf-bid-manager`
16+
17+
## Usage
18+
19+
### Run as a library
20+
```
21+
import garf_bid_manager
22+
from garf_io import writer
23+
24+
# Fetch report
25+
query = """
26+
SELECT
27+
advertiser_name,
28+
metric_clicks AS clicks
29+
FROM standard
30+
WHERE advertiser = 1
31+
AND dataRange = LAST_7_DAYS
32+
"""
33+
fetched_report = (
34+
garf_bid_manager.BidManagerApiReportFetcher()
35+
.fetch(query, query=query)
36+
)
37+
38+
# Write report to console
39+
console_writer = writer.create_writer('console')
40+
console_writer.write(fetched_report, 'output')
41+
```
42+
43+
### Run via CLI
44+
45+
> Install `garf-executors` package to run queries via CLI (`pip install garf-executors`).
46+
47+
```
48+
garf <PATH_TO_QUERIES> --source bid-manager \
49+
--output <OUTPUT_TYPE> \
50+
--source.<SOURCE_PARAMETER=VALUE>
51+
```
52+
53+
where:
54+
55+
* `<PATH_TO_QUERIES>` - local or remove files containing queries
56+
* `<OUTPUT_TYPE>` - output supported by [`garf-io` library](../garf_io/README.md).
57+
* `<SOURCE_PARAMETER=VALUE` - key-value pairs to refine fetching, check [available source parameters](#available-source-parameters).
58+
59+
## Available source parameters
60+
61+
| name | values| comments |
62+
|----- | ----- | -------- |
63+
| `credentials_file` | File with Oauth or service account credentials | You can expose `credentials_file` as `GARF_BID_MANAGER_CREDENTIALS_FILE` ENV variable|
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
SELECT
2+
FILTER_ADVERTISER_NAME,
3+
FILTER_ADVERTISER,
4+
FILTER_ADVERTISER_CURRENCY,
5+
FILTER_INSERTION_ORDER_NAME,
6+
FILTER_INSERTION_ORDER,
7+
FILTER_LINE_ITEM_NAME,
8+
FILTER_LINE_ITEM,
9+
METRIC_IMPRESSIONS,
10+
METRIC_BILLABLE_IMPRESSIONS,
11+
METRIC_CLICKS,
12+
METRIC_CTR,
13+
METRIC_TOTAL_CONVERSIONS,
14+
METRIC_LAST_CLICKS,
15+
METRIC_LAST_IMPRESSIONS,
16+
METRIC_REVENUE_ADVERTISER,
17+
METRIC_MEDIA_COST_ADVERTISER,
18+
FROM STANDARD
19+
WHERE advertiser = {advertiser_id}
20+
AND dataRange = LAST_7_DAYS
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
SELECT
2+
youtube_ad_video_id AS video_id,
3+
youtube_ad_video AS video_name,
4+
metric_clicks AS clicks,
5+
FROM youtube
6+
WHERE advertiser = {advertiser_id}
7+
AND dataRange = LAST_7_DAYS
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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 logging
15+
16+
from garf_bid_manager.api_clients import BidManagerApiClient
17+
from garf_bid_manager.report_fetcher import BidManagerApiReportFetcher
18+
19+
__all__ = [
20+
'BidManagerApiClient',
21+
'BidManagerApiReportFetcher',
22+
]
23+
24+
__version__ = '0.0.1'
25+
26+
logging.getLogger('googleapiclient.discovery_cache').setLevel(logging.ERROR)
27+
logging.getLogger('google_auth_oauthlib.flow').setLevel(logging.ERROR)
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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 Bid Manager API."""
15+
16+
import logging
17+
import os
18+
import pathlib
19+
20+
import smart_open
21+
import tenacity
22+
from garf_core import api_clients
23+
from google.oauth2 import service_account
24+
from google_auth_oauthlib.flow import InstalledAppFlow
25+
from googleapiclient.discovery import build
26+
from typing_extensions import override
27+
28+
from garf_bid_manager import exceptions, query_editor
29+
30+
_API_URL = 'https://doubleclickbidmanager.googleapis.com/'
31+
_DEFAULT_API_SCOPES = ['https://www.googleapis.com/auth/doubleclickbidmanager']
32+
33+
_SERVICE_ACCOUNT_CREDENTIALS_FILE = str(pathlib.Path.home()) + 'dbm.json'
34+
35+
36+
class BidManagerApiClientError(exceptions.BidManagerApiError):
37+
"""Bid Manager API client specific error."""
38+
39+
40+
class BidManagerApiClient(api_clients.BaseClient):
41+
"""Responsible for connecting to Bid Manager API."""
42+
43+
def __init__(
44+
self,
45+
api_version: str = 'v2',
46+
credentials_file: str | pathlib.Path = os.getenv(
47+
'GARF_BID_MANAGER_CREDENTIALS_FILE', _SERVICE_ACCOUNT_CREDENTIALS_FILE
48+
),
49+
) -> None:
50+
"""Initializes BidManagerApiClient."""
51+
self.api_version = api_version
52+
self.credentials_file = credentials_file
53+
self._client = None
54+
self._credentials = None
55+
56+
@property
57+
def credentials(self):
58+
if not self._credentials:
59+
self._credentials = self._get_oauth_credentials()
60+
return self._credentials
61+
62+
@property
63+
def client(self):
64+
if self._client:
65+
return self._client
66+
return build(
67+
'doubleclickbidmanager',
68+
self.api_version,
69+
discoveryServiceUrl=(
70+
f'{_API_URL}/$discovery/rest?version={self.api_version}'
71+
),
72+
credentials=self.credentials,
73+
)
74+
75+
@override
76+
def get_response(
77+
self, request: query_editor.BidManagerApiQuery, **kwargs: str
78+
) -> api_clients.GarfApiResponse:
79+
query = _build_request(request)
80+
query_response = self.client.queries().create(body=query).execute()
81+
report_response = (
82+
self.client.queries()
83+
.run(queryId=query_response['queryId'], synchronous=False)
84+
.execute()
85+
)
86+
query_id = report_response['key']['queryId']
87+
report_id = report_response['key']['reportId']
88+
logging.info(
89+
'Query %s is running, report %s has been created and is '
90+
'currently being generated.',
91+
query_id,
92+
report_id,
93+
)
94+
95+
get_request = (
96+
self.client.queries()
97+
.reports()
98+
.get(
99+
queryId=report_response['key']['queryId'],
100+
reportId=report_response['key']['reportId'],
101+
)
102+
)
103+
104+
status = _check_if_report_is_done(get_request)
105+
106+
logging.debug(
107+
'Report %s generated successfully. Now downloading.', report_id
108+
)
109+
with smart_open.open(
110+
status['metadata']['googleCloudStoragePath'], 'r', encoding='utf-8'
111+
) as f:
112+
data = f.readlines()
113+
results = []
114+
for row in data[1:]:
115+
if row := row.strip():
116+
result = dict(zip(request.fields, row.split(',')))
117+
results.append(result)
118+
else:
119+
break
120+
return api_clients.GarfApiResponse(results=results)
121+
122+
def _get_service_account_credentials(self):
123+
if pathlib.Path(self.credentials_file).is_file():
124+
return service_account.Credentials.from_service_account_file(
125+
self.credentials_file, scopes=_DEFAULT_API_SCOPES
126+
)
127+
raise BidManagerApiClientError(
128+
'A service account key file could not be found at '
129+
f'{self.credentials_file}.'
130+
)
131+
132+
def _get_oauth_credentials(self):
133+
if pathlib.Path(self.credentials_file).is_file():
134+
return InstalledAppFlow.from_client_secrets_file(
135+
self.credentials_file, _DEFAULT_API_SCOPES
136+
).run_local_server(port=8088)
137+
raise BidManagerApiClientError(
138+
f'Credentials file could not be found at {self.credentials_file}.'
139+
)
140+
141+
142+
def _build_request(request: query_editor.BidManagerApiQuery):
143+
"""Builds Bid Manager API query object from BidManagerApiQuery."""
144+
metrics = []
145+
group_bys = []
146+
for field in request.fields:
147+
if field.startswith('METRIC'):
148+
metrics.append(field)
149+
elif field.startswith('FILTER'):
150+
group_bys.append(field)
151+
filters = []
152+
data_range = None
153+
for field in request.filters:
154+
name, operator, value = field.split()
155+
if name.startswith('dataRange'):
156+
data_range = value
157+
else:
158+
filters.append({'type': name, 'value': value})
159+
query = {
160+
'metadata': {
161+
'title': request.title or 'garf',
162+
'format': 'CSV',
163+
},
164+
'params': {
165+
'type': request.resource_name,
166+
'groupBys': group_bys,
167+
'filters': filters,
168+
},
169+
'schedule': {'frequency': 'ONE_TIME'},
170+
}
171+
if metrics:
172+
query['params']['metrics'] = metrics
173+
if data_range:
174+
query['metadata']['dataRange'] = {'range': data_range}
175+
return query
176+
177+
178+
@tenacity.retry(
179+
stop=tenacity.stop_after_attempt(100), wait=tenacity.wait_exponential()
180+
)
181+
def _check_if_report_is_done(get_request) -> bool:
182+
status = get_request.execute()
183+
state = status.get('metadata').get('status').get('state')
184+
if state != 'DONE':
185+
logging.debug(
186+
'Report %s it not ready, retrying...', status['key']['reportId']
187+
)
188+
raise Exception
189+
return status
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 BidManagerApiError(exceptions.GarfError):
19+
"""Base class for all library exceptions."""

0 commit comments

Comments
 (0)