├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── appstoreconnect ├── __init__.py ├── __version__.py ├── api.py └── resources.py ├── example.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python bytecode: 2 | *.py[co] 3 | 4 | # Packaging files: 5 | *.egg* 6 | dist/* 7 | MANIFEST 8 | 9 | # Sphinx docs: 10 | build 11 | 12 | # SQLite3 database files: 13 | *.db 14 | 15 | # Logs: 16 | *.log 17 | 18 | # IDEs 19 | .project 20 | .pydevproject 21 | .settings 22 | .idea 23 | 24 | # Linux Editors 25 | *~ 26 | \#*\# 27 | /.emacs.desktop 28 | /.emacs.desktop.lock 29 | .elc 30 | auto-save-list 31 | tramp 32 | .\#* 33 | *.swp 34 | *.swo 35 | 36 | # Mac 37 | .DS_Store 38 | ._* 39 | 40 | # Windows 41 | Thumbs.db 42 | Desktop.ini 43 | git 44 | 45 | # venv 46 | .venv/ 47 | 48 | # Apple AuthKeys 49 | AuthKey_* 50 | 51 | # VSCode 52 | .vscode 53 | 54 | # Files used or generated during dev/testing 55 | *.csv 56 | *.p8 57 | test_api_sales.py 58 | test_api_finance.py 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.10.1 2 | Bugfixes: 3 | - Relax cryptography dependencies (@conformist-mw) 4 | - Do not assume presence of content-type header (@jaysoffian) 5 | - Fix a possible RecursionError 6 | 7 | ## 0.10.0 8 | 9 | Features: 10 | - Add a timeout parameter (in seconds) for requests 11 | - Add proxy support 12 | 13 | Bugfixes: 14 | - Avoid a RecursionError when accessing an unknown attribute on some resources 15 | 16 | ## 0.9.1 17 | 18 | Bugfixes: 19 | - Relax required dependencies in setup.py 20 | - Fix APIError exception handling 21 | - Add relationships attribute to the Build resource 22 | 23 | ## 0.9.0 24 | 25 | Features: 26 | - New endpoint: `modify_user_account` 27 | - Support getting more related resources, like `user.visibleApps()` 28 | 29 | Bugfixes: 30 | - Pin dependencies versions in setup.py 31 | 32 | ## 0.8.4 33 | 34 | Features: 35 | - Expose the HTTP status code when App Store Connect API response raises APIError (@GClunies) 36 | 37 | ## 0.8.3 38 | 39 | Bugfixes: 40 | - Fix invite_user method (@AricWu) 41 | 42 | ## 0.8.2 43 | 44 | Features: 45 | - New `split_response` argument in `download_finance_reports()` function that splits the response into 2 objects. Defualt value is `split_response=False`. This also 46 | allows the 2 responses to be saved to separate files using a syntax like `save_to=['test1.csv', 'test2.csv']`. (@GClunies) 47 | 48 | ## 0.8.1 49 | 50 | Bugfixes: 51 | - Add default versions and subtypes in download_sales_and_trends_reports 52 | 53 | ## 0.8.0 54 | 55 | Features: 56 | - New endpoints: 57 | - delete_beta_tester 58 | - read_beta_tester_information 59 | - modify_beta_group 60 | - delete_beta_group 61 | - read_beta_group_information 62 | - read_beta_app_localization_information 63 | - create_beta_app_localization 64 | - modify_registered_device 65 | - read_beta_app_review_submission_information 66 | - Collect anonymous usage statistics 67 | 68 | Breaking changes API: 69 | - new parameters for create_beta_tester 70 | - new parameters for create_beta_group 71 | - new parameters for submit_app_for_beta_review 72 | - register_device renamed to register_new_device 73 | 74 | ## 0.7.0 75 | 76 | Features: 77 | - New endpoint: register_device (@BalestraPatrick) 78 | 79 | ## 0.6.0 80 | 81 | Features: 82 | - New endpoints: invite_user and read_user_invitation_information (@BalestraPatrick) 83 | 84 | Bugfixes: 85 | - Fixes create_beta_tester endpoint URL (@BalestraPatrick) 86 | 87 | ## 0.5.1 88 | 89 | Bugfixes: 90 | - Fixes token re-generation (@gsaraceno) 91 | 92 | ## 0.5.0 93 | 94 | Features: 95 | - Handle listing all resources in the provisioning section (devices thanks to @EricG-Personal) 96 | 97 | ## 0.4.1 98 | 99 | Features: 100 | - Allow to query resources sorted 101 | - Allow passing key as a string value (@kpotehin) 102 | 103 | Bugfixes: 104 | - Fixed sort param in reports (@kpotehin) 105 | 106 | ## 0.4.0 107 | 108 | Features: 109 | - Handle fetching related resources (@WangYi) 110 | 111 | Bugfixes: 112 | - When paging resources, fix missing resource in the first page (@WangYi) 113 | 114 | ## 0.3.0 115 | 116 | Features: 117 | - Complete API rewrite, "list" methods return an iterator over resources, "get" method returns a resource 118 | - Handles all GET endpoints (except the new "Provisioning" section) 119 | - Handle pagination 120 | - Handle downloading Finance and Sales reports 121 | 122 | ## 0.2.1 123 | 124 | Bugfixes: 125 | 126 | - Cryptography dependency is required 127 | 128 | ## 0.2.0 129 | 130 | Features: 131 | 132 | - Added more functions (@fehmitoumi) 133 | 134 | ## 0.1.0 135 | 136 | Features: 137 | 138 | - Initial Release 139 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ponytech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE requirements.txt CHANGELOG.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | App Store Connect Api 2 | ==== 3 | 4 | This is a Python wrapper around the **Apple App Store Api** : https://developer.apple.com/documentation/appstoreconnectapi 5 | 6 | So far, it handles token generation / expiration, methods for listing resources and downloading reports. 7 | 8 | Installation 9 | ------------ 10 | 11 | [![Version](http://img.shields.io/pypi/v/appstoreconnect.svg?style=flat)](https://pypi.org/project/appstoreconnect/) 12 | 13 | The project is published on PyPI, install with: 14 | 15 | pip install appstoreconnect 16 | 17 | Usage 18 | ----- 19 | 20 | Please follow instructions on [Apple documentation](https://developer.apple.com/documentation/appstoreconnectapi/creating_api_keys_for_app_store_connect_api) on how to generate an API key. 21 | 22 | With your *key ID*, *key file* (you can either pass the path to the file or the content of it as a string) and *issuer ID* create a new API instance: 23 | 24 | ```python 25 | from appstoreconnect import Api, UserRole 26 | api = Api(key_id, path_to_key_file, issuer_id) 27 | 28 | # use a proxy 29 | api = Api(key_id, path_to_key_file, issuer_id, proxy='http://1.2.3.4:3128') 30 | 31 | # set a timeout (in seconds) for requests 32 | api = Api(key_id, path_to_key_file, issuer_id, timeout=42) 33 | ``` 34 | 35 | Here are a few examples of API usage. For a complete list of available methods please see [api.py](https://github.com/Ponytech/appstoreconnectapi/blob/master/appstoreconnect/api.py#L148). 36 | 37 | ```python 38 | # list all apps 39 | apps = api.list_apps() 40 | for app in apps: 41 | print(app.name, app.sku) 42 | 43 | # sort resources 44 | apps = api.list_apps(sort='name') 45 | 46 | # filter apps 47 | apps = api.list_apps(filters={'sku': 'DINORUSH', 'name': 'Dino Rush'}) 48 | print("%d apps found" % len(apps)) 49 | 50 | # read app information 51 | app = api.read_app_information('1308363336') 52 | print(app.name, app.sku, app.bundleId) 53 | 54 | # get a related resource 55 | for group in app.betaGroups(): 56 | print(group.name) 57 | 58 | # list bundle ids 59 | for bundle_id in api.list_bundle_ids(): 60 | print(bundle_id.identifier) 61 | 62 | # list certificates 63 | for certificate in api.list_certificates(): 64 | print(certificate.name) 65 | 66 | # modify a user 67 | user = api.list_users(filters={'username': 'finance@nemoidstudio.com'})[0] 68 | api.modify_user_account(user, roles=[UserRole.FINANCE, UserRole.ACCESS_TO_REPORTS]) 69 | 70 | # download sales report 71 | api.download_sales_and_trends_reports( 72 | filters={'vendorNumber': '123456789', 'frequency': 'WEEKLY', 'reportDate': '2019-06-09'}, save_to='report.csv') 73 | 74 | # download finance report 75 | api.download_finance_reports(filters={'vendorNumber': '123456789', 'reportDate': '2019-06'}, save_to='finance.csv') 76 | ``` 77 | 78 | Define a timeout (in seconds) after which an exception is raised if no response is received. 79 | 80 | ```python 81 | api = Api(key_id, path_to_key_file, issuer_id, timeout=30) 82 | api.list_apps() 83 | 84 | APIError: Read timeout after 30 seconds 85 | ``` 86 | 87 | 88 | Please note this is a work in progress, API is subject to change between versions. 89 | 90 | Anonymous data collection 91 | ------------------------- 92 | 93 | Starting with version 0.8.0 this library anonymously collects its usage to help better improve its development. 94 | What we collect is: 95 | 96 | - a SHA1 hash of the issuer_id 97 | - the OS and Python version used 98 | - which enpoints had been used 99 | 100 | You can review the [source code](https://github.com/Ponytech/appstoreconnectapi/blob/b73d4314e2a9f9098f3287f57fff687563e70b28/appstoreconnect/api.py#L238) 101 | 102 | If you feel uncomfortable with it you can completely opt-out by initliazing the API with: 103 | 104 | ```python 105 | api = Api(key_id, path_to_key_file, issuer_id, submit_stats=False) 106 | ``` 107 | 108 | The is also an [open issue](https://github.com/Ponytech/appstoreconnectapi/issues/18) about this topic where we would love to here your feedback and best practices. 109 | 110 | 111 | Development 112 | ----------- 113 | 114 | Project development happens on [Github](https://github.com/Ponytech/appstoreconnectapi) 115 | 116 | 117 | TODO 118 | ---- 119 | 120 | * [ ] Support App Store Connect API 1.2 121 | * [ ] Support the include parameter 122 | * [X] handle POST, DELETE and PATCH requests 123 | * [X] sales report 124 | * [X] handle related resources 125 | * [X] allow to sort resources 126 | * [ ] proper API documentation 127 | * [ ] add tests 128 | 129 | 130 | Credits 131 | ------- 132 | 133 | This project is developed by [Ponytech](https://ponytech.net) 134 | -------------------------------------------------------------------------------- /appstoreconnect/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import Api, UserRole 2 | -------------------------------------------------------------------------------- /appstoreconnect/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 10, 1) 2 | 3 | __version__ = '.'.join(map(str, VERSION)) 4 | -------------------------------------------------------------------------------- /appstoreconnect/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import jwt 3 | import gzip 4 | import platform 5 | import hashlib 6 | from collections import defaultdict 7 | from pathlib import Path 8 | from datetime import datetime, timedelta 9 | import time 10 | import json 11 | from typing import List 12 | from enum import Enum, auto 13 | 14 | from .resources import * 15 | from .__version__ import __version__ as version 16 | 17 | ALGORITHM = 'ES256' 18 | BASE_API = "https://api.appstoreconnect.apple.com" 19 | 20 | 21 | class UserRole(Enum): 22 | ADMIN = auto() 23 | FINANCE = auto() 24 | TECHNICAL = auto() 25 | SALES = auto() 26 | MARKETING = auto() 27 | DEVELOPER = auto() 28 | ACCOUNT_HOLDER = auto() 29 | READ_ONLY = auto() 30 | APP_MANAGER = auto() 31 | ACCESS_TO_REPORTS = auto() 32 | CUSTOMER_SUPPORT = auto() 33 | 34 | 35 | class HttpMethod(Enum): 36 | GET = 1 37 | POST = 2 38 | PATCH = 3 39 | DELETE = 4 40 | 41 | 42 | class APIError(Exception): 43 | def __init__(self, error_string, status_code=None): 44 | try: 45 | self.status_code = int(status_code) 46 | except (ValueError, TypeError): 47 | pass 48 | super().__init__(error_string) 49 | 50 | 51 | class Api: 52 | 53 | def __init__(self, key_id, key_file, issuer_id, submit_stats=True, timeout=None, proxy=None): 54 | self._token = None 55 | self.token_gen_date = None 56 | self.exp = None 57 | self.key_id = key_id 58 | self.key_file = key_file 59 | self.issuer_id = issuer_id 60 | self.submit_stats = submit_stats 61 | self.timeout = timeout 62 | self.proxy = proxy 63 | self._call_stats = defaultdict(int) 64 | if self.submit_stats: 65 | self._submit_stats("session_start") 66 | 67 | self._debug = False 68 | token = self.token # generate first token 69 | 70 | def __del__(self): 71 | if self.submit_stats: 72 | self._submit_stats("session_end") 73 | 74 | def _generate_token(self): 75 | try: 76 | key = open(self.key_file, 'r').read() 77 | except IOError as e: 78 | key = self.key_file 79 | self.token_gen_date = datetime.now() 80 | exp = int(time.mktime((self.token_gen_date + timedelta(minutes=20)).timetuple())) 81 | return jwt.encode({'iss': self.issuer_id, 'exp': exp, 'aud': 'appstoreconnect-v1'}, key, 82 | headers={'kid': self.key_id, 'typ': 'JWT'}, algorithm=ALGORITHM).decode('ascii') 83 | 84 | def _get_resource(self, Resource, resource_id): 85 | url = "%s%s/%s" % (BASE_API, Resource.endpoint, resource_id) 86 | payload = self._api_call(url) 87 | return Resource(payload.get('data', {}), self) 88 | 89 | def _get_resource_from_payload_data(self, payload): 90 | try: 91 | resource_type = resources[payload.get('type')] 92 | except KeyError: 93 | raise APIError("Unsupported resource type %s" % payload.get('type')) 94 | 95 | return resource_type(payload, self) 96 | 97 | def get_related_resource(self, full_url): 98 | payload = self._api_call(full_url) 99 | data = payload.get('data') 100 | if data is None: 101 | return None 102 | elif type(data) == dict: 103 | return self._get_resource_from_payload_data(data) 104 | 105 | def get_related_resources(self, full_url): 106 | payload = self._api_call(full_url) 107 | data = payload.get('data', []) 108 | for resource in data: 109 | yield self._get_resource_from_payload_data(resource) 110 | 111 | def _create_resource(self, Resource, args): 112 | attributes = {} 113 | for attribute in Resource.attributes: 114 | if attribute in args and args[attribute] is not None: 115 | attributes[attribute] = args[attribute] 116 | 117 | relationships_dict = {} 118 | for relation in Resource.relationships.keys(): 119 | if relation in args and args[relation] is not None: 120 | relationships_dict[relation] = {} 121 | if Resource.relationships[relation].get('multiple', False): 122 | relationships_dict[relation]['data'] = [] 123 | relationship_objects = args[relation] 124 | if type(relationship_objects) is not list: 125 | relationship_objects = [relationship_objects] 126 | for relationship_object in relationship_objects: 127 | relationships_dict[relation]['data'].append({ 128 | 'id': relationship_object.id, 129 | 'type': relationship_object.type 130 | }) 131 | else: 132 | relationships_dict[relation]['data'] = { 133 | 'id': args[relation].id, 134 | 'type': args[relation].type 135 | } 136 | 137 | post_data = { 138 | 'data': { 139 | 'attributes': attributes, 140 | 'relationships': relationships_dict, 141 | 'type': Resource.type 142 | } 143 | } 144 | url = "%s%s" % (BASE_API, Resource.endpoint) 145 | if self._debug: 146 | print(post_data) 147 | payload = self._api_call(url, HttpMethod.POST, post_data) 148 | 149 | return Resource(payload.get('data', {}), self) 150 | 151 | def _modify_resource(self, resource, args): 152 | attributes = {} 153 | 154 | for attribute in resource.attributes: 155 | if attribute in args and args[attribute] is not None: 156 | if type(args[attribute]) == list: 157 | value = list(map(lambda e: e.name if isinstance(e, Enum) else e, args[attribute])) 158 | elif isinstance(args[attribute], Enum): 159 | value = args[attribute].name 160 | else: 161 | value = args[attribute] 162 | attributes[attribute] = value 163 | 164 | relationships = {} 165 | if hasattr(resource, 'relationships'): 166 | for relationship in resource.relationships: 167 | if relationship in args and args[relationship] is not None: 168 | relationships[relationship] = {} 169 | relationships[relationship]['data'] = [] 170 | for relationship_object in args[relationship]: 171 | relationships[relationship]['data'].append( 172 | { 173 | 'id': relationship_object.id, 174 | 'type': relationship_object.type 175 | } 176 | ) 177 | 178 | post_data = { 179 | 'data': { 180 | 'attributes': attributes, 181 | 'id': resource.id, 182 | 'type': resource.type 183 | } 184 | } 185 | if len(relationships): 186 | post_data['data']['relationships'] = relationships 187 | 188 | url = "%s%s/%s" % (BASE_API, resource.endpoint, resource.id) 189 | if self._debug: 190 | print(post_data) 191 | payload = self._api_call(url, HttpMethod.PATCH, post_data) 192 | 193 | return type(resource)(payload.get('data', {}), self) 194 | 195 | def _delete_resource(self, resource: Resource): 196 | url = "%s%s/%s" % (BASE_API, resource.endpoint, resource.id) 197 | self._api_call(url, HttpMethod.DELETE) 198 | 199 | def _get_resources(self, Resource, filters=None, sort=None, full_url=None): 200 | class IterResource: 201 | def __init__(self, api, url): 202 | self.api = api 203 | self.url = url 204 | self.index = 0 205 | self.total_length = None 206 | self.payload = None 207 | 208 | def __getitem__(self, item): 209 | items = list(self) 210 | return items[item] 211 | 212 | def __iter__(self): 213 | return self 214 | 215 | def __repr__(self): 216 | return "Iterator over %s resource" % Resource.__name__ 217 | 218 | def __len__(self): 219 | if not self.payload: 220 | self.fetch_page() 221 | return self.total_length 222 | 223 | def __next__(self): 224 | if not self.payload: 225 | self.fetch_page() 226 | if self.index < len(self.payload.get('data', [])): 227 | data = self.payload.get('data', [])[self.index] 228 | self.index += 1 229 | return Resource(data, self.api) 230 | else: 231 | self.url = self.payload.get('links', {}).get('next', None) 232 | self.index = 0 233 | if self.url: 234 | self.fetch_page() 235 | if self.index < len(self.payload.get('data', [])): 236 | data = self.payload.get('data', [])[self.index] 237 | self.index += 1 238 | return Resource(data, self.api) 239 | raise StopIteration() 240 | 241 | def fetch_page(self): 242 | self.payload = self.api._api_call(self.url) 243 | self.total_length = self.payload.get('meta', {}).get('paging', {}).get('total', 0) 244 | 245 | url = full_url if full_url else "%s%s" % (BASE_API, Resource.endpoint) 246 | url = self._build_query_parameters(url, filters, sort) 247 | return IterResource(self, url) 248 | 249 | def _build_query_parameters(self, url, filters, sort = None): 250 | separator = '?' 251 | if type(filters) is dict: 252 | for index, (filter_name, filter_value) in enumerate(filters.items()): 253 | filter_name = "filter[%s]" % filter_name 254 | url = "%s%s%s=%s" % (url, separator, filter_name, filter_value) 255 | separator = '&' 256 | if type(sort) is str: 257 | url = "%s%ssort=%s" % (url, separator, sort) 258 | return url 259 | 260 | def _api_call(self, url, method=HttpMethod.GET, post_data=None): 261 | headers = {"Authorization": "Bearer %s" % self.token} 262 | if self._debug: 263 | print("%s %s" % (method.value, url)) 264 | 265 | if self._submit_stats: 266 | endpoint = url.replace(BASE_API, '') 267 | if method in (HttpMethod.PATCH, HttpMethod.DELETE): # remove last bit of endpoint which is a resource id 268 | endpoint = "/".join(endpoint.split('/')[:-1]) 269 | request = "%s %s" % (method.name, endpoint) 270 | self._call_stats[request] += 1 271 | 272 | try: 273 | if method == HttpMethod.GET: 274 | proxies = {'https': self.proxy} if self.proxy else None 275 | r = requests.get(url, headers=headers, timeout=self.timeout, proxies=proxies) 276 | elif method == HttpMethod.POST: 277 | headers["Content-Type"] = "application/json" 278 | r = requests.post(url=url, headers=headers, data=json.dumps(post_data), timeout=self.timeout) 279 | elif method == HttpMethod.PATCH: 280 | headers["Content-Type"] = "application/json" 281 | r = requests.patch(url=url, headers=headers, data=json.dumps(post_data), timeout=self.timeout) 282 | elif method == HttpMethod.DELETE: 283 | r = requests.delete(url=url, headers=headers, timeout=self.timeout) 284 | else: 285 | raise APIError("Unknown HTTP method") 286 | except requests.exceptions.Timeout: 287 | raise APIError(f"Read timeout after {self.timeout} seconds") 288 | 289 | if self._debug: 290 | print(r.status_code) 291 | 292 | content_type = r.headers.get('content-type') 293 | 294 | if content_type in ["application/json", "application/vnd.api+json"]: 295 | payload = r.json() 296 | if 'errors' in payload: 297 | raise APIError( 298 | payload.get('errors', [])[0].get('detail', 'Unknown error'), 299 | payload.get('errors', [])[0].get('status', None) 300 | ) 301 | return payload 302 | elif content_type == 'application/a-gzip': 303 | # TODO implement stream decompress 304 | data_gz = b"" 305 | for chunk in r.iter_content(1024 * 1024): 306 | if chunk: 307 | data_gz = data_gz + chunk 308 | 309 | data = gzip.decompress(data_gz) 310 | return data.decode("utf-8") 311 | else: 312 | if not 200 <= r.status_code <= 299: 313 | raise APIError("HTTP error [%d][%s]" % (r.status_code, r.content)) 314 | return r 315 | 316 | def _submit_stats(self, event_type): 317 | """ 318 | this submits anonymous usage statistics to help us better understand how this library is used 319 | you can opt-out by initializing the client with submit_stats=False 320 | """ 321 | payload = { 322 | 'project': 'appstoreconnectapi', 323 | 'version': version, 324 | 'type': event_type, 325 | 'parameters': { 326 | 'python_version': platform.python_version(), 327 | 'platform': platform.platform(), 328 | 'issuer_id_hash': hashlib.sha1(self.issuer_id.encode()).hexdigest(), # send anonymized hash 329 | } 330 | } 331 | if event_type == 'session_end': 332 | payload['parameters']['endpoints'] = self._call_stats 333 | requests.post('https://stats.ponytech.net/new-event', json.dumps(payload)) 334 | 335 | @property 336 | def token(self): 337 | # generate a new token every 15 minutes 338 | if (self._token is None) or (self.token_gen_date + timedelta(minutes=15) < datetime.now()): 339 | self._token = self._generate_token() 340 | 341 | return self._token 342 | 343 | # Users and Roles 344 | def modify_user_account( 345 | self, 346 | user: User, 347 | allAppsVisible: bool = None, 348 | provisioningAllowed: bool = None, 349 | roles: List[UserRole] = None, 350 | visibleApps: List[App] = None, 351 | ): 352 | """ 353 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/modify_a_user_account 354 | :return: a User resource 355 | """ 356 | return self._modify_resource(user, locals()) 357 | 358 | def list_users(self, filters=None, sort=None): 359 | """ 360 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_users 361 | :return: an iterator over User resources 362 | """ 363 | return self._get_resources(User, filters, sort) 364 | 365 | def list_invited_users(self, filters=None, sort=None): 366 | """ 367 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_invited_users 368 | :return: an iterator over UserInvitation resources 369 | """ 370 | return self._get_resources(UserInvitation, filters, sort) 371 | 372 | # TODO: implement POST requests using Resource 373 | def invite_user(self, all_apps_visible, email, first_name, last_name, provisioning_allowed, roles, visible_apps=None): 374 | """ 375 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/invite_a_user 376 | :return: a UserInvitation resource 377 | """ 378 | post_data = {'data': {'attributes': {'allAppsVisible': all_apps_visible, 'email': email, 'firstName': first_name, 'lastName': last_name, 'provisioningAllowed': provisioning_allowed, 'roles': roles}, 'type': 'userInvitations'}} 379 | if visible_apps is not None: 380 | visible_apps_relationship = list(map(lambda a: {'id': a, 'type': 'apps'}, visible_apps)) 381 | visible_apps_data = {'visibleApps': {'data': visible_apps_relationship}} 382 | post_data['data']['relationships'] = visible_apps_data 383 | payload = self._api_call(BASE_API + "/v1/userInvitations", HttpMethod.POST, post_data) 384 | return UserInvitation(payload.get('data'), {}) 385 | 386 | def read_user_invitation_information(self, user_invitation_id: str): 387 | """ 388 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/read_user_invitation_information 389 | :return: a UserInvitation resource 390 | """ 391 | return self._get_resource(UserInvitation, user_invitation_id) 392 | 393 | # Beta Testers and Groups 394 | def create_beta_tester(self, email: str, firstName: str = None, lastName: str = None, betaGroups: BetaGroup = None, builds: Build = None) -> BetaTester: 395 | """ 396 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/create_a_beta_tester 397 | :return: an BetaTester resource 398 | """ 399 | return self._create_resource(BetaTester, locals()) 400 | 401 | def delete_beta_tester(self, betaTester: BetaTester) -> None: 402 | """ 403 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/delete_a_beta_tester 404 | :return: None 405 | """ 406 | return self._delete_resource(betaTester) 407 | 408 | def list_beta_testers(self, filters=None, sort=None): 409 | """ 410 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_testers 411 | :return: an iterator over BetaTester resources 412 | """ 413 | return self._get_resources(BetaTester, filters, sort) 414 | 415 | def read_beta_tester_information(self, beta_tester_id: str): 416 | """ 417 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/read_beta_tester_information 418 | :return: a BetaTester resource 419 | """ 420 | return self._get_resource(BetaTester, beta_tester_id) 421 | 422 | def create_beta_group(self, app: App, name: str, publicLinkEnabled: bool = None, publicLinkLimit: int = None, publicLinkLimitEnabled: bool = None) -> BetaGroup: 423 | """ 424 | :reference:https://developer.apple.com/documentation/appstoreconnectapi/create_a_beta_group 425 | :return: a BetaGroup resource 426 | """ 427 | return self._create_resource(BetaGroup, locals()) 428 | 429 | def modify_beta_group(self, betaGroup: BetaGroup, name: str = None, publicLinkEnabled: bool = None, publicLinkLimit: int = None, publicLinkLimitEnabled: bool = None) -> BetaGroup: 430 | """ 431 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/modify_a_beta_group 432 | :return: a BetaGroup resource 433 | """ 434 | return self._modify_resource(betaGroup, locals()) 435 | 436 | def delete_beta_group(self, betaGroup: BetaGroup): 437 | return self._delete_resource(betaGroup) 438 | 439 | def list_beta_groups(self, filters=None, sort=None): 440 | """ 441 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_groups 442 | :return: an iterator over BetaGroup resources 443 | """ 444 | return self._get_resources(BetaGroup, filters, sort) 445 | 446 | def read_beta_group_information(self, beta_group_ip): 447 | """ 448 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/read_beta_group_information 449 | :return: an BetaGroup resource 450 | """ 451 | return self._get_resource(BetaGroup, beta_group_ip) 452 | 453 | def add_build_to_beta_group(self, beta_group_id, build_id): 454 | post_data = {'data': [{ 'id': build_id, 'type': 'builds'}]} 455 | payload = self._api_call(BASE_API + "/v1/betaGroups/" + beta_group_id + "/relationships/builds", HttpMethod.POST, post_data) 456 | return BetaGroup(payload.get('data'), {}) 457 | 458 | # App Resources 459 | def read_app_information(self, app_ip): 460 | """ 461 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/read_app_information 462 | :param app_ip: 463 | :return: an App resource 464 | """ 465 | return self._get_resource(App, app_ip) 466 | 467 | def list_apps(self, filters=None, sort=None): 468 | """ 469 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_apps 470 | :return: an iterator over App resources 471 | """ 472 | return self._get_resources(App, filters, sort) 473 | 474 | def list_prerelease_versions(self, filters=None, sort=None): 475 | """ 476 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_prerelease_versions 477 | :return: an iterator over PreReleaseVersion resources 478 | """ 479 | return self._get_resources(PreReleaseVersion, filters, sort) 480 | 481 | def list_beta_app_localizations(self, filters=None): 482 | """ 483 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_app_localizations 484 | :return: an iterator over BetaAppLocalization resources 485 | """ 486 | return self._get_resources(BetaAppLocalization, filters) 487 | 488 | def read_beta_app_localization_information(self, beta_app_id: str): 489 | """ 490 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/read_beta_app_localization_information 491 | :return: an BetaAppLocalization resource 492 | """ 493 | return self._get_resource(BetaAppLocalization, beta_app_id) 494 | 495 | def create_beta_app_localization(self, app: App, locale: str, description: str = None, feedbackEmail: str = None, marketingUrl: str = None, privacyPolicyUrl: str = None, tvOsPrivacyPolicy: str = None): 496 | """ 497 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/create_a_beta_app_localization 498 | :return: an BetaAppLocalization resource 499 | """ 500 | return self._create_resource(BetaAppLocalization, locals()) 501 | 502 | def list_app_encryption_declarations(self, filters=None): 503 | """ 504 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_app_encryption_declarations 505 | :return: an iterator over AppEncryptionDeclaration resources 506 | """ 507 | return self._get_resources(AppEncryptionDeclaration, filters) 508 | 509 | def list_beta_license_agreements(self, filters=None): 510 | """ 511 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_license_agreements 512 | :return: an iterator over BetaLicenseAgreement resources 513 | """ 514 | return self._get_resources(BetaLicenseAgreement, filters) 515 | 516 | # Build Resources 517 | def list_builds(self, filters=None, sort=None): 518 | """ 519 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_builds 520 | :return: an iterator over Build resources 521 | """ 522 | return self._get_resources(Build, filters, sort) 523 | 524 | # TODO: handle fields on get_resources() 525 | def build_processing_state(self, app_id, version): 526 | return self._api_call(BASE_API + "/v1/builds?filter[app]=" + app_id + "&filter[version]=" + version + "&fields[builds]=processingState") 527 | 528 | # TODO: implement POST requests using Resource 529 | def set_uses_non_encryption_exemption_setting(self, build_id, uses_non_encryption_exemption_setting): 530 | post_data = {'data': {'attributes': {'usesNonExemptEncryption': uses_non_encryption_exemption_setting}, 'id': build_id, 'type': 'builds'}} 531 | payload = self._api_call(BASE_API + "/v1/builds/" + build_id, HttpMethod.PATCH, post_data) 532 | return Build(payload.get('data'), {}) 533 | 534 | def list_build_beta_details(self, filters=None): 535 | """ 536 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_build_beta_details 537 | :return: an iterator over BuildBetaDetail resources 538 | """ 539 | return self._get_resources(BuildBetaDetail, filters) 540 | 541 | def create_beta_build_localization(self, build: Build, locale: str, whatsNew: str = None): 542 | """ 543 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/create_a_beta_build_localization 544 | :return: a BetaBuildLocalization resource 545 | """ 546 | return self._create_resource(BetaBuildLocalization, locals()) 547 | 548 | def modify_beta_build_localization(self, beta_build_localization: BetaBuildLocalization, whatsNew: str): 549 | """ 550 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/modify_a_beta_build_localization 551 | :return: a BetaBuildLocalization resource 552 | """ 553 | return self._modify_resource(beta_build_localization, locals()) 554 | 555 | def list_beta_build_localizations(self, filters=None): 556 | """ 557 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_build_localizations 558 | :return: an iterator over BetaBuildLocalization resources 559 | """ 560 | return self._get_resources(BetaBuildLocalization, filters) 561 | 562 | def list_beta_app_review_details(self, filters=None): 563 | """ 564 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_app_review_details 565 | :return: an iterator over BetaAppReviewDetail resources 566 | """ 567 | return self._get_resources(BetaAppReviewDetail, filters) 568 | 569 | def submit_app_for_beta_review(self, build: Build) -> BetaAppReviewSubmission: 570 | """ 571 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/submit_an_app_for_beta_review 572 | :return: a BetaAppReviewSubmission resource 573 | """ 574 | 575 | return self._create_resource(BetaAppReviewSubmission, locals()) 576 | 577 | def list_beta_app_review_submissions(self, filters=None): 578 | """ 579 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_beta_app_review_submissions 580 | :return: an iterator over BetaAppReviewSubmission resources 581 | """ 582 | return self._get_resources(BetaAppReviewSubmission, filters) 583 | 584 | def read_beta_app_review_submission_information(self, beta_app_id: str): 585 | """ 586 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/read_beta_app_review_submission_information 587 | :return: an BetaAppReviewSubmission resource 588 | """ 589 | return self._get_resource(BetaAppReviewSubmission, beta_app_id) 590 | 591 | # Provisioning 592 | def list_bundle_ids(self, filters=None, sort=None): 593 | """ 594 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_bundle_ids 595 | :return: an iterator over BundleId resources 596 | """ 597 | return self._get_resources(BundleId, filters, sort) 598 | 599 | def list_certificates(self, filters=None, sort=None): 600 | """ 601 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_and_download_certificates 602 | :return: an iterator over Certificate resources 603 | """ 604 | return self._get_resources(Certificate, filters, sort) 605 | 606 | def list_devices(self, filters=None, sort=None): 607 | """ 608 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_devices 609 | :return: an iterator over Device resources 610 | """ 611 | return self._get_resources(Device, filters, sort) 612 | 613 | def register_new_device(self, name: str, platform: str, udid: str) -> Device: 614 | """ 615 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/register_a_new_device 616 | :return: a Device resource 617 | """ 618 | return self._create_resource(Device, locals()) 619 | 620 | def modify_registered_device(self, device: Device, name: str = None, status: str = None) -> Device: 621 | """ 622 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/modify_a_registered_device 623 | :return: a Device resource 624 | """ 625 | return self._modify_resource(device, locals()) 626 | 627 | def list_profiles(self, filters=None, sort=None): 628 | """ 629 | :reference: https://developer.apple.com/documentation/appstoreconnectapi/list_and_download_profiles 630 | :return: an iterator over Profile resources 631 | """ 632 | return self._get_resources(Profile, filters, sort) 633 | 634 | # Reporting 635 | def download_finance_reports(self, filters=None, split_response=False, save_to=None): 636 | # setup required filters if not provided 637 | for required_key, default_value in ( 638 | ('regionCode', 'ZZ'), 639 | ('reportType', 'FINANCIAL'), 640 | # vendorNumber is required but we cannot provide a default value 641 | # reportDate is required but we cannot provide a default value 642 | ): 643 | if required_key not in filters: 644 | filters[required_key] = default_value 645 | 646 | url = "%s%s" % (BASE_API, FinanceReport.endpoint) 647 | url = self._build_query_parameters(url, filters) 648 | response = self._api_call(url) 649 | 650 | if split_response: 651 | res1 = response.split('Total_Rows')[0] 652 | res2 = '\n'.join(response.split('Total_Rows')[1].split('\n')[1:]) 653 | 654 | if save_to: 655 | file1 = Path(save_to[0]) 656 | file1.write_text(res1, 'utf-8') 657 | file2 = Path(save_to[1]) 658 | file2.write_text(res2, 'utf-8') 659 | 660 | return res1, res2 661 | 662 | if save_to: 663 | file = Path(save_to) 664 | file.write_text(response, 'utf-8') 665 | 666 | return response 667 | 668 | def download_sales_and_trends_reports(self, filters=None, save_to=None): 669 | # setup required filters if not provided 670 | default_versions = { 671 | 'SALES': '1_0', 672 | 'SUBSCRIPTION': '1_2', 673 | 'SUBSCRIPTION_EVENT': '1_2', 674 | 'SUBSCRIBER': '1_2', 675 | 'NEWSSTAND': '1_0', 676 | 'PRE_ORDER': '1_0', 677 | } 678 | default_subtypes = { 679 | 'SALES': 'SUMMARY', 680 | 'SUBSCRIPTION': 'SUMMARY', 681 | 'SUBSCRIPTION_EVENT': 'SUMMARY', 682 | 'SUBSCRIBER': 'DETAILED', 683 | 'NEWSSTAND': 'DETAILED', 684 | 'PRE_ORDER': 'SUMMARY', 685 | } 686 | for required_key, default_value in ( 687 | ('frequency', 'DAILY'), 688 | ('reportType', 'SALES'), 689 | ('reportSubType', default_subtypes.get(filters.get('reportType', 'SALES'), 'SUMMARY')), 690 | ('version', default_versions.get(filters.get('reportType', 'SALES'), '1_0')), 691 | # vendorNumber is required but we cannot provide a default value 692 | ): 693 | if required_key not in filters: 694 | filters[required_key] = default_value 695 | 696 | url = "%s%s" % (BASE_API, SalesReport.endpoint) 697 | url = self._build_query_parameters(url, filters) 698 | response = self._api_call(url) 699 | 700 | if save_to: 701 | file = Path(save_to) 702 | file.write_text(response, 'utf-8') 703 | 704 | return response 705 | -------------------------------------------------------------------------------- /appstoreconnect/resources.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from abc import ABC, abstractmethod 3 | import sys 4 | 5 | 6 | class Resource(ABC): 7 | relationships = {} 8 | 9 | def __init__(self, data, api): 10 | self._data = data 11 | self._api = api 12 | 13 | def __getattr__(self, item): 14 | if item == 'id': 15 | return self._data.get('id') 16 | if item in self._data.get('attributes', {}): 17 | return self._data.get('attributes', {})[item] 18 | if item in self.relationships: 19 | def getter(): 20 | # Try to fetch relationship 21 | nonlocal item 22 | url = self._data.get('relationships', {})[item]['links']['related'] 23 | if self.relationships[item]['multiple']: 24 | return self._api.get_related_resources(full_url=url) 25 | else: 26 | return self._api.get_related_resource(full_url=url) 27 | return getter 28 | 29 | raise AttributeError('%s has no attributes %s' % (self.type_name, item)) 30 | 31 | def __repr__(self): 32 | return '%s id %s' % (self.type_name, self._data.get('id')) 33 | 34 | def __dir__(self): 35 | return ['id'] + list(self._data.get('attributes', {}).keys()) + list(self._data.get('relationships', {}).keys()) 36 | 37 | @property 38 | def type_name(self): 39 | return type(self).__name__ 40 | 41 | @property 42 | @abstractmethod 43 | def endpoint(self): 44 | pass 45 | 46 | 47 | # Beta Testers and Groups 48 | 49 | class BetaTester(Resource): 50 | endpoint = '/v1/betaTesters' 51 | type = 'betaTesters' 52 | attributes = ['email', 'firstName', 'inviteType', 'lastName'] 53 | relationships = { 54 | 'apps': {'multiple': True}, 55 | 'betaGroups': {'multiple': True}, 56 | 'builds': {'multiple': True}, 57 | } 58 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betatester' 59 | 60 | 61 | class BetaGroup(Resource): 62 | endpoint = '/v1/betaGroups' 63 | type = 'betaGroups' 64 | attributes = ['isInternalGroup', 'name', 'publicLink', 'publicLinkEnabled', 'publicLinkId', 'publicLinkLimit', 'publicLinkLimitEnabled', 'createdDate'] 65 | relationships = { 66 | 'app': {'multiple': False}, 67 | 'betaTesters': {'multiple': True}, 68 | 'builds': {'multiple': True}, 69 | } 70 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betagroup' 71 | 72 | 73 | # App Resources 74 | 75 | class App(Resource): 76 | endpoint = '/v1/apps' 77 | type = 'apps' 78 | attributes = ['bundleId', 'name', 'primaryLocale', 'sku'] 79 | relationships = { 80 | 'betaLicenseAgreement': {'multiple': False}, 81 | 'preReleaseVersions': {'multiple': True}, 82 | 'betaAppLocalizations': {'multiple': True}, 83 | 'betaGroups': {'multiple': True}, 84 | 'betaTesters': {'multiple': True}, 85 | 'builds': {'multiple': True}, 86 | 'betaAppReviewDetail': {'multiple': False}, 87 | } 88 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/app' 89 | 90 | 91 | class PreReleaseVersion(Resource): 92 | endpoint = '/v1/preReleaseVersions' 93 | type = 'preReleaseVersions' 94 | attributes = ['platform', 'version'] 95 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/preReleaseVersion/attributes' 96 | 97 | 98 | class BetaAppLocalization(Resource): 99 | endpoint = '/v1/betaAppLocalizations' 100 | type = 'betaAppLocalizations' 101 | attributes = ['description', 'feedbackEmail', 'locale', 'marketingUrl', 'privacyPolicyUrl', 'tvOsPrivacyPolicy'] 102 | relationships = { 103 | 'app': {'multiple': False} 104 | } 105 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betaAppLocalization/attributes' 106 | 107 | 108 | class AppEncryptionDeclaration(Resource): 109 | endpoint = '/v1/appEncryptionDeclarations' 110 | type = 'appEncryptionDeclarations' 111 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/appEncryptionDeclaration/attributes' 112 | 113 | 114 | class BetaLicenseAgreement(Resource): 115 | endpoint = '/v1/betaLicenseAgreements' 116 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betaLicenseAgreement/attributes' 117 | 118 | 119 | # Build Resources 120 | 121 | class Build(Resource): 122 | endpoint = '/v1/builds' 123 | type = 'builds' 124 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/build/attributes' 125 | relationships = { 126 | 'app': {'multiple': False}, 127 | 'appEncryptionDeclaration': {'multiple': False}, 128 | 'individualTesters': {'multiple': True}, 129 | 'preReleaseVersion': {'multiple': False}, 130 | 'betaBuildLocalizations': {'multiple': True}, 131 | 'buildBetaDetail': {'multiple': False}, 132 | 'betaAppReviewSubmission': {'multiple': False}, 133 | 'appStoreVersion': {'multiple': False}, 134 | 'icons': {'multiple': True}, 135 | } 136 | 137 | 138 | class BuildBetaDetail(Resource): 139 | endpoint = '/v1/buildBetaDetails' 140 | type = 'buildBetaDetails' 141 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/buildBetaDetail/attributes' 142 | 143 | 144 | class BetaBuildLocalization(Resource): 145 | endpoint = '/v1/betaBuildLocalizations' 146 | type = 'betaBuildLocalizations' 147 | attributes = ['locale', 'whatsNew'] 148 | relationships = { 149 | 'build': {'multiple': False}, 150 | } 151 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betaBuildLocalization/attributes' 152 | 153 | 154 | class BetaAppReviewDetail(Resource): 155 | endpoint = '/v1/betaAppReviewDetails' 156 | type = 'betaAppReviewDetails' 157 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betaAppReviewDetail/attributes' 158 | 159 | 160 | class BetaAppReviewSubmission(Resource): 161 | endpoint = '/v1/betaAppReviewSubmissions' 162 | type = 'betaAppReviewSubmissions' 163 | attributes = ['betaReviewState'] 164 | relationships = { 165 | 'build': {'multiple': False}, 166 | } 167 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/betaAppReviewSubmission/attributes' 168 | 169 | 170 | # Users and Roles 171 | 172 | class User(Resource): 173 | endpoint = '/v1/users' 174 | type = 'users' 175 | attributes = ['allAppsVisible', 'provisioningAllowed', 'roles'] 176 | relationships = { 177 | 'visibleApps': {'multiple': True}, 178 | } 179 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/user/attributes' 180 | 181 | 182 | class UserInvitation(Resource): 183 | endpoint = '/v1/userInvitations' 184 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/userinvitation/attributes' 185 | 186 | 187 | # Provisioning 188 | class BundleId(Resource): 189 | endpoint = '/v1/bundleIds' 190 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/bundleid/attributes' 191 | 192 | 193 | class Certificate(Resource): 194 | endpoint = '/v1/certificates' 195 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/certificate/attributes' 196 | 197 | 198 | class Device(Resource): 199 | endpoint = '/v1/devices' 200 | type = 'devices' 201 | attributes = ['name', 'platform', 'udid', 'status'] 202 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/device/attributes' 203 | 204 | 205 | class Profile(Resource): 206 | endpoint = '/v1/profiles' 207 | documentation = 'https://developer.apple.com/documentation/appstoreconnectapi/profile/attributes' 208 | 209 | 210 | # Reporting 211 | 212 | class FinanceReport(Resource): 213 | endpoint = '/v1/financeReports' 214 | filters = 'https://developer.apple.com/documentation/appstoreconnectapi/download_finance_reports' 215 | 216 | 217 | class SalesReport(Resource): 218 | endpoint = '/v1/salesReports' 219 | filters = 'https://developer.apple.com/documentation/appstoreconnectapi/download_sales_and_trends_reports' 220 | 221 | 222 | # create an index of Resources by type 223 | resources = {} 224 | for name, obj in inspect.getmembers(sys.modules[__name__]): 225 | if inspect.isclass(obj) and issubclass(obj, Resource) and hasattr(obj, 'type') and obj != Resource: 226 | resources[getattr(obj, 'type')] = obj 227 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from appstoreconnect import Api, UserRole 5 | 6 | 7 | if __name__ == "__main__": 8 | key_id = sys.argv[1] 9 | key_file = sys.argv[2] 10 | issuer_id = sys.argv[3] 11 | api = Api(key_id, key_file, issuer_id) 12 | 13 | # list all apps 14 | apps = api.list_apps() 15 | for app in apps: 16 | print(app.name, app.sku) 17 | 18 | # filter apps 19 | apps = api.list_apps(filters={'sku': 'DINORUSH', 'name': 'Dino Rush'}) 20 | print("%d apps found" % len(apps)) 21 | 22 | # modify a user 23 | user = api.list_users(filters={'username': 'finance@nemoidstudio.com'})[0] 24 | api.modify_user_account(user, roles=[UserRole.FINANCE, UserRole.APP_MANAGER, UserRole.ACCESS_TO_REPORTS]) 25 | 26 | # download sales report 27 | api.download_sales_and_trends_reports( 28 | filters={'vendorNumber': '123456789', 'frequency': 'WEEKLY', 'reportDate': '2019-06-09'}, save_to='report.csv') 29 | 30 | # download finance report 31 | api.download_finance_reports(filters={'vendorNumber': '123456789', 'reportDate': '2019-06'}, save_to='finance.csv') 32 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | . 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import os 6 | 7 | from setuptools import find_packages, setup 8 | 9 | NAME = 'appstoreconnect' 10 | DESCRIPTION = 'A Python wrapper around Apple App Store Api' 11 | URL = 'https://ponytech.net/projects/app-store-connect' 12 | EMAIL = 'contact@ponytech.net' 13 | AUTHOR = 'Ponytech' 14 | REQUIRES_PYTHON = '>=3.6.0' 15 | VERSION = None 16 | 17 | REQUIRED = [ 18 | 'requests>=2.20.1,==2.*', 19 | 'PyJWT>=1.6.4,==1.*', 20 | 'cryptography>=2.6.1', 21 | ] 22 | 23 | EXTRAS = { 24 | } 25 | 26 | here = os.path.abspath(os.path.dirname(__file__)) 27 | 28 | try: 29 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 30 | long_description = '\n' + f.read() 31 | except FileNotFoundError: 32 | long_description = DESCRIPTION 33 | 34 | about = {} 35 | if not VERSION: 36 | with open(os.path.join(here, NAME, '__version__.py')) as f: 37 | exec(f.read(), about) 38 | else: 39 | about['__version__'] = VERSION 40 | 41 | setup( 42 | name=NAME, 43 | version=about['__version__'], 44 | description=DESCRIPTION, 45 | long_description=long_description, 46 | long_description_content_type='text/markdown', 47 | author=AUTHOR, 48 | author_email=EMAIL, 49 | python_requires=REQUIRES_PYTHON, 50 | url=URL, 51 | packages=find_packages(exclude=('tests',)), 52 | install_requires=REQUIRED, 53 | extras_require=EXTRAS, 54 | include_package_data=True, 55 | license='MIT', 56 | classifiers=[ 57 | 'License :: OSI Approved :: MIT License', 58 | 'Programming Language :: Python', 59 | 'Programming Language :: Python :: 3', 60 | 'Programming Language :: Python :: 3.6', 61 | 'Programming Language :: Python :: 3.7', 62 | 'Programming Language :: Python :: 3.8', 63 | 'Programming Language :: Python :: 3.9', 64 | ], 65 | ) 66 | --------------------------------------------------------------------------------