├── .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 | [![Build Status](https://travis-ci.org/SaturnFromTitan/adwords_reports.svg?branch=master)](https://travis-ci.org/SaturnFromTitan/adwords_reports) 3 | [![codecov](https://codecov.io/gh/SaturnFromTitan/adwords_reports/branch/master/graph/badge.svg)](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) --------------------------------------------------------------------------------