├── .coveragerc
├── .gitignore
├── .travis.yml
├── LICENSE.md
├── README.md
├── adwords_reports
├── __init__.py
├── account.py
├── account_label.py
├── client.py
├── micro_amounts.py
└── report_definition.py
├── examples
├── README.md
├── account_hierarchy.py
└── download_reports.py
├── requirements.txt
├── setup.py
└── tests
├── __init__.py
├── test_account.py
├── test_account_label.py
├── test_client.py
├── test_googleads.yaml
├── test_micro_amounts.py
└── test_report_definition.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = True
3 | source = adwords_reports
4 |
5 | [report]
6 | exclude_lines =
7 | if self.debug:
8 | pragma: no cover
9 | raise NotImplementedError
10 | if __name__ == .__main__.:
11 | ignore_errors = True
12 | omit =
13 | tests/*
14 | setup.py
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | ### Python template
3 | # Byte-compiled / optimized / DLL files
4 | __pycache__/
5 | *.py[cod]
6 | *$py.class
7 |
8 | # C extensions
9 | *.so
10 |
11 | # Distribution / packaging
12 | .Python
13 | env/
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | *.egg-info/
27 | .installed.cfg
28 | *.egg
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | .hypothesis/
50 |
51 | # Translations
52 | *.mo
53 | *.pot
54 |
55 | # Django stuff:
56 | *.log
57 | local_settings.py
58 |
59 | # Flask stuff:
60 | instance/
61 | .webassets-cache
62 |
63 | # Scrapy stuff:
64 | .scrapy
65 |
66 | # Sphinx documentation
67 | docs/_build/
68 |
69 | # PyBuilder
70 | target/
71 |
72 | # Jupyter Notebook
73 | .ipynb_checkpoints
74 |
75 | # pyenv
76 | .python-version
77 |
78 | # celery beat schedule file
79 | celerybeat-schedule
80 |
81 | # SageMath parsed files
82 | *.sage.py
83 |
84 | # dotenv
85 | .env
86 |
87 | # virtualenv
88 | .venv
89 | venv/
90 | ENV/
91 |
92 | # Spyder project settings
93 | .spyderproject
94 | .spyproject
95 |
96 | # Rope project settings
97 | .ropeproject
98 |
99 | # mkdocs documentation
100 | /site
101 |
102 | # mypy
103 | .mypy_cache/
104 |
105 | # custom stuff
106 | .idea
107 | .DS_Store
108 | .pytest_cache
109 | try_stuff.py
110 | /googleads.yaml
111 | pip_distribution_instructions.md
112 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "2.7"
4 | - "3.5"
5 | - "3.6"
6 | install:
7 | - pip install pytest-cov
8 | - pip install codecov
9 | - pip install -r requirements.txt
10 | script:
11 | - py.test --cov=./
12 | after_success:
13 | - codecov --token=fe9862ba-9505-43b5-af77-b82817268852
14 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright 2012 The Obvious Corporation and contributors.
2 |
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 |
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 |
16 | ```
17 | -------------------------------------------------------------------------
18 | Apache License
19 | Version 2.0, January 2004
20 | http://www.apache.org/licenses/
21 |
22 |
23 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
24 |
25 | 1. Definitions.
26 |
27 | "License" shall mean the terms and conditions for use, reproduction,
28 | and distribution as defined by Sections 1 through 9 of this document.
29 |
30 | "Licensor" shall mean the copyright owner or entity authorized by
31 | the copyright owner that is granting the License.
32 |
33 | "Legal Entity" shall mean the union of the acting entity and all
34 | other entities that control, are controlled by, or are under common
35 | control with that entity. For the purposes of this definition,
36 | "control" means (i) the power, direct or indirect, to cause the
37 | direction or management of such entity, whether by contract or
38 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
39 | outstanding shares, or (iii) beneficial ownership of such entity.
40 |
41 | "You" (or "Your") shall mean an individual or Legal Entity
42 | exercising permissions granted by this License.
43 |
44 | "Source" form shall mean the preferred form for making modifications,
45 | including but not limited to software source code, documentation
46 | source, and configuration files.
47 |
48 | "Object" form shall mean any form resulting from mechanical
49 | transformation or translation of a Source form, including but
50 | not limited to compiled object code, generated documentation,
51 | and conversions to other media types.
52 |
53 | "Work" shall mean the work of authorship, whether in Source or
54 | Object form, made available under the License, as indicated by a
55 | copyright notice that is included in or attached to the work
56 | (an example is provided in the Appendix below).
57 |
58 | "Derivative Works" shall mean any work, whether in Source or Object
59 | form, that is based on (or derived from) the Work and for which the
60 | editorial revisions, annotations, elaborations, or other modifications
61 | represent, as a whole, an original work of authorship. For the purposes
62 | of this License, Derivative Works shall not include works that remain
63 | separable from, or merely link (or bind by name) to the interfaces of,
64 | the Work and Derivative Works thereof.
65 |
66 | "Contribution" shall mean any work of authorship, including
67 | the original version of the Work and any modifications or additions
68 | to that Work or Derivative Works thereof, that is intentionally
69 | submitted to Licensor for inclusion in the Work by the copyright owner
70 | or by an individual or Legal Entity authorized to submit on behalf of
71 | the copyright owner. For the purposes of this definition, "submitted"
72 | means any form of electronic, verbal, or written communication sent
73 | to the Licensor or its representatives, including but not limited to
74 | communication on electronic mailing lists, source code control systems,
75 | and issue tracking systems that are managed by, or on behalf of, the
76 | Licensor for the purpose of discussing and improving the Work, but
77 | excluding communication that is conspicuously marked or otherwise
78 | designated in writing by the copyright owner as "Not a Contribution."
79 |
80 | "Contributor" shall mean Licensor and any individual or Legal Entity
81 | on behalf of whom a Contribution has been received by Licensor and
82 | subsequently incorporated within the Work.
83 |
84 | 2. Grant of Copyright License. Subject to the terms and conditions of
85 | this License, each Contributor hereby grants to You a perpetual,
86 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
87 | copyright license to reproduce, prepare Derivative Works of,
88 | publicly display, publicly perform, sublicense, and distribute the
89 | Work and such Derivative Works in Source or Object form.
90 |
91 | 3. Grant of Patent License. Subject to the terms and conditions of
92 | this License, each Contributor hereby grants to You a perpetual,
93 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
94 | (except as stated in this section) patent license to make, have made,
95 | use, offer to sell, sell, import, and otherwise transfer the Work,
96 | where such license applies only to those patent claims licensable
97 | by such Contributor that are necessarily infringed by their
98 | Contribution(s) alone or by combination of their Contribution(s)
99 | with the Work to which such Contribution(s) was submitted. If You
100 | institute patent litigation against any entity (including a
101 | cross-claim or counterclaim in a lawsuit) alleging that the Work
102 | or a Contribution incorporated within the Work constitutes direct
103 | or contributory patent infringement, then any patent licenses
104 | granted to You under this License for that Work shall terminate
105 | as of the date such litigation is filed.
106 |
107 | 4. Redistribution. You may reproduce and distribute copies of the
108 | Work or Derivative Works thereof in any medium, with or without
109 | modifications, and in Source or Object form, provided that You
110 | meet the following conditions:
111 |
112 | (a) You must give any other recipients of the Work or
113 | Derivative Works a copy of this License; and
114 |
115 | (b) You must cause any modified files to carry prominent notices
116 | stating that You changed the files; and
117 |
118 | (c) You must retain, in the Source form of any Derivative Works
119 | that You distribute, all copyright, patent, trademark, and
120 | attribution notices from the Source form of the Work,
121 | excluding those notices that do not pertain to any part of
122 | the Derivative Works; and
123 |
124 | (d) If the Work includes a "NOTICE" text file as part of its
125 | distribution, then any Derivative Works that You distribute must
126 | include a readable copy of the attribution notices contained
127 | within such NOTICE file, excluding those notices that do not
128 | pertain to any part of the Derivative Works, in at least one
129 | of the following places: within a NOTICE text file distributed
130 | as part of the Derivative Works; within the Source form or
131 | documentation, if provided along with the Derivative Works; or,
132 | within a display generated by the Derivative Works, if and
133 | wherever such third-party notices normally appear. The contents
134 | of the NOTICE file are for informational purposes only and
135 | do not modify the License. You may add Your own attribution
136 | notices within Derivative Works that You distribute, alongside
137 | or as an addendum to the NOTICE text from the Work, provided
138 | that such additional attribution notices cannot be construed
139 | as modifying the License.
140 |
141 | You may add Your own copyright statement to Your modifications and
142 | may provide additional or different license terms and conditions
143 | for use, reproduction, or distribution of Your modifications, or
144 | for any such Derivative Works as a whole, provided Your use,
145 | reproduction, and distribution of the Work otherwise complies with
146 | the conditions stated in this License.
147 |
148 | 5. Submission of Contributions. Unless You explicitly state otherwise,
149 | any Contribution intentionally submitted for inclusion in the Work
150 | by You to the Licensor shall be under the terms and conditions of
151 | this License, without any additional terms or conditions.
152 | Notwithstanding the above, nothing herein shall supersede or modify
153 | the terms of any separate license agreement you may have executed
154 | with Licensor regarding such Contributions.
155 |
156 | 6. Trademarks. This License does not grant permission to use the trade
157 | names, trademarks, service marks, or product names of the Licensor,
158 | except as required for reasonable and customary use in describing the
159 | origin of the Work and reproducing the content of the NOTICE file.
160 |
161 | 7. Disclaimer of Warranty. Unless required by applicable law or
162 | agreed to in writing, Licensor provides the Work (and each
163 | Contributor provides its Contributions) on an "AS IS" BASIS,
164 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
165 | implied, including, without limitation, any warranties or conditions
166 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
167 | PARTICULAR PURPOSE. You are solely responsible for determining the
168 | appropriateness of using or redistributing the Work and assume any
169 | risks associated with Your exercise of permissions under this License.
170 |
171 | 8. Limitation of Liability. In no event and under no legal theory,
172 | whether in tort (including negligence), contract, or otherwise,
173 | unless required by applicable law (such as deliberate and grossly
174 | negligent acts) or agreed to in writing, shall any Contributor be
175 | liable to You for damages, including any direct, indirect, special,
176 | incidental, or consequential damages of any character arising as a
177 | result of this License or out of the use or inability to use the
178 | Work (including but not limited to damages for loss of goodwill,
179 | work stoppage, computer failure or malfunction, or any and all
180 | other commercial damages or losses), even if such Contributor
181 | has been advised of the possibility of such damages.
182 |
183 | 9. Accepting Warranty or Additional Liability. While redistributing
184 | the Work or Derivative Works thereof, You may choose to offer,
185 | and charge a fee for, acceptance of support, warranty, indemnity,
186 | or other liability obligations and/or rights consistent with this
187 | License. However, in accepting such obligations, You may act only
188 | on Your own behalf and on Your sole responsibility, not on behalf
189 | of any other Contributor, and only if You agree to indemnify,
190 | defend, and hold each Contributor harmless for any liability
191 | incurred by, or claims asserted against, such Contributor by reason
192 | of your accepting any such warranty or additional liability.
193 |
194 | END OF TERMS AND CONDITIONS
195 | ```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AdWords Reports
2 | [](https://travis-ci.org/SaturnFromTitan/adwords_reports)
3 | [](https://codecov.io/gh/SaturnFromTitan/adwords_reports)
4 |
5 | ## Description
6 | `adwords_reports` is a library to quickly and easily receive cross-account reports from Google AdWords.
7 |
8 | ## Getting started
9 | 1. You can install `adwords_reports` using `pip`:
10 |
11 | `$ pip install adwords_reports`
12 |
13 | or using `git`:
14 |
15 | `$ pip install git+https://github.com/SaturnFromTitan/adwords_reports.git`
16 |
17 | 1. Get access to AdWords API and cache all credential information in a .yaml file.
18 | * [Google's tutorial for authenticating with their API](https://www.youtube.com/watch?v=yaDlZMfYWkg&list=PLOU2XLYxmsII2PCvm73bwxRCu2g_dyp67&index=2)
19 | * [Google's client library for python](https://github.com/googleads/googleads-python-lib)
20 | * You'll need to pass the path to this file to the Client for authentication:
21 |
22 | ```
23 | from adwords_reports import Client
24 |
25 | credentials_path = "adwords_credentials.yaml"
26 | client = Client(credentials_path)
27 | ```
28 | 1. Try to run the code in *examples/account_hierarchy.py* to see if everything is working.
29 | * Have a look at the other examples as well. This is the best place to get you up to speed.
30 | 1. For more detailed information on report types, available fields etc. please refer to the [Google's
31 | official documentation](https://developers.google.com/adwords/api/docs/appendix/reports).
32 |
33 | ## Technology
34 | * The library currently supports Python 2.7 and 3.5+
35 | * All reports are returned as [pandas](https://github.com/pandas-dev/pandas) DataFrames - the standard tool to analyse data in Python
36 | * Tests are written with [pytest](https://github.com/pytest-dev/pytest).
37 |
38 | ## Who do I talk to?
39 | The project was launched and is currently maintained by me, [Martin Winkel](https://www.linkedin.com/in/martin-winkel-90678977).
40 |
41 | ## Contribute
42 | Contributors are very welcome! Just file an issue or pull request.
43 |
--------------------------------------------------------------------------------
/adwords_reports/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import os
4 | import logging
5 |
6 | # as described here: https://docs.python.org/2/howto/logging.html#configuring-logging-for-a-library
7 | logger = logging.getLogger("adwords_reports")
8 | logger.addHandler(logging.NullHandler())
9 |
10 | __version__ = "1.2.2"
11 | app_dir = os.path.dirname(__file__)
12 | base_dir = os.path.dirname(app_dir)
13 |
14 | from adwords_reports.client import Client
15 | from adwords_reports.report_definition import ReportDefinition
16 |
--------------------------------------------------------------------------------
/adwords_reports/account.py:
--------------------------------------------------------------------------------
1 | import io
2 | import pandas as pd
3 | from retrying import retry
4 |
5 | from adwords_reports import logger
6 | from adwords_reports.account_label import AccountLabel
7 |
8 |
9 | class Account:
10 | SELECTOR = {
11 | "fields": ["Name", "CustomerId", "CurrencyCode", "DateTimeZone"],
12 | "predicates": [{
13 | "field": "CanManageClients",
14 | "operator": "EQUALS",
15 | "values": "FALSE"
16 | }],
17 | "ordering": [{
18 | "field": "Name",
19 | "sortOrder": "ASCENDING"
20 | }]
21 | }
22 |
23 | def __init__(self, client, account_id, name, currency, time_zone, labels):
24 | self.client = client
25 | self.id = account_id
26 | self.name = name
27 | self.currency = currency
28 | self.time_zone = time_zone
29 | self.labels = labels
30 |
31 | @classmethod
32 | def from_ad_account(cls, client, ad_account):
33 | labels = cls.parse_labels(ad_account)
34 | return cls(client=client, account_id=ad_account.customerId, name=ad_account.name,
35 | currency=ad_account.currencyCode, time_zone=ad_account.dateTimeZone,
36 | labels=labels)
37 |
38 | def download(self, report_definition, zero_impressions):
39 | """ Downloads a report from the API
40 | :param report_definition: ReportDefinition
41 | :param zero_impressions: bool
42 | :return: DataFrame
43 | """
44 | json_report_definition = report_definition.raw
45 | header = json_report_definition["selector"]["fields"]
46 |
47 | response = self._download(json_report_definition, zero_impressions)
48 | data = io.StringIO(response)
49 | return pd.read_csv(data, names=header)
50 |
51 | @retry(stop_max_attempt_number=3, wait_random_min=5000, wait_random_max=10000)
52 | def _download(self, json_report_definition, zero_impressions):
53 | logger.info("Downloading report.")
54 | downloader = self.client.downloader
55 | response = downloader.DownloadReportAsString(
56 | json_report_definition, skip_report_header=True, skip_column_header=True,
57 | skip_report_summary=True, include_zero_impressions=zero_impressions)
58 | return response
59 |
60 | @staticmethod
61 | def parse_labels(ad_account):
62 | if "accountLabels" in ad_account:
63 | return [AccountLabel.from_ad_account_label(ad_label)
64 | for ad_label in ad_account["accountLabels"]]
65 | else:
66 | return list()
67 |
68 | def __repr__(self):
69 | return "\nAccountName: {name} (ID: {id})".format(name=self.name, id=self.id)
70 |
--------------------------------------------------------------------------------
/adwords_reports/account_label.py:
--------------------------------------------------------------------------------
1 | class AccountLabel:
2 | def __init__(self, name, label_id):
3 | self.name = name
4 | self.id = label_id
5 |
6 | @classmethod
7 | def from_ad_account_label(cls, adwords_acc_label):
8 | return cls(name=adwords_acc_label.name, label_id=adwords_acc_label.id)
9 |
10 | def __repr__(self):
11 | return "{name} ({id})".format(name=self.name, id=self.id)
12 |
--------------------------------------------------------------------------------
/adwords_reports/client.py:
--------------------------------------------------------------------------------
1 | from retrying import retry
2 | from googleads import adwords
3 |
4 | from adwords_reports import logger
5 | from adwords_reports.account import Account
6 |
7 |
8 | DEFAULT_API_VERSION = "v201802"
9 |
10 |
11 | class Client:
12 | """ AdWords service class that handles interactions with AdWords API and provides a top-level interface for this api.
13 | Most important functionality:
14 | - Initiate API connection using credentials
15 | - Generator for accounts matching the account selector in project _config
16 | - Download reports
17 | """
18 | def __init__(self, credentials_path, api_version=DEFAULT_API_VERSION):
19 | # caution, don't change the order of these attributes
20 | self._client = self._authenticate(credentials_path)
21 | self.top_level_account_id = self._client.client_customer_id
22 | self.api_version = api_version
23 |
24 | self.downloader = self._init_report_downloader()
25 |
26 | def accounts(self):
27 | """
28 | :return: generator with Account objects sorted by name
29 | """
30 | logger.info("Getting accounts.")
31 | ad_accounts = self._get_entries(Account.SELECTOR, service="ManagedCustomerService")
32 |
33 | for ad_account in ad_accounts:
34 | account = Account.from_ad_account(client=self, ad_account=ad_account)
35 | self.select(account_id=account.id)
36 | yield account
37 | self.reset_selection()
38 |
39 | def select(self, account_id):
40 | """ starts a new session with the scope of this account.
41 | :param account_id: str or int
42 | """
43 | self._client.SetClientCustomerId(account_id)
44 |
45 | def reset_selection(self):
46 | """ resets scope to the top level account/mcc used in the .yaml file """
47 | self.select(self.top_level_account_id)
48 |
49 | def _get_entries(self, selector, service):
50 | """
51 | :param selector: nested dict that describes what is requested
52 | :param service: str, identifying adwords service that is responsible
53 | :return: adwords page object
54 | """
55 | page = self._get_page(selector, service)
56 | if "entries" not in page:
57 | raise LookupError("Nothing matches the selector.")
58 | return page["entries"]
59 |
60 | @retry(stop_max_attempt_number=3, wait_random_min=5000, wait_random_max=10000)
61 | def _get_page(self, selector, service):
62 | """
63 | :param selector: nested dict that describes what is requested
64 | :param service: str, identifying adwords service that is responsible
65 | :return: adwords page object
66 | """
67 | service_object = self._init_service(service)
68 | return service_object.get(selector)
69 |
70 | @retry(stop_max_attempt_number=3, wait_random_min=5000, wait_random_max=10000)
71 | def _init_service(self, service_name):
72 | logger.info("Initiating {}".format(service_name))
73 | return self._client.GetService(service_name, version=self.api_version)
74 |
75 | @retry(stop_max_attempt_number=3, wait_random_min=5000, wait_random_max=10000)
76 | def _init_report_downloader(self):
77 | logger.info("Initiating ReportDownloader.")
78 | return self._client.GetReportDownloader(version=self.api_version)
79 |
80 | @retry(stop_max_attempt_number=3, wait_random_min=5000, wait_random_max=10000)
81 | def _authenticate(self, credentials_path):
82 | logger.info("Initiating Client.")
83 | return adwords.AdWordsClient.LoadFromStorage(credentials_path)
84 |
--------------------------------------------------------------------------------
/adwords_reports/micro_amounts.py:
--------------------------------------------------------------------------------
1 | MICRO_FACTOR = 10**6
2 |
3 |
4 | def reg_and_micro(number):
5 | """ takes a bid amount and identifies if it's micro or regular format. Then returns both formats.
6 | CAUTION: There might be currencies where this doesn't make sense
7 | """
8 | is_micro = number >= 0.01 * MICRO_FACTOR # >= 10k must be micro
9 | if is_micro:
10 | regular = micro_to_reg(number)
11 | micro = reg_to_micro(regular) # for formatting
12 | else:
13 | micro = reg_to_micro(number)
14 | regular = micro_to_reg(micro) # for formatting
15 | return regular, micro
16 |
17 |
18 | def reg_to_micro(number):
19 | """ Convert a number to a micro amount:
20 | - times one million
21 | - and rounded to multiples of 10k """
22 | assert isinstance(number, (float, int))
23 | return int(round(float(number) * MICRO_FACTOR, -4))
24 |
25 |
26 | def micro_to_reg(number):
27 | """ Convert micro amount to regular euro amount
28 | - divided by one million
29 | - and rounded to 2 fractional digits """
30 | assert isinstance(number, (float, int))
31 | return round(float(number) / MICRO_FACTOR, 2)
32 |
--------------------------------------------------------------------------------
/adwords_reports/report_definition.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 |
4 | class ReportDefinition:
5 | def __init__(self, report_type, fields, predicates=None, last_days=None, date_from=None, date_to=None):
6 | """ Create report definition as needed in api call from meta information
7 | :param report_type: str, https://developers.google.com/adwords/api/docs/appendix/reports
8 | :param fields: list of str
9 | :param predicates: list of dicts
10 | :param last_days: int, date_to = yesterday and date_from = today - days_ago
11 | :param date_from: str, format YYYYMMDD or YYYY-MM-DD
12 | :param date_to: str, format YYYYMMDD or YYYY-MM-DD
13 | """
14 | self.report_type = report_type
15 | self.fields = fields
16 | self.predicates = predicates
17 |
18 | self.date_min = date_from
19 | self.date_max = date_to
20 | self.last_days = last_days
21 |
22 | self.raw = self._as_dict()
23 |
24 | def _as_dict(self):
25 | self._determine_dates()
26 |
27 | report_def = {
28 | "reportName": "api_report",
29 | "dateRangeType": "CUSTOM_DATE",
30 | "reportType": self.report_type,
31 | "downloadFormat": "CSV",
32 | "selector": {
33 | "fields": self.fields,
34 | "dateRange": {
35 | "min": self.date_min,
36 | "max": self.date_max
37 | }
38 | }
39 | }
40 | if self.predicates is not None:
41 | report_def["selector"]["predicates"] = self.predicates
42 | return report_def
43 |
44 | def _determine_dates(self):
45 | self._validate_inputs()
46 |
47 | if self.last_days is not None:
48 | self._calculate_dates_from_relative()
49 | self._standardize_date_format()
50 |
51 | def _validate_inputs(self):
52 | dates_are_relative = self.last_days is not None
53 | dates_are_absolute = (self.date_min is not None) or (self.date_max is not None)
54 |
55 | assert dates_are_relative or dates_are_absolute, "No time range specified."
56 | assert not (dates_are_relative and dates_are_absolute), "Either absolute dates or relative dates."
57 |
58 | def _calculate_dates_from_relative(self):
59 | today = datetime.date.today()
60 | self.date_max = (today - datetime.timedelta(1)).isoformat()
61 | self.date_min = (today - datetime.timedelta(self.last_days)).isoformat()
62 | self.last_days = None
63 |
64 | def _standardize_date_format(self):
65 | self.date_min = self.date_min.replace("-", "")
66 | self.date_max = self.date_max.replace("-", "")
67 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Examples
2 | Here you find a few code snippets showcasing some common use cases.
3 | Let me know if you have ideas, questions or suggestions.
4 |
--------------------------------------------------------------------------------
/examples/account_hierarchy.py:
--------------------------------------------------------------------------------
1 | from adwords_reports import Client
2 |
3 |
4 | def account_hierarchy(credentials):
5 | """
6 | This script will loop over your accounts and print out their names.
7 | :param credentials: str, path to your adwords credentials file
8 | """
9 | # init connection to adwords API
10 | client = Client(credentials)
11 |
12 | # before the method is returning an account it 'selects' it,
13 | # i.e. it creates a new session with the scope of this account.
14 | for account in client.accounts():
15 | print(account)
16 |
17 |
18 | if __name__ == "__main__":
19 | credentials_path = "googleads.yaml"
20 | account_hierarchy(credentials_path)
21 |
--------------------------------------------------------------------------------
/examples/download_reports.py:
--------------------------------------------------------------------------------
1 | from adwords_reports import Client, ReportDefinition
2 |
3 |
4 | def download_reports(credentials, report_definition):
5 | """
6 | This script demonstrates the easy interface of accessing reports per account.
7 | :param credentials: str, path to your adwords credentials file
8 | :param report_definition: ReportDefinition
9 | """
10 | # init connection to adwords API
11 | adwords_service = Client(credentials)
12 |
13 | # before the method is returning an account it 'selects' it,
14 | # i.e. it creates a new session with the scope of this account.
15 | # there's no other way to download reports in AdWords.
16 | for account in adwords_service.accounts():
17 | print(account)
18 |
19 | report = account.download(report_definition, zero_impressions=True)
20 | print(report) # pandas DataFrame
21 |
22 | # you may now
23 | # - stack reports of all your accounts
24 | # - or save them to a csv
25 | # - or push them to a database
26 | # - ...
27 |
28 |
29 | if __name__ == "__main__":
30 | credentials_path = "googleads.yaml"
31 |
32 | report_def = ReportDefinition(
33 | report_type="KEYWORDS_PERFORMANCE_REPORT",
34 | fields=[
35 | "AdGroupId", "Id", "Criteria", "KeywordMatchType", "CpcBid",
36 | "Impressions", "Clicks", "Conversions", "Cost"
37 | ],
38 | predicates=[{
39 | "field": "CampaignName",
40 | "operator": "DOES_NOT_CONTAIN_IGNORE_CASE",
41 | "values": "test"
42 | }],
43 | last_days=7
44 | )
45 | download_reports(credentials_path, report_def)
46 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # general usage
2 | googleads==12.0.0
3 | pandas==0.23.0
4 | retrying==1.3.3
5 |
6 | # testing
7 | pytest==3.5.1
8 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | import io
3 | import re
4 | import setuptools
5 |
6 |
7 | # Read version from __init__.py file of project
8 | # Taken from https://packaging.python.org/guides/single-sourcing-package-version/
9 | def read(*names, **kwargs):
10 | file_path = os.path.dirname(__file__)
11 | with io.open(
12 | os.path.join(file_path, *names),
13 | encoding=kwargs.get("encoding", "utf8")) as fp:
14 | return fp.read()
15 |
16 |
17 | def find_version(*file_paths):
18 | version_file = read(*file_paths)
19 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
20 | if version_match:
21 | return version_match.group(1)
22 | raise RuntimeError("Unable to find version string.")
23 |
24 |
25 | PACKAGES = [
26 | "adwords_reports"
27 | ]
28 |
29 | DEPENDENCIES = [
30 | "googleads",
31 | "pandas",
32 | "retrying"
33 | ]
34 |
35 | CLASSIFIERS = [
36 | "Intended Audience :: Developers",
37 | "Programming Language :: Python :: 2.7",
38 | "Programming Language :: Python :: 3.5",
39 | "Programming Language :: Python :: 3.6"
40 | ]
41 | KEYWORDS = [
42 | "data",
43 | "analytics",
44 | "api",
45 | "wrapper",
46 | "google",
47 | "adwords",
48 | "reports",
49 | "reporting"
50 | ]
51 |
52 | setuptools.setup(
53 | name="adwords_reports",
54 | description="Pythonic wrapper of the Google AdWords API for easy reporting.",
55 | keywords=" ".join(KEYWORDS),
56 | author="Martin Winkel",
57 | author_email="martin.winkel.pps@gmail.com",
58 | url="https://github.com/SaturnFromTitan/adwords_reports",
59 | version=find_version("adwords_reports", "__init__.py"),
60 | packages=PACKAGES,
61 | install_requires=DEPENDENCIES,
62 | classifiers=CLASSIFIERS,
63 | license="Apache License 2.0"
64 | )
65 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pytest
3 |
4 | from adwords_reports.client import Client
5 | from adwords_reports.account_label import AccountLabel
6 |
7 | test_dir = os.path.dirname(__file__)
8 |
9 |
10 | @pytest.fixture()
11 | def fix_client():
12 | test_credentials = os.path.join(test_dir, "test_googleads.yaml")
13 | return Client(test_credentials)
14 |
15 |
16 | @pytest.fixture()
17 | def fix_account(fix_client):
18 | return list(fix_client.accounts())[0]
19 |
20 |
21 | @pytest.fixture()
22 | def fix_adwords_account_service(fix_client):
23 | service = fix_client._init_service("ManagedCustomerService")
24 | return service.suds_client
25 |
26 |
27 | @pytest.fixture()
28 | def fix_adwords_account_label(fix_adwords_account_service):
29 | label = fix_adwords_account_service.factory.create("AccountLabel")
30 | label.name = "unused"
31 | label.id = 123
32 | return label
33 |
34 |
35 | @pytest.fixture()
36 | def fix_adwords_account(fix_adwords_account_service, fix_adwords_account_label):
37 | ad_account = fix_adwords_account_service.factory.create("ManagedCustomer")
38 | ad_account.name = "Test1"
39 | ad_account.customerId = "519-085-5164"
40 | ad_account.currencyCode = "CAD"
41 | ad_account.dateTimeZone = "America/Vancouver"
42 | ad_account.canManageClients = False
43 | ad_account.testAccount = False
44 | ad_account.accountLabels = [
45 | fix_adwords_account_label
46 | ]
47 | return ad_account
48 |
49 |
50 | @pytest.fixture()
51 | def fix_account_label(fix_adwords_account_label):
52 | return AccountLabel.from_ad_account_label(fix_adwords_account_label)
53 |
54 |
55 | @pytest.fixture()
56 | def fix_report_definition():
57 | from adwords_reports.report_definition import ReportDefinition
58 | return ReportDefinition(report_type="KEYWORDS_PERFORMANCE_REPORT", fields=["Criteria"], last_days=7)
59 |
--------------------------------------------------------------------------------
/tests/test_account.py:
--------------------------------------------------------------------------------
1 | import pandas as pd
2 |
3 | from tests import (fix_client, fix_account, fix_report_definition, fix_account_label,
4 | fix_adwords_account_service, fix_adwords_account_label, fix_adwords_account) # is used
5 |
6 |
7 | def test_account_selector():
8 | from adwords_reports.account import Account
9 |
10 | assert Account.SELECTOR == {
11 | "fields": ["Name", "CustomerId", "CurrencyCode", "DateTimeZone"],
12 | "predicates": [{
13 | "field": "CanManageClients",
14 | "operator": "EQUALS",
15 | "values": "FALSE"
16 | }],
17 | "ordering": [{
18 | "field": "Name",
19 | "sortOrder": "ASCENDING"
20 | }]
21 | }
22 |
23 |
24 | def test_init(fix_client):
25 | from adwords_reports.account import Account
26 |
27 | assert Account(
28 | client=fix_client,
29 | account_id="519-085-5164",
30 | name="Dont touch - !ImportantForTests!",
31 | currency="CAD",
32 | time_zone="America/Vancouver",
33 | labels=list()
34 | )
35 |
36 |
37 | def test_from_ad_account(fix_client, fix_adwords_account):
38 | from adwords_reports.account import Account
39 | from adwords_reports.account_label import AccountLabel
40 |
41 | # test initiation from native adwords account
42 | account = Account.from_ad_account(fix_client, fix_adwords_account)
43 | assert account.name == "Test1"
44 | assert account.id == "519-085-5164"
45 | assert account.currency == "CAD"
46 | assert account.time_zone == "America/Vancouver"
47 | assert isinstance(account.labels, list)
48 | assert isinstance(account.labels[0], AccountLabel)
49 |
50 |
51 | def test_download_report_impressions(fix_account, fix_report_definition):
52 | # impression keywords (empty df since test account can't be served)
53 |
54 | report = fix_account.download(fix_report_definition, zero_impressions=False)
55 | expected_result = pd.DataFrame(columns=["Criteria"])
56 | assert report.equals(expected_result)
57 |
58 |
59 | def test_download_report_zero_impressions(fix_account, fix_report_definition):
60 | report = fix_account.download(fix_report_definition, zero_impressions=True)
61 | expected_result = pd.DataFrame([["test_kw_1"]], columns=["Criteria"])
62 | assert report.equals(expected_result)
63 |
64 |
65 | def test_repr(fix_account):
66 | assert str(fix_account) == "\nAccountName: Dont touch - !ImportantForTests! (ID: 5190855164)"
67 |
--------------------------------------------------------------------------------
/tests/test_account_label.py:
--------------------------------------------------------------------------------
1 | from tests import fix_client, fix_account_label, fix_adwords_account_service, fix_adwords_account_label # is used
2 |
3 |
4 | def test_init(fix_account_label):
5 | assert fix_account_label.name == "unused"
6 | assert fix_account_label.id == 123
7 |
8 |
9 | def test_from_adwords_account_label(fix_adwords_account_label):
10 | from adwords_reports.account_label import AccountLabel
11 |
12 | label = AccountLabel.from_ad_account_label(fix_adwords_account_label)
13 | assert label.name == "unused"
14 | assert label.id == 123
15 |
16 |
17 | def test_repr(fix_account_label):
18 | assert str(fix_account_label) == "unused (123)"
19 |
--------------------------------------------------------------------------------
/tests/test_client.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pytest
3 |
4 | from adwords_reports.client import Client
5 |
6 | from tests import test_dir
7 | from tests import fix_client # is used
8 |
9 |
10 | def test_default_api_version():
11 | from adwords_reports.client import DEFAULT_API_VERSION
12 | assert DEFAULT_API_VERSION == "v201802"
13 |
14 |
15 | def test_init():
16 | test_credentials = os.path.join(test_dir, "test_googleads.yaml")
17 | assert Client(test_credentials)
18 |
19 |
20 | def test_init_service(fix_client):
21 | import googleads
22 |
23 | account_service = fix_client._init_service("ManagedCustomerService")
24 | assert isinstance(account_service, googleads.common.SudsServiceProxy)
25 |
26 |
27 | def test_init_report_downloader(fix_client):
28 | import googleads
29 |
30 | report_downloader = fix_client._init_report_downloader()
31 | assert isinstance(report_downloader, googleads.adwords.ReportDownloader)
32 |
33 |
34 | def test_get_entries_results(fix_client):
35 | selector = {
36 | "fields": ["Name", "CustomerId", "CurrencyCode", "DateTimeZone"]
37 | }
38 | result = fix_client._get_entries(selector, "ManagedCustomerService")
39 | assert result
40 | assert isinstance(result, list)
41 |
42 |
43 | def test_get_entries_no_results(fix_client):
44 | selector = {
45 | "fields": ["Name", "CustomerId", "CurrencyCode", "DateTimeZone"],
46 | "predicates": [{
47 | "field": "Name",
48 | "operator": "EQUALS",
49 | "values": "i_do_not_exist"
50 | }]
51 | }
52 | with pytest.raises(LookupError):
53 | fix_client._get_entries(selector, "ManagedCustomerService")
54 |
55 |
56 | def test_select(fix_client):
57 | assert fix_client._client.client_customer_id == "519-085-5164"
58 | fix_client.select("873-154-8394")
59 | assert fix_client._client.client_customer_id == "873-154-8394"
60 |
61 |
62 | def test_reset_selection(fix_client):
63 | assert fix_client._client.client_customer_id == "519-085-5164"
64 | fix_client.select("873-154-8394")
65 | assert fix_client._client.client_customer_id == "873-154-8394"
66 | fix_client.reset_selection()
67 | assert fix_client._client.client_customer_id == "519-085-5164"
68 |
69 |
70 | def test_account_iterator(fix_client):
71 | from adwords_reports.account import Account
72 |
73 | for account in fix_client.accounts():
74 | assert isinstance(account, Account)
75 | assert account.name == "Dont touch - !ImportantForTests!"
76 |
--------------------------------------------------------------------------------
/tests/test_googleads.yaml:
--------------------------------------------------------------------------------
1 | # AdWordsClient configurations
2 | adwords:
3 | #############################################################################
4 | # Required Fields #
5 | #############################################################################
6 | developer_token: "i8qwojq897H6MyfcfgoHCA"
7 | #############################################################################
8 | # Optional Fields #
9 | #############################################################################
10 | client_customer_id: "519-085-5164"
11 | # user_agent: INSERT_USER_AGENT_HERE
12 | # partial_failure: True
13 | # validate_only: True
14 | #############################################################################
15 | # OAuth2 Configuration #
16 | # Below you may provide credentials for either the installed application or #
17 | # service account flows. Remove or comment the lines for the flow you're #
18 | # not using. #
19 | #############################################################################
20 | # The following values configure the client for the installed application
21 | # flow.
22 | client_id: "306599674009-1kgphdvst0tb766m2vao672ovphsi6qo.apps.googleusercontent.com"
23 | client_secret: "1VxMj7cnyFcf3ZLyVpR-mTGI"
24 | refresh_token: "1/9JKaj2ea-IlOF1Gk5Y2KrxQbVKAoGQSy3At5w6mwyWs"
25 | # The following values configure the client for the service account flow.
26 | # path_to_private_key_file: INSERT_PATH_TO_JSON_KEY_FILE_HERE
27 | # delegated_account: INSERT_DOMAIN_WIDE_DELEGATION_ACCOUNT
28 | #############################################################################
29 | # ReportDownloader Headers #
30 | # Below you may specify boolean values for optional headers that will be #
31 | # applied to all requests made by the ReportDownloader utility by default. #
32 | #############################################################################
33 | # report_downloader_headers:
34 | # skip_report_header: False
35 | # skip_column_header: False
36 | # skip_report_summary: False
37 | # use_raw_enum_values: False
38 |
39 |
40 | # DfpClient configurations
41 | dfp:
42 | #############################################################################
43 | # Required Fields #
44 | #############################################################################
45 | application_name: INSERT_APPLICATION_NAME_HERE
46 | #############################################################################
47 | # Optional Fields #
48 | #############################################################################
49 | # The network_code is required for all services except NetworkService:
50 | # network_code: INSERT_NETWORK_CODE_HERE
51 | # delegated_account: INSERT_DOMAIN_WIDE_DELEGATION_ACCOUNT
52 | #############################################################################
53 | # OAuth2 Configuration #
54 | # Below you may provide credentials for either the installed application or #
55 | # service account (recommended) flows. Remove or comment the lines for the #
56 | # flow you're not using. #
57 | #############################################################################
58 | # The following values configure the client for the service account flow.
59 | path_to_private_key_file: INSERT_PATH_TO_JSON_KEY_FILE_HERE
60 | # delegated_account: INSERT_DOMAIN_WIDE_DELEGATION_ACCOUNT
61 | # The following values configure the client for the installed application
62 | # flow.
63 | # client_id: INSERT_OAUTH_2_CLIENT_ID_HERE
64 | # client_secret: INSERT_CLIENT_SECRET_HERE
65 | # refresh_token: INSERT_REFRESH_TOKEN_HERE
66 |
67 |
68 | # Common configurations:
69 | ###############################################################################
70 | # Compression (optional) #
71 | # Below you may specify whether to accept and automatically decompress gzip #
72 | # encoded SOAP requests. By default, gzip compression is not enabled. #
73 | ###############################################################################
74 | # enable_compression: False
75 | ###############################################################################
76 | # Logging configuration (optional) #
77 | # Below you may specify the logging configuration. This will be provided as #
78 | # an input to logging.config.dictConfig. #
79 | ###############################################################################
80 | # logging:
81 | # version: 1
82 | # disable_existing_loggers: False
83 | # formatters:
84 | # default_fmt:
85 | # format: ext://googleads.util.LOGGER_FORMAT
86 | # handlers:
87 | # default_handler:
88 | # class: logging.StreamHandler
89 | # formatter: default_fmt
90 | # level: INFO
91 | # loggers:
92 | # Configure root logger
93 | # "":
94 | # handlers: [default_handler]
95 | # level: INFO
96 | ###############################################################################
97 | # Proxy configurations (optional) #
98 | # Below you may specify an HTTP or HTTPS Proxy to be used when making API #
99 | # requests. Note: You must specify the scheme used for the proxy endpoint. #
100 | # #
101 | # For additional information on configuring these values, see: #
102 | # http://docs.python-requests.org/en/master/user/advanced/#proxies #
103 | ###############################################################################
104 | # proxy_config:
105 | # http: INSERT_HTTP_PROXY_URI_HERE
106 | # https: INSERT_HTTPS_PROXY_URI_HERE
107 | # If specified, the given cafile will only be used if certificate validation
108 | # is not disabled.
109 | # cafile: INSERT_PATH_HERE
110 | # disable_certificate_validation: False
111 | ################################################################################
112 | # Utilities Included (optional) #
113 | # Below you may specify whether the library will include utilities used in the #
114 | # user agent. By default, the library will include utilities used in the user #
115 | # agent. #
116 | ################################################################################
117 | # include_utilities_in_user_agent: True
118 | ################################################################################
119 | # SOAP implementation (optional) #
120 | # Specify which SOAP implementation to use. This library currently supports #
121 | # suds and zeep. Zeep is more modern, and is the default, if you have any #
122 | # issues please report them and fall back to suds. #
123 | ################################################################################
124 | soap_impl: suds
125 | ################################################################################
126 | # Custom HTTP headers (optional) #
127 | # Specify one or more custom headers to pass along with all requests to #
128 | # the API. #
129 | ################################################################################
130 | # custom_http_headers:
131 | # X-My-Header: 'content'
132 |
--------------------------------------------------------------------------------
/tests/test_micro_amounts.py:
--------------------------------------------------------------------------------
1 | def test_micro_to_reg():
2 | from adwords_reports.micro_amounts import micro_to_reg
3 |
4 | assert micro_to_reg(23000000) == 23.0
5 |
6 |
7 | def test_micro_to_reg_rounding():
8 | from adwords_reports.micro_amounts import micro_to_reg
9 |
10 | assert micro_to_reg(1111111) == 1.11
11 |
12 |
13 | def test_micro_to_reg_too_small():
14 | from adwords_reports.micro_amounts import micro_to_reg
15 |
16 | assert micro_to_reg(100) == 0.0
17 |
18 |
19 | def test_reg_to_micro():
20 | from adwords_reports.micro_amounts import reg_to_micro
21 |
22 | assert reg_to_micro(1.11) == 1110000
23 |
24 |
25 | def test_reg_to_micro_rounding():
26 | from adwords_reports.micro_amounts import reg_to_micro
27 |
28 | assert reg_to_micro(1.1111) == 1110000
29 |
30 |
31 | def test_reg_to_micro_too_small():
32 | from adwords_reports.micro_amounts import reg_to_micro
33 |
34 | assert reg_to_micro(0.003) == 0
35 |
36 |
37 | def test_reg_and_micro_reg():
38 | from adwords_reports.micro_amounts import reg_and_micro
39 |
40 | assert reg_and_micro(1.11) == (1.11, 1110000)
41 |
42 |
43 | def test_reg_and_micro_micro():
44 | from adwords_reports.micro_amounts import reg_and_micro
45 |
46 | assert reg_and_micro(100000) == (0.10, 100000)
47 |
--------------------------------------------------------------------------------
/tests/test_report_definition.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 |
4 | def test_report_definition():
5 | from adwords_reports.report_definition import ReportDefinition
6 | import datetime
7 |
8 | today = datetime.date.today()
9 | yesterday = (today - datetime.timedelta(1)).strftime("%Y-%m-%d")
10 | seven_d_ago = (today - datetime.timedelta(7)).strftime("%Y-%m-%d")
11 | yesterday_clean = yesterday.replace("-", "")
12 | seven_d_ago_clean = seven_d_ago.replace("-", "")
13 |
14 | r_type = "KEYWORDS_PERFORMANCE_REPORT"
15 | fields = ["Criteria"]
16 | predicates = [{"field": "Name", "operator": "EQUALS", "values": "test_kw_1"}]
17 | # check if structure is as intended
18 | r_def = ReportDefinition(r_type, fields, predicates, last_days=7)
19 | expected_result = {
20 | "reportName": "api_report",
21 | "dateRangeType": "CUSTOM_DATE",
22 | "reportType": r_type,
23 | "downloadFormat": "CSV",
24 | "selector": {
25 | "fields": fields,
26 | "dateRange": {
27 | "min": seven_d_ago_clean,
28 | "max": yesterday_clean
29 | },
30 | "predicates": predicates
31 | }
32 | }
33 | assert r_def._as_dict() == expected_result
34 |
35 | # conversion of date strings
36 | r_def2 = ReportDefinition(
37 | report_type=r_type, fields=fields, date_from=seven_d_ago, date_to=yesterday)
38 | expected_result2 = {
39 | "reportName": "api_report",
40 | "dateRangeType": "CUSTOM_DATE",
41 | "reportType": r_type,
42 | "downloadFormat": "CSV",
43 | "selector": {
44 | "fields": fields,
45 | "dateRange": {
46 | "min": seven_d_ago_clean,
47 | "max": yesterday_clean
48 | }
49 | }
50 | }
51 | assert r_def2._as_dict() == expected_result2
52 |
53 | # multiple specifications for date range
54 | with pytest.raises(AssertionError):
55 | ReportDefinition(
56 | report_type=r_type, fields=fields, predicates=predicates,
57 | last_days=3, date_from=seven_d_ago, date_to=yesterday)
--------------------------------------------------------------------------------