Skip to content

Commit

Permalink
Splits read-only and writable transaction logic
Browse files Browse the repository at this point in the history
  • Loading branch information
Mitchell Hentges committed Jul 12, 2019
1 parent f41ad26 commit 6d98695
Show file tree
Hide file tree
Showing 10 changed files with 280 additions and 291 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

## [upcoming]

### Changed
* Separates read-only logic from logic that requires a transaction

## [4.1.0] - 2019-07-10
### Removed
* `--skip-check-package-names`. When pushing or checking an APK, expected package names must always be provided
Expand Down
17 changes: 8 additions & 9 deletions mozapkpublisher/check_rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@
import requests

from argparse import ArgumentParser
from mozapkpublisher.common.googleplay import EditService, add_general_google_play_arguments

from mozapkpublisher.common.googleplay import add_general_google_play_arguments, \
ReadOnlyGooglePlay, GooglePlayConnection

DAY = 24 * 60 * 60

logger = logging.getLogger(__name__)


def check_rollout(edit_service, days):
def check_rollout(google_play, days):
"""Check if package_name has a release on staged rollout for too long"""
track_status = edit_service.get_track_status(track='production')
track_status = google_play.get_track_status(track='production')
releases = track_status['releases']
for release in releases:
if release['status'] == 'inProgress':
Expand All @@ -40,12 +40,11 @@ def main():
type=int, default=7)
config = parser.parse_args()

edit_service = EditService(
google_play = ReadOnlyGooglePlay.create(GooglePlayConnection.open(
config.service_account,
config.google_play_credentials_file.name,
package_name='org.mozilla.firefox',
)
for (release, age) in check_rollout(edit_service, config.days):
config.google_play_credentials_file.name
), 'org.mozilla.firefox')
for (release, age) in check_rollout(google_play, config.days):
print('fennec {} is on staged rollout at {}% but it shipped {} days ago'.format(
release['name'], int(release['userFraction'] * 100), int(age / DAY)))

Expand Down
7 changes: 0 additions & 7 deletions mozapkpublisher/common/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,6 @@ def __init__(self, checked_file, expected, actual):
)


class NoTransactionError(LoggedError):
def __init__(self, package_name):
super(NoTransactionError, self).__init__(
'Transaction has not been started for package "{}"'.format(package_name)
)


class NotMultiLocaleApk(LoggedError):
def __init__(self, apk_path, unique_locales):
super(NotMultiLocaleApk, self).__init__(
Expand Down
231 changes: 130 additions & 101 deletions mozapkpublisher/common/googleplay.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"""

import argparse
from contextlib import contextmanager

import httplib2
import json
import logging
Expand All @@ -23,7 +25,7 @@
# HACK: importing mock in production is useful for option `--do-not-contact-google-play`
from unittest.mock import MagicMock

from mozapkpublisher.common.exceptions import NoTransactionError, WrongArgumentGiven
from mozapkpublisher.common.exceptions import WrongArgumentGiven

logger = logging.getLogger(__name__)

Expand All @@ -41,57 +43,118 @@ def add_general_google_play_arguments(parser):
--service-account and --credentials must still be provided (you can just fill them with random string and file).''')


class EditService(object):
def __init__(self, service_account, credentials_file_path, package_name, commit=False, contact_google_play=True):
self._contact_google_play = contact_google_play
if self._contact_google_play:
general_service = _connect(service_account, credentials_file_path)
self._service = general_service.edits()
else:
self._service = _craft_google_play_service_mock()
logger.warning('`--do-not-contact-google-play` option was given. Not a single request to Google Play will be made!')
class GooglePlayConnection:
def __init__(self, edit_resource):
self._edit_resource = edit_resource

self._package_name = package_name
self._commit = commit
self.start_new_transaction()

def start_new_transaction(self):
result = self._service.insert(body={}, packageName=self._package_name).execute()
self._edit_id = result['id']

def transaction_required(method):
def _transaction_required(*args, **kwargs):
edit_service = args[0]
if edit_service._edit_id is None:
raise NoTransactionError(edit_service._package_name)

return method(*args, **kwargs)
return _transaction_required

@transaction_required
def commit_transaction(self):
if self._commit:
self._service.commit(editId=self._edit_id, packageName=self._package_name).execute()
logger.info('Changes committed')
logger.debug('edit_id "{}" for package "{}" has been committed'.format(self._edit_id, self._package_name))
else:
logger.warning('`commit` option was not given. Transaction not committed.')
def get_edits_resource(self):
return self._edit_resource

@staticmethod
def open(service_account, credentials_file_path):
# Create an httplib2.Http object to handle our HTTP requests an
# authorize it with the Credentials. Note that the first parameter,
# service_account_name, is the Email address created for the Service
# account. It must be the email address associated with the key that
# was created.
scope = 'https://www.googleapis.com/auth/androidpublisher'
credentials = ServiceAccountCredentials.from_p12_keyfile(
service_account,
credentials_file_path,
scopes=scope
)
http = httplib2.Http()
http = credentials.authorize(http)

service = build(serviceName='androidpublisher', version='v3', http=http,
cache_discovery=False)

return GooglePlayConnection(service.edits())


class _ExecuteDummy:
def __init__(self, return_value):
self._return_value = return_value

def execute(self):
return self._return_value


class MockGooglePlayConnection:
@staticmethod
def get_edits_resource():
edit_service_mock = MagicMock()

edit_service_mock.insert = lambda *args, **kwargs: _ExecuteDummy(
{'id': 'fake-transaction-id'})
edit_service_mock.commit = lambda *args, **kwargs: _ExecuteDummy(None)

apks_mock = MagicMock()
apks_mock.upload = lambda *args, **kwargs: _ExecuteDummy(
{'versionCode': 'fake-version-code'})
edit_service_mock.apks = lambda *args, **kwargs: apks_mock

update_mock = MagicMock()
update_mock.update = lambda *args, **kwargs: _ExecuteDummy('fake-update-response')
edit_service_mock.tracks = lambda *args, **kwargs: update_mock
edit_service_mock.listings = lambda *args, **kwargs: update_mock
edit_service_mock.apklistings = lambda *args, **kwargs: update_mock

return edit_service_mock

self._edit_id = None

@transaction_required
def connection_for_options(contact_google_play, service_account, credentials_file):
if contact_google_play:
return GooglePlayConnection.open(service_account, credentials_file.name)
else:
logger.warning('Not a single request to Google Play will be made, since `contact_google_play` was set')
return MockGooglePlayConnection()


class ReadOnlyGooglePlay:
"""Read-only access to the Google Play store
Create an instance by calling ReadOnlyGooglePlay.create() instead of using the constructor
"""

def __init__(self, edit_resource, edit_id, package_name):
self._edit_resource = edit_resource
self._edit_id = edit_id
self._package_name = package_name

def get_track_status(self, track):
response = self._service.tracks().get(
editId=self._edit_id, track=track, packageName=self._package_name
response = self._edit_resource.tracks().get(
editId=self._edit_id,
track=track,
packageName=self._package_name
).execute()
logger.debug(u'Track "{}" has status: {}'.format(track, response))
logger.debug('Track "{}" has status: {}'.format(track, response))
return response

@transaction_required
@staticmethod
def create(connection, package_name):
edit_resource = connection.get_edits_resource()
edit_id = edit_resource.insert(body={}, packageName=package_name).execute()['id']
return ReadOnlyGooglePlay(edit_resource, edit_id, package_name)


class WritableGooglePlay(ReadOnlyGooglePlay):
"""Read-write access to the Google Play store
Create an instance by calling WritableGooglePlay.transaction(), instead of using the
constructor. This will automatically handle committing the transaction when the "with" block
ends.
E.g.: `with WritableGooglePlay.transaction() as google_play:`
"""

def __init__(self, edit_resource, edit_id, package_name):
super().__init__(edit_resource, edit_id, package_name)

def upload_apk(self, apk_path):
logger.info('Uploading "{}" ...'.format(apk_path))
try:
response = self._service.apks().upload(
response = self._edit_resource.apks().upload(
editId=self._edit_id,
packageName=self._package_name,
media_body=apk_path
Expand All @@ -103,19 +166,17 @@ def upload_apk(self, apk_path):
# XXX This is really how data is returned by the googleapiclient.
error_content = json.loads(e.content)
errors = error_content['error']['errors']
if (
len(errors) == 1 and
errors[0]['reason'] in (
'apkUpgradeVersionConflict', 'apkNotificationMessageKeyUpgradeVersionConflict'
)
):
if (len(errors) == 1 and errors[0]['reason'] in (
'apkUpgradeVersionConflict',
'apkNotificationMessageKeyUpgradeVersionConflict'
)):
logger.warning(
'APK "{}" has already been uploaded on Google Play. Skipping...'.format(apk_path)
'APK "{}" has already been uploaded on Google Play. Skipping...'.format(
apk_path)
)
return
raise

@transaction_required
def update_track(self, track, version_codes, rollout_percentage=None):
body = {
u'releases': [{
Expand All @@ -125,32 +186,32 @@ def update_track(self, track, version_codes, rollout_percentage=None):
}
if rollout_percentage is not None:
if rollout_percentage < 0 or rollout_percentage > 100:
raise WrongArgumentGiven('rollout percentage must be between 0 and 100. Value given: {}'.format(rollout_percentage))
raise WrongArgumentGiven(
'rollout percentage must be between 0 and 100. Value given: {}'.format(
rollout_percentage))

body[u'userFraction'] = rollout_percentage / 100.0 # Ensure float in Python 2

response = self._service.tracks().update(
response = self._edit_resource.tracks().update(
editId=self._edit_id, track=track, packageName=self._package_name, body=body
).execute()
logger.info('Track "{}" updated with: {}'.format(track, body))
logger.debug('Track update response: {}'.format(response))

@transaction_required
def update_listings(self, language, title, full_description, short_description):
body = {
'fullDescription': full_description,
'shortDescription': short_description,
'title': title,
}
response = self._service.listings().update(
response = self._edit_resource.listings().update(
editId=self._edit_id, packageName=self._package_name, language=language, body=body
).execute()
logger.info(u'Listing for language "{}" has been updated with: {}'.format(language, body))
logger.debug(u'Listing response: {}'.format(response))

@transaction_required
def update_whats_new(self, language, apk_version_code, whats_new):
response = self._service.apklistings().update(
response = self._edit_resource.apklistings().update(
editId=self._edit_id, packageName=self._package_name, language=language,
apkVersionCode=apk_version_code, body={'recentChanges': whats_new}
).execute()
Expand All @@ -159,48 +220,16 @@ def update_whats_new(self, language, apk_version_code, whats_new):
))
logger.debug(u'Apk listing response: {}'.format(response))


def _craft_google_play_service_mock():
edit_service_mock = MagicMock()

edit_service_mock.insert = lambda *args, **kwargs: _ExecuteDummy({'id': 'fake-transaction-id'})
edit_service_mock.commit = lambda *args, **kwargs: _ExecuteDummy(None)

apks_mock = MagicMock()
apks_mock.upload = lambda *args, **kwargs: _ExecuteDummy({'versionCode': 'fake-version-code'})
edit_service_mock.apks = lambda *args, **kwargs: apks_mock

update_mock = MagicMock()
update_mock.update = lambda *args, **kwargs: _ExecuteDummy('fake-update-response')
edit_service_mock.tracks = lambda *args, **kwargs: update_mock
edit_service_mock.listings = lambda *args, **kwargs: update_mock
edit_service_mock.apklistings = lambda *args, **kwargs: update_mock

return edit_service_mock


class _ExecuteDummy():
def __init__(self, return_value):
self._return_value = return_value

def execute(self):
return self._return_value


def _connect(service_account, credentials_file_path):
""" Connect to the google play interface
"""

# Create an httplib2.Http object to handle our HTTP requests an
# authorize it with the Credentials. Note that the first parameter,
# service_account_name, is the Email address created for the Service
# account. It must be the email address associated with the key that
# was created.
scope = 'https://www.googleapis.com/auth/androidpublisher'
credentials = ServiceAccountCredentials.from_p12_keyfile(service_account, credentials_file_path, scopes=scope)
http = httplib2.Http()
http = credentials.authorize(http)

service = build(serviceName='androidpublisher', version='v3', http=http, cache_discovery=False)

return service
@staticmethod
@contextmanager
def transaction(connection, package_name, do_not_commit=False):
edit_resource = connection.get_edits_resource()
edit_id = edit_resource.insert(body={}, packageName=package_name).execute()['id']
google_play = WritableGooglePlay(edit_resource, edit_id, package_name)
yield google_play
if do_not_commit:
logger.warning('Transaction not committed, since `do_not_commit` was set')
else:
edit_resource.commit(editId=edit_id, packageName=package_name)
logger.info('Changes committed')
logger.debug('edit_id "{}" for "{}" has been committed'.format(edit_id, package_name))
Loading

0 comments on commit 6d98695

Please sign in to comment.