PK!alibhockey/__init__.py#!/usr/bin/env python3 """Hockey API wrapper.""" import json import logging import re import time from typing import Any, ClassVar, Dict, List, Optional import requests from libhockey.crashes import HockeyCrashesClient from libhockey.versions import HockeyVersionsClient class HockeyClient: """Class responsible for getting data from HockeyApp through REST calls. :param str access_token: The access token to use for authentication. Leave as None to use KeyVault """ log: logging.Logger token: str versions: HockeyVersionsClient crashes: HockeyCrashesClient def __init__(self, *, access_token: str) -> None: """Initialize the HockeyAppClient with the application id and the token.""" self.log = logging.getLogger("libhockey") self.token = access_token self.crashes = HockeyCrashesClient(self.token, self.log) self.versions = HockeyVersionsClient(self.token, self.log) PK![d{{libhockey/constants.py#!/usr/bin/env python3 """Constants for use with the API.""" API_BASE_URL: str = "https://rink.hockeyapp.net/api/2/apps" PK!I [[libhockey/crashes.py"""Hockey crashes API wrappers.""" import logging from typing import Iterator, List, Optional import deserialize import libhockey.constants from libhockey.derived_client import HockeyDerivedClient @deserialize.key("identifier", "id") @deserialize.key("crash_method", "method") @deserialize.key("crash_file", "file") @deserialize.key("crash_class", "class") @deserialize.key("crash_line", "line") class HockeyCrashGroup: """Represents a Hockey crash group.""" identifier: int app_id: int created_at: str updated_at: str status: int reason: Optional[str] last_crash_at: str exception_type: Optional[str] fixed: bool app_version_id: int bundle_version: str bundle_short_version: str number_of_crashes: int grouping_hash: str grouping_type: int pattern: Optional[str] crash_method: Optional[str] crash_file: Optional[str] crash_class: Optional[str] crash_line: Optional[str] def url(self) -> str: """Return the access URL for the crash. :returns: The access URL """ return f"https://rink.hockeyapp.net/manage/apps/{self.app_id}/app_versions/" + \ f"{self.app_version_id}/crash_reasons/{self.identifier}" def __str__(self) -> str: """Generate and return the string representation of the object. :return: A string representation of the object """ return str( { "Exception Type": self.exception_type, "Reason": self.reason, "Method": self.crash_method, "File": self.crash_file, "Class": self.crash_class, "Count": self.number_of_crashes, } ) def __hash__(self) -> int: """Calculate the hash of the object :returns: The hash value of the object :raises Exception: If the language is not English """ properties = [ self.exception_type, self.reason, self.crash_method, self.crash_file, self.crash_class, ] return hash("-".join(map(str, properties))) def __eq__(self, other: object) -> bool: """Determine if the supplied object is equal to self :param other: The object to compare to self :returns: True if they are equal, False otherwise. """ if not isinstance(other, HockeyCrashGroup): return False return self.__hash__() == other.__hash__() class HockeyCrashGroupsResponse: """Represents a Hockey crash groups response.""" crash_reasons: List[HockeyCrashGroup] status: str current_page: int per_page: int total_entries: int total_pages: int @deserialize.key("identifier", "id") class HockeyCrashInstance: """Represents a Hockey crash instance.""" identifier: int app_id: int crash_reason_id: int created_at: str updated_at: str oem: str model: str os_version: str jail_break: bool contact_string: str user_string: str has_log: bool has_description: bool app_version_id: int bundle_version: str bundle_short_version: str class HockeyCrashesResponse: """Represents a Hockey crashes response.""" crash_reason: HockeyCrashGroup crashes: List[HockeyCrashInstance] status: str current_page: int per_page: int total_entries: int total_pages: int class HockeyCrashesClient(HockeyDerivedClient): """Wrapper around the Hockey crashes APIs. :param token: The authentication token :param parent_logger: The parent logger that we will use for our own logging """ def __init__(self, token: str, parent_logger: logging.Logger) -> None: super().__init__("crashes", token, parent_logger) def generate_groups_for_version( self, app_id: str, app_version_id: int, *, page: int = 1 ) -> Iterator[HockeyCrashGroup]: """Get all crash groups for a given hockeyApp version. These crash groups are not guaranteed to be ordered in any particular way :param app_id: The ID of the app :param app_version_id: The version ID for the app :param int page: The page of crash groups to get :returns: The list of crash groups that were found :rtype: HockeyCrashGroup """ request_url = f"{libhockey.constants.API_BASE_URL}/{app_id}/app_versions/{app_version_id}/" + \ f"crash_reasons?per_page=100&order=desc&page={page}" self.log.info(f"Fetching page {page} of crash groups") response = self.get(request_url, retry_count=3) crash_reasons_response = deserialize.deserialize(HockeyCrashGroupsResponse, response.json()) self.log.info(f"Fetched page {page}/{crash_reasons_response.total_pages} of crash groups") reasons: List[HockeyCrashGroup] = crash_reasons_response.crash_reasons for reason in reasons: yield reason if crash_reasons_response.total_pages > page: yield from self.generate_groups_for_version(app_id, app_version_id, page=page + 1) def groups_for_version( self, app_id: str, app_version_id: int, max_count: Optional[int] = None ) -> List[HockeyCrashGroup]: """Get all crash groups for a given hockeyApp version. :param app_id: The ID of the app :param app_version_id: The version ID for the app :param max_count: The maximum count of crash groups to fetch before stopping :returns: The list of crash groups that were found """ groups = [] for group in self.generate_groups_for_version(app_id, app_version_id): groups.append(group) if max_count is not None and len(groups) >= max_count: break return groups def generate_in_group( self, app_id: str, app_version_id: int, crash_group_id: int, *, page: int = 1 ) -> Iterator[HockeyCrashInstance]: """Get all crash instances in a group. :param app_id: The ID of the app :param app_version_id: The version ID for the app :param crash_group_id: The ID of the group to get the crashes :param int page: The page of crashes to start at :returns: The crashes that were found in the group :rtype: HockeyCrashInstance """ request_url = f"{libhockey.constants.API_BASE_URL}/{app_id}/app_versions/{app_version_id}/crash_reasons/" + \ f"{crash_group_id}?per_page=100&order=desc&page={page}" response = self.get(request_url, retry_count=3) crashes_response = deserialize.deserialize(HockeyCrashesResponse, response.json()) crashes: List[HockeyCrashInstance] = crashes_response.crashes for crash in crashes: yield crash if crashes_response.total_pages > page: yield from self.generate_in_group(app_id, app_version_id, crash_group_id, page=page + 1) def in_group( self, app_id: str, app_version_id: int, crash_group_id: int ) -> List[HockeyCrashInstance]: """Get all crash instances in a group. :param app_id: The ID of the app :param app_version_id: The version ID for the app :param crash_group_id: The ID of the group to get the crashes :returns: The list of crash instances that were found """ return list(self.generate_in_group(app_id, app_version_id, crash_group_id)) PK![libhockey/derived_client.py"""Base definition for Hockey clients.""" import logging import time import requests class HockeyDerivedClient: """Base definition for Hockey clients. :param name: The name of the derived client :param token: The authentication token :param parent_logger: The parent logger that we will use for our own logging """ log: logging.Logger token: str def __init__(self, name: str, token: str, parent_logger: logging.Logger) -> None: self.log = parent_logger.getChild(name) self.token = token def get(self, url: str, *, retry_count: int = 0) -> requests.Response: """Perform a GET request to a url :param url: The URL to run the GET on :param int retry_count: The number of retries remaining if we got a 202 last time :returns: The raw JSON response :raises Exception: If the request fails with a non 200 status code """ response = requests.get(url, headers={"X-HockeyAppToken": self.token}) if response.status_code == 202 and retry_count > 0: self.log.info( f"202 response. Sleeping for 10 seconds before invoking HockeyApp again..." ) time.sleep(10) return self.get(url, retry_count=retry_count - 1) if response.status_code != 200: raise Exception(f"HockeyApp request failed: {url} Error: {response.text}") return response PK!E00libhockey/versions.py"""Hockey versions API wrappers.""" import enum import logging import re from typing import Dict, Iterator, List, Optional import deserialize import requests import libhockey.constants from libhockey.derived_client import HockeyDerivedClient @deserialize.key("app_size", "appsize") @deserialize.key("identifier", "id") @deserialize.key("short_version", "shortversion") class HockeyAppVersion: """Hockey API App Version.""" app_id: int app_owner: str app_size: int block_crashes: bool config_url: str created_at: str device_family: Optional[str] download_url: Optional[str] expired_at: Optional[str] external: bool identifier: int mandatory: bool minimum_os_version: Optional[str] notes: str restricted_to_tags: bool sdk_version: Optional[str] short_version: str status: int timestamp: int title: str updated_at: str uuids: Optional[Dict[str, str]] version: str class HockeyAppVersionsResponse: """Hockey API App Versions response.""" app_versions: List[HockeyAppVersion] status: str current_page: int per_page: int total_entries: int total_pages: int @deserialize.key("identifier", "id") @deserialize.key("short_version", "shortversion") class HockeyAppVersionStatistics: """Hockey API Statistics.""" class Statistics: """The statistics struct from Hockey.""" crashes: int devices: int downloads: int installs: int last_request_at: Optional[str] usage_time: str created_at: str identifier: int short_version: str statistics: Statistics version: str @deserialize.key("app_size", "appsize") @deserialize.key("identifier", "id") @deserialize.key("short_version", "shortversion") class HockeyUploadResponse: """Hockey upload API response.""" app_size: Optional[int] block_crashes: bool block_personal_data: bool bundle_identifier: str company: str config_url: str created_at: str custom_release_type: str device_family: Optional[str] featured: bool identifier: int internal: bool minimum_os_version: Optional[str] notes: Optional[str] owner_token: str owner: str platform: str public_identifier: str public_url: str release_type: int retention_days: str role: int short_version: Optional[str] status: int timestamp: Optional[int] title: str updated_at: str version: Optional[str] visibility: str class HockeyStatisticsResponse: """Hockey API statistics response.""" app_versions: List[HockeyAppVersionStatistics] status: str class HockeyVersionNotesType(enum.Enum): """Hockey notes types.""" TEXTILE = 0 MARKDOWN = 1 class HockeyUploadNotificationType(enum.Enum): """Hockey upload notification types.""" DONT_NOTIFY = 0 NOTIFY_ALL_INSTALLABLE = 1 NOTIFY_ALL = 2 class HockeyUploadDownloadStatus(enum.Enum): """Hockey download status types.""" DISALLOWED = 1 AVAILABLE = 2 class HockeyUploadMandatory(enum.Enum): """Hockey mandatory types.""" NO = 0 YES = 1 class HockeyUploadReleaseType(enum.Enum): """Hockey release types.""" ALPHA = 2 BETA = 0 STORE = 1 ENTERPRISE = 3 class HockeyUploadRestrictionType(enum.Enum): """Hockey restriction types.""" RESTRICTED = "7132" UNRESTRICTED = "" class HockeyVersionsClient(HockeyDerivedClient): """Wrapper around the Hockey versions APIs. :param token: The authentication token :param parent_logger: The parent logger that we will use for our own logging """ def __init__(self, token: str, parent_logger: logging.Logger) -> None: super().__init__("versions", token, parent_logger) def recent(self, app_id: str) -> List[HockeyAppVersion]: """Get the recent versions for the app ID. :param app_id: The ID of the app to get the versions for :returns: The list of versions found :raises Exception: If we fail to get the versions """ self.log.info(f"Getting recent versions of app with id: {app_id}") request_url = f"{libhockey.constants.API_BASE_URL}/{app_id}?format=json" request_headers = {"X-HockeyAppToken": self.token} response = requests.get(request_url, headers=request_headers) if response.status_code != 200: raise Exception( f"Failed to get app versions: {response.status_code} -> {response.text}" ) response_data: HockeyAppVersionsResponse = deserialize.deserialize( HockeyAppVersionsResponse, response.json() ) return response_data.app_versions def generate_all(self, app_id: str, *, page: int = 1) -> Iterator[HockeyAppVersion]: """Get all app versions for the app ID. :param app_id: The ID for the app to get the versions for :param int page: The page of results to start at (leave unspecified for all) :returns: The list of app versions :rtype: HockeyAppVersion :raises Exception: If we don't get the app versions """ request_url = f"{libhockey.constants.API_BASE_URL}/{app_id}/app_versions?page={page}" request_headers = {"X-HockeyAppToken": self.token} self.log.info(f"Fetching page {page} of app versions") response = requests.get(request_url, headers=request_headers) if response.status_code != 200: raise Exception( f"Failed to get app versions: {response.status_code} -> {response.text}" ) response_data: HockeyAppVersionsResponse = deserialize.deserialize( HockeyAppVersionsResponse, response.json() ) self.log.info(f"Fetched page {page}/{response_data.total_pages} of app versions") versions: List[HockeyAppVersion] = response_data.app_versions for version in versions: yield version if response_data.total_pages > page: yield from self.generate_all(app_id, page=page + 1) def all(self, app_id: str) -> List[HockeyAppVersion]: """Get all app versions for the app ID. :param app_id: The ID for the app to get the versions for :returns: The list of app versions :raises Exception: If we don't get the app versions """ return list(self.generate_all(app_id)) def hockey_version_identifier_for_version(self, app_id: str, version: str) -> Optional[int]: """Get the Hockey version identifier for the app version (usually build number). :param app_id: The ID for the app :param version: The app version (usually build number) :returns: The Hockey version identifier """ for app_version in self.generate_all(app_id): if app_version.version == version: return app_version.identifier return None def latest_commit(self, app_id: str) -> Optional[str]: """Find the most recent release which has an available commit in it and return the commit hash. :param app_id: The ID of the app to get the versions for :returns: The latest commit available on Hockey """ self.log.info(f"Getting latest commit for app: {app_id}") for version in self.generate_all(app_id): # Find commit sha in notes field matches = re.search("Commit: ([a-zA-Z0-9]+)", version.notes) if matches: version_match: str = matches.group(1).strip() return version_match return None def statistics(self, app_id: str) -> List[HockeyAppVersionStatistics]: """Get all version statistics for the app ID. :param app_id: The ID for the app to get the statistics for :returns: The list of app version statistics :raises Exception: If we don't get the statistics """ request_url = f"{libhockey.constants.API_BASE_URL}/{app_id}/statistics" request_headers = {"X-HockeyAppToken": self.token} response = requests.get(request_url, headers=request_headers) if response.status_code != 200: raise Exception( f"Failed to get app versions: {response.status_code} -> {response.text}" ) response_data: HockeyStatisticsResponse = deserialize.deserialize( HockeyStatisticsResponse, response.json() ) return response_data.app_versions def upload( self, ipa_path: str, dsym_path: str, notes: str, release_type: HockeyUploadReleaseType, restriction_type: HockeyUploadRestrictionType, commit_sha: str, ) -> str: """Upload a new version of an app to Hockey. :param ipa_path: The path to the .ipa file to upload :param dsym_path: The path to the directory continaing the dSYM bundles :param notes: The release notes in Markdown format :param release_type: The type of release this is :param restriction_type: Whether this build should be restricted or not :param commit_sha: The commit that resulted in this build :returns: The URL to the build on Hockey :raises Exception: If we fail to upload the build :raises DeserializeException: If we fail to parse the upload response """ # pylint: disable=too-many-locals ipa_file_name = ipa_path.split("/")[-1] dsym_file_name = dsym_path.split("/")[-1] self.log.info("IPA Name: " + ipa_file_name) self.log.info("dSYM Name: " + dsym_file_name) with open(ipa_path, "rb") as ipa_file: with open(dsym_path, "rb") as dsym_file: # Build request request_url = f"{libhockey.constants.API_BASE_URL}/upload" request_headers = {"X-HockeyAppToken": self.token} request_files = { "ipa": (ipa_file_name, ipa_file), "dsym": (dsym_file_name, dsym_file), } request_body = { "notes": notes, "notes_type": HockeyVersionNotesType.MARKDOWN.value, "notify": HockeyUploadNotificationType.DONT_NOTIFY.value, "status": HockeyUploadDownloadStatus.AVAILABLE.value, "teams": restriction_type.value, "mandatory": HockeyUploadMandatory.NO.value, "release_type": release_type.value, "commit_sha": commit_sha, "retention_days": 28, } self.log.info("Hockey request: " + str(request_body)) # Perform request response = requests.post( request_url, headers=request_headers, files=request_files, data=request_body, timeout=20 * 60, ) if response.status_code == 401: raise Exception("Invalid Hockeyapp token") if response.status_code != 201: raise Exception( f"Unsuccessful response status code: {response.status_code} -> {response.text.encode('utf-8')}" ) # Print HockeyApp download link try: upload_response = deserialize.deserialize(HockeyUploadResponse, response.json()) except deserialize.DeserializeException as ex: self.log.error(f"Failed to deserialize upload response: {ex}") raise version: str = upload_response.version if not version: # HockeyApp isn't current returning the `version` field. # In the current payload the version is only available in the config url. # This is a hack that we can hopefully remove once HockeyApp returns `version` again. # # Eg. https://rink.hockeyapp.net/manage/apps/208032/app_versions/57966 self.log.warning( f"Failed to deserialize `version` from response, using config_url instead {response.json()}" ) version = upload_response.config_url.split("/")[-1] return f"{upload_response.public_url}/app_versions/{version}" # pylint: enable=too-many-locals PK!AwN!libhockey-0.2.0.dist-info/LICENSE MIT License Copyright (c) Microsoft Corporation. All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE PK!HnHTUlibhockey-0.2.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!Hb,J"libhockey-0.2.0.dist-info/METADATATMo8W 4],*$(liZKl) K⪿~G렇̛7\"ڰ-g/aή[>㨎3}j:JxL[9J-u8-bXn[,m(҆߻ (-:Jvv?vfU ORn5t1}3 _Dx$㞼R`%2mM9=[gb蕍,c`&g>>1!ڟSLa_\a,_RZ G/fb 皶R7H`FJݷ6ߵ-:#F<[,)PM #  :6p@`˛r*U΄5n "y|mZRLT<F@v-%z1'5aA BA%0 & msskx $-d}'pan˚>'+snZ0>2ou-v {ZL-jv:iz2\Fꡀhf )wsӀJ>NyӪ6"["X^ ZIVâV97j4|L'ϥ.˯>ebP0[ os;>NY;Qhr_ñ=eϑԊ^Q⑄mٽ~y/9P2L8_xq:ZnU~@џH[Cl8d|lR"/8 fM>6;naY1p z^!PK!alibhockey/__init__.pyPK![d{{libhockey/constants.pyPK!I [[libhockey/crashes.pyPK![&"libhockey/derived_client.pyPK!E00(libhockey/versions.pyPK!AwN!Xlibhockey-0.2.0.dist-info/LICENSEPK!HnHTU]libhockey-0.2.0.dist-info/WHEELPK!Hb,J"%^libhockey-0.2.0.dist-info/METADATAPK!Hwx_ alibhockey-0.2.0.dist-info/RECORDPK c