Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Allow using custom tracks with google-play #215

Merged
merged 10 commits into from
Apr 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,56 @@
Version 0.24.0
-------------

Changes in this release improve usability of tool `google-play`. Updates are from [PR #215](https://github.com/codemagic-ci-cd/cli-tools/pull/215) and [PR #216](https://github.com/codemagic-ci-cd/cli-tools/pull/216).

**Breaking**

**None of the breaking changes have an effect on command line usage**, only the Python API of is affected.

- Method signature changes:
- Signature of `GooglePlay` (tool `google-play`) initialization was changed:
- positional argument `package_name` was removed,
- keyword argument `log_requests` was removed,
- keyword argument `json_output` was removed,
- positional argument `credentials` accepts now Google Play service account credentials both as JSON `str` and parsed `dict`.
- Signature of `GooglePlayDeveloperAPIClient` initialization was changed and simplified:
- positional argument `resource_printer` was removed,
- positional argument `package_name` was removed.
- `GooglePlay` method `get_latest_build_number` requires `package_name` argument,
- `GooglePlayDeveloperAPIClient` methods `create_edit` and `delete_edit` require `package_name` argument,
- property `max_version_code` of `Track` was converted into a method `get_max_version_code()`.
- CLI argument definitions for `google-play` were updated and moved from `codemagic.tools.google_play` to `codemagic.tools.google_play.arguments`.
- Removed definitions:
- enumeration `codemagic.google_play.resources.TrackName` was removed.
- class `codemagic.google_play.ResourcePrinter` was removed.
- exception `codemagic.google_play.VersionCodeFromTrackError` was removed.
- removals in `GooglePlayDeveloperAPIClient`:
- method `get_track_information` was removed,
- property `service` was removed.

**Features**

- Update tool `google-play`:
- Allow using custom release tracks with action `google-play get-latest-build-number`.
- Add new action `google-play tracks get` to get information about specific release track for given package name.
- Add new action `google-play tracks list` to get information about all available release tracks for given package name.

**Development**

- Module `codemagic.tools.google_play` was refactored by splitting single source file into a subpackage.
- Define actions group `TracksActionGroup` for working with tracks.
- Move `get_latest_build_number` action / method implementation into separate subclass `GetLatestBuildNumberAction`.
- Rework the internals of `GooglePlayDeveloperAPIClient`:
- add context manager to handle `edit` lifecycle so that callers don't have to take care of deletion afterwards,
- add new methods `get_track` and `list_tracks`,
- service resource instance was removed from class instance as it wasn't thread safe.
- Update `mypy` version.
- Add more tests for `GooglePlay` tool.

**Docs**

- TBD

Version 0.23.1
-------------

Expand Down
610 changes: 264 additions & 346 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/codemagic/__version__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
__title__ = 'codemagic-cli-tools'
__description__ = 'CLI tools used in Codemagic builds'
__version__ = '0.23.1'
__version__ = '0.24.0'
__url__ = 'https://github.com/codemagic-ci-cd/cli-tools'
__licence__ = 'GNU General Public License v3.0'
4 changes: 2 additions & 2 deletions src/codemagic/cli/cli_process_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def unblock(self):
def read(self, buffer_size=1024) -> bytes:
# https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-readfile
try:
chunk, _result = winapi.ReadFile(self._pipe_handle, buffer_size, 0)
chunk, _result = winapi.ReadFile(self._pipe_handle, buffer_size, 0) # type: ignore
except BrokenPipeError:
chunk = b''
return self._bytes(chunk or b'')
Expand All @@ -100,7 +100,7 @@ def read_all(self) -> bytes:
# https://docs.microsoft.com/en-us/windows/win32/api/namedpipeapi/nf-namedpipeapi-peeknamedpipe
# Check how much is there still remaining in the pipe to be read and use that as the final buffer size
try:
_data, buffer_size, *_rest = winapi.PeekNamedPipe(self._pipe_handle, 1)
_data, buffer_size, *_rest = winapi.PeekNamedPipe(self._pipe_handle, 1) # type: ignore
except BrokenPipeError:
# The pipe has already been ended
return b''
Expand Down
3 changes: 0 additions & 3 deletions src/codemagic/google_play/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
from .api_error import GooglePlayDeveloperAPIClientError
from .api_error import VersionCodeFromTrackError
from .resource_printer import ResourcePrinter
from .resources import TrackName
207 changes: 136 additions & 71 deletions src/codemagic/google_play/api_client.py
Original file line number Diff line number Diff line change
@@ -1,98 +1,163 @@
import contextlib
import json
from functools import lru_cache
from typing import AnyStr
from typing import List
from typing import Optional
from typing import Union

import httplib2
from googleapiclient import discovery
from googleapiclient import errors
from oauth2client.service_account import ServiceAccountCredentials

from codemagic.utilities import log

from .api_error import AuthorizationError
from .api_error import CredentialsError
from .api_error import EditError
from .api_error import VersionCodeFromTrackError
from .resource_printer import ResourcePrinter
from .api_error import GetResourceError
from .api_error import GooglePlayDeveloperAPIClientError
from .api_error import ListResourcesError
from .resources import Edit
from .resources import Track
from .resources import TrackName

CredentialsJson = Union[AnyStr, dict]


class GooglePlayDeveloperAPIClient:
SCOPE = 'androidpublisher'
SCOPE_URI = f'https://www.googleapis.com/auth/{SCOPE}'
API_VERSION = 'v3'

_service_instance: Optional[discovery.Resource] = None

def __init__(self,
credentials: str,
package_name: str,
resource_printer: ResourcePrinter):
def __init__(self, service_account_json_keyfile: CredentialsJson):
"""
:param credentials: Your Gloud Service account credentials with JSON key type
:param package_name: package name of the app in Google Play Console (Ex: com.google.example)
:param resource_printer: printer initialized in google-play tool
:param service_account_json_keyfile: Your Gcloud Service account credentials with JSON key type
"""
self._credentials = credentials
self.package_name = package_name
self.resource_printer = resource_printer
self._service_account_json_keyfile = service_account_json_keyfile
self._logger = log.get_logger(self.__class__)

def get_edit_resource(self):
return self.service.edits() # type: ignore
@classmethod
def _get_edit_id(cls, edit: Union[str, Edit]) -> str:
if isinstance(edit, Edit):
return edit.id
return edit

def _get_json_keyfile_dict(self) -> dict:
if isinstance(self._service_account_json_keyfile, dict):
return self._service_account_json_keyfile
try:
return json.loads(self._service_account_json_keyfile)
except ValueError as ve:
message = 'Unable to parse service account credentials, must be a valid json'
raise GooglePlayDeveloperAPIClientError(message) from ve

@lru_cache(1)
def _get_android_publisher_service(self):
json_keyfile = self._get_json_keyfile_dict()
http = httplib2.Http()
try:
service_account_credentials = ServiceAccountCredentials.from_json_keyfile_dict(
json_keyfile,
scopes=self.SCOPE_URI,
)
http = service_account_credentials.authorize(http)
return discovery.build(
self.SCOPE,
self.API_VERSION,
http=http,
cache_discovery=False,
)
except (errors.Error, errors.HttpError) as e:
raise AuthorizationError(e) from e

@property
def android_publishing_service(self):
return self._get_android_publisher_service()

@property
def service(self) -> discovery.Resource:
if self._service_instance is None:
try:
json_credentials = json.loads(str(self._credentials))
except json.decoder.JSONDecodeError:
raise CredentialsError()

http = httplib2.Http()
try:
service_account_credentials = ServiceAccountCredentials.from_json_keyfile_dict(
json_credentials, scopes=self.SCOPE_URI)
http = service_account_credentials.authorize(http)
self._service_instance = discovery.build(self.SCOPE, self.API_VERSION, http=http, cache_discovery=False)
except errors.HttpError as e:
raise AuthorizationError(e._get_reason() or 'Http Error')
except errors.Error as e:
raise AuthorizationError(str(e))
return self._service_instance

def create_edit(self) -> Edit:
self.resource_printer.log_request(f'Create an edit for the package "{self.package_name}"')
def edits_service(self):
return self.android_publishing_service.edits()

def create_edit(self, package_name: str) -> Edit:
self._logger.debug(f'Create an edit for the package {package_name!r}')
try:
edit_response = self.get_edit_resource().insert(body={}, packageName=self.package_name).execute()
resource = Edit(**edit_response)
self.resource_printer.print_resource(resource)
return resource
except errors.HttpError as e:
raise EditError('create', self.package_name, e._get_reason() or 'Http Error')
except errors.Error as e:
raise EditError('create', self.package_name, str(e))

def delete_edit(self, edit_id: str) -> None:
self.resource_printer.log_request(f'Delete the edit "{edit_id}" for the package "{self.package_name}"')
edit_request = self.edits_service.insert(body={}, packageName=package_name)
edit_response = edit_request.execute()
self._logger.debug(f'Created edit {edit_response} for package {package_name!r}')
except (errors.Error, errors.HttpError) as e:
raise EditError('create', package_name, e) from e
else:
return Edit(**edit_response)

def delete_edit(self, edit: Union[str, Edit], package_name: str) -> None:
edit_id = self._get_edit_id(edit)
self._logger.debug(f'Delete the edit {edit_id!r} for the package {package_name!r}')
try:
self.get_edit_resource().delete(packageName=self.package_name, editId=edit_id).execute()
self._service_instance = None
except errors.HttpError as e:
raise EditError('delete', self.package_name, e._get_reason() or 'Http Error')
except errors.Error as e:
raise EditError('delete', self.package_name, str(e))

def get_track_information(self, edit_id: str, track_name: TrackName) -> Track:
self.resource_printer.log_request(
f'Get information about the track "{track_name.value}" '
f'for the package "{self.package_name}"',
)
delete_request = self.edits_service.delete(
packageName=package_name,
editId=edit_id,
)
delete_request.execute()
self._logger.debug(f'Deleted edit {edit_id} for package {package_name!r}')
except (errors.Error, errors.HttpError) as e:
raise EditError('delete', package_name, e) from e

@contextlib.contextmanager
def use_app_edit(self, package_name: str):
edit = self.create_edit(package_name)
try:
yield edit
finally:
self.delete_edit(edit, package_name)

def get_track(
self,
package_name: str,
track_name: str,
edit: Optional[Union[str, Edit]] = None,
) -> Track:
if edit is not None:
edit_id = self._get_edit_id(edit)
return self._get_track(package_name, track_name, edit_id)
with self.use_app_edit(package_name) as _edit:
return self._get_track(package_name, track_name, _edit.id)

def _get_track(self, package_name: str, track_name: str, edit_id: str) -> Track:
self._logger.debug(f'Get track {track_name!r} for package {package_name!r} using edit {edit_id}')
try:
track_request = self.edits_service.tracks().get(
packageName=package_name,
editId=edit_id,
track=track_name,
)
track_response = track_request.execute()
self._logger.debug(f'Got track {track_name!r} for package {package_name!r}: {track_response}')
except (errors.Error, errors.HttpError) as e:
raise GetResourceError('track', package_name, e) from e
else:
return Track(**track_response)

def list_tracks(
self,
package_name: str,
edit: Optional[Union[str, Edit]] = None,
) -> List[Track]:
if edit is not None:
edit_id = self._get_edit_id(edit)
return self._list_tracks(package_name, edit_id)
with self.use_app_edit(package_name) as _edit:
return self._list_tracks(package_name, _edit.id)

def _list_tracks(self, package_name: str, edit_id: str) -> List[Track]:
self._logger.debug(f'List tracks for package {package_name!r} using edit {edit_id}')
try:
track_response = self.get_edit_resource().tracks().get(
packageName=self.package_name, editId=edit_id, track=track_name.value).execute()
resource = Track(**track_response)
self.resource_printer.print_resource(resource)
return resource
except errors.HttpError as e:
raise VersionCodeFromTrackError(track_name.value, e._get_reason() or 'Http Error')
except errors.Error as e:
raise VersionCodeFromTrackError(track_name.value, str(e))
tracks_request = self.edits_service.tracks().list(
packageName=package_name,
editId=edit_id,
)
tracks_response = tracks_request.execute()
self._logger.debug(f'Got tracks for package {package_name!r}: {tracks_response}')
except (errors.Error, errors.HttpError) as e:
raise ListResourcesError('tracks', package_name, e) from e
else:
return [Track(**track) for track in tracks_response['tracks']]
61 changes: 50 additions & 11 deletions src/codemagic/google_play/api_error.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,62 @@
class GooglePlayDeveloperAPIClientError(Exception):
pass
from abc import ABC
from abc import abstractmethod
from typing import Union

from googleapiclient import errors


class CredentialsError(GooglePlayDeveloperAPIClientError):
def __init__(self):
super().__init__('Unable to parse service account credentials, must be a valid json')
class GooglePlayDeveloperAPIClientError(Exception):
@classmethod
def _get_error_reason(cls, error: Union[errors.Error, errors.HttpError]) -> str:
if isinstance(error, errors.Error):
return str(error)
else:
reason = error._get_reason()
return reason or 'Http Error'


class AuthorizationError(GooglePlayDeveloperAPIClientError):
def __init__(self, reason: str):
def __init__(self, error: Union[errors.Error, errors.HttpError]):
reason = self._get_error_reason(error)
super().__init__(f'Unable to authorize with provided credentials. {reason}')


class EditError(GooglePlayDeveloperAPIClientError):
def __init__(self, action: str, package_name: str, reason: str):
def __init__(self, action: str, package_name: str, error: Union[errors.Error, errors.HttpError]):
reason = self._get_error_reason(error)
super().__init__(f'Unable to {action} an edit for package "{package_name}". {reason}')


class VersionCodeFromTrackError(GooglePlayDeveloperAPIClientError):
def __init__(self, track: str, reason: str):
super().__init__(
f'Failed to get version code from Google Play from {track} track. {reason}')
class _RequestError(GooglePlayDeveloperAPIClientError, ABC):
def __init__(
self,
resource_description: str,
package_name: str,
request_error: Union[errors.Error, errors.HttpError],
):
self.package_name = package_name
self.request_error = request_error
super().__init__(self._get_message(resource_description))

def _get_reason(self) -> str:
return self._get_error_reason(self.request_error)

@abstractmethod
def _get_message(self, resource_description: str) -> str:
raise NotImplementedError()


class GetResourceError(_RequestError):
def _get_message(self, resource_description: str) -> str:
return (
f'Failed to get {resource_description} from Google Play for package "{self.package_name}". '
f'{self._get_reason()}'
)


class ListResourcesError(_RequestError):
def _get_message(self, resource_description: str) -> str:
return (
f'Failed to list {resource_description} from Google Play for package "{self.package_name}". '
f'{self._get_reason()}'
)
Loading