Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.
Open
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
12 changes: 9 additions & 3 deletions hockeyapp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ def _delete(self, uri_parts, data=None):
headers=self.headers,
data=data))

def _get(self, uri_parts, data=None):
"""Post data to the API
def _get(self, uri_parts, data=None, params=None):
"""Get data from the API

:param list uri_parts: Parts of the URI to compose the URI
:param dict data: Optional query parameters for the GET
Expand All @@ -88,10 +88,11 @@ def _get(self, uri_parts, data=None):
LOGGER.debug('Performing HTTP GET to %s', uri)
return self._response(requests.get(uri,
headers=self.headers,
params=params,
data=data))

def _post(self, uri_parts=None, data=None, files=None):
"""Get data from the API
"""Post data to the API

:param list uri_parts: Parts of the URI to compose the URI
:param dict data: Optional query parameters for the POST
Expand Down Expand Up @@ -119,6 +120,8 @@ def _response(self, response):
if 200 <= response.status_code <= 300:
if 'application/json' in response.headers['Content-Type']:
return response.json()
if self._api_rate_limit_exceeded(response):
raise APIError({'429': 'Rate Limit Exceeded'})
return response.content
if response.status_code == 404:
raise APIError({'404': 'URL Not Found: %s' % response.url})
Expand All @@ -127,6 +130,9 @@ def _response(self, response):
LOGGER.debug(response.content)
raise APIError('Not JSON')

def _api_rate_limit_exceeded(self, response):
return response.content == b'202 Accepted (Rate Limit Exceeded)\n'

@property
def _uri(self):
"""Return the URI for the request
Expand Down
91 changes: 90 additions & 1 deletion hockeyapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def __init__(self, token, app_id=None):
if app_id:
self._check_app_id(app_id)
self._app_id = app_id
super(Application, self).__init__(token)
super(self.__class__, self).__init__(token)

def create(self, title, bundle_identifier, platform='iOS', release_type=0):
"""Create a new application without uploading a file.
Expand Down Expand Up @@ -145,6 +145,46 @@ def crash_log(self, crash_id):
data={'format': 'log'})
return response


def crash_group_search(self, query_string, symbolicated=False, offset=1, limit=25, order='asc'):
"""Search for crash groups using query params. Results are paginated crash groups.
Hockeyapp requires date searching params to be encoded in an unusual format
e.g. .../search?query=created_at:[\"2012-10-22T00:00\"+TO+\"2016-01-01T23:59\"]
Requests.get would url escape this string into something that hockeyapp won't recognize.
For this reason, the query_string of
`'created_at:[\"2014-05-01T00:00\"+TO+\"2014-05-30T23:59\"]'`
is not cleaned up at all before being passed into Requests.get.
See help text in the hockeyapp web search UI under crashes for more about
HockeyApp-specific querying.

:param str query_string: query search params already url formatted
:param bool symbolicated: run crashes through the symbolication process
:param int offset: The offset for the page of feedback
:param int limit: The maximum number of entries per page (25, 50, 100)
:param str order: Order of items in list, ``asc`` or ``desc``
:rtype: Feedback

"""
if order not in ['asc', 'desc']:
raise ValueError('order must either be "asc" or "desc"')

url_components = ['apps', self._app_id, 'crash_reasons', 'search']
request_params = {'query': query_string,
'symbolication': int(symbolicated),
'page': offset,
'per_page': limit,
'order': order}
request_params_as_string = self._stringify_params_without_escape(request_params)

response = self._get(uri_parts=url_components,
params=request_params_as_string)
return CrashGroups(response.get('crash_reasons', []),
response.get('total_entries', 0),
response.get('total_pages', 0),
response.get('current_page', 0),
response.get('per_page', 0))


def crash_groups(self, version_id=None, symbolicated=False, offset=1,
limit=25, order='asc'):
"""List all crashes grouped by reason for an app. If version_id is
Expand Down Expand Up @@ -177,6 +217,45 @@ def crash_groups(self, version_id=None, symbolicated=False, offset=1,
response.get('current_page', 0),
response.get('per_page', 0))

def crash_search(self, query_string, symbolicated=False, offset=1, limit=25, order='asc'):
"""Search for crashes using query params. Results are paginated crashes.
Hockeyapp requires date searching params to be encoded in an unusual format
e.g. .../search?query=created_at:[\"2012-10-22T00:00\"+TO+\"2016-01-01T23:59\"]
Requests.get would url escape this string into something that hockeyapp won't recognize.
For this reason, the query_string of
`'created_at:[\"2014-05-01T00:00\"+TO+\"2014-05-30T23:59\"]'`
is not cleaned up at all before being passed into Requests.get.
See help text in the hockeyapp web search UI under crashes for more about
HockeyApp-specific querying.

:param str query_string: query search params already url formatted
:param bool symbolicated: run crashes through the symbolication process
:param int offset: The offset for the page of feedback
:param int limit: The maximum number of entries per page (25, 50, 100)
:param str order: Order of items in list, ``asc`` or ``desc``
:rtype: Feedback

"""
if order not in ['asc', 'desc']:
raise ValueError('order must either be "asc" or "desc"')

url_components = ['apps', self._app_id, 'crashes', 'search']
request_params = {'query': query_string,
'symbolication': int(symbolicated),
'page': offset,
'per_page': limit,
'order': order}
request_params_as_string = self._stringify_params_without_escape(request_params)

response = self._get(uri_parts=url_components,
params=request_params_as_string)
return Crashes(response.get('crashes', []),
response.get('total_entries', 0),
response.get('total_pages', 0),
response.get('current_page', 0),
response.get('per_page', 0))


def crashes(self, reason_id, offset=0, limit=25):
"""Paginated list of crashes in a crash reason group.

Expand Down Expand Up @@ -503,6 +582,16 @@ def _check_app_id(self, value):
raise ValueError('public_identifier is a 32 character hex digest '
'hash value')

def _stringify_params_without_escape(self, params):
"""Hockeyapp requires date searching params to be encoded in an unusual format
e.g. .../search?query=created_at:[\"2012-10-22T00:00\"+TO+\"2016-01-01T23:59\"]
Requests.get would url escape this string into something that hockeyapp won't recognize.

:param dict params: dictionary of url-safe params to be collapsed
:return str: url-safe collection of params for URI requests
"""
return "&".join("%s=%s" % (k,v) for k,v in sorted(params.items()))


# Deprecated classes for transitional support, to be removed in future versions

Expand Down
10 changes: 8 additions & 2 deletions tests/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,11 @@ def response_content(url, request):
with httmock.HTTMock(response_content):
self.assertEqual(self.api._get(['app_versions']), expectation)



def test_get_with_rate_limit_throws_api_error(self):
@httmock.all_requests
def response_content(url, request):
headers = {'content-type': 'text/plain; charset=utf-8', 'status': '202 Accepted'}
content = b'202 Accepted (Rate Limit Exceeded)\n'
return httmock.response(202, content, headers, None, 5, request)
with httmock.HTTMock(response_content):
self.assertRaises(api.APIError, self.api._get, ['anything'])
14 changes: 13 additions & 1 deletion tests/app_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@ def test_statistics(self, get, _):

@mock.patch.object(app.Application, '_check_app_id')
@mock.patch.object(app.Application, '_get')
def test_verions(self, get, _):
def test_versions(self, get, _):
application = app.Application(self.TOKEN, app_id=self.APP_IDENTIFIER)
application.versions()
get.assert_called_with(uri_parts=['apps', self.APP_IDENTIFIER, 'app_versions'])

@mock.patch.object(app.Application, '_check_app_id')
@mock.patch.object(app.Application, '_get')
def test_crash_group_search(self, get, _):
query_string = 'created_at:[\"2014-05-01T00:00\"+TO+\"2014-05-30T23:59\"]'
expected_request_params = 'order=asc&page=1&per_page=25&query=created_at:["2014-05-01T00:00"+TO+"2014-05-30T23:59"]&symbolication=0'
expected_uri_parts = ['apps', self.APP_IDENTIFIER, 'crash_reasons', 'search']
application = app.Application(self.TOKEN, app_id=self.APP_IDENTIFIER)

application.crash_group_search(query_string)

get.assert_called_with(uri_parts=expected_uri_parts, params=expected_request_params)