Skip to content

Commit b1dad31

Browse files
authored
Merge pull request #16 from soda480/0.1.2
Add graphql api
2 parents b748028 + 3dd86a2 commit b1dad31

File tree

6 files changed

+333
-33
lines changed

6 files changed

+333
-33
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
- master
99
jobs:
1010
build:
11-
runs-on: ubuntu-16.04
11+
runs-on: ubuntu-20.04
1212
container: python:3.6-alpine
1313

1414
steps:

Dockerfile

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,16 @@
1515
#
1616

1717
FROM python:3.6-alpine AS build-image
18-
1918
ENV PYTHONDONTWRITEBYTECODE 1
20-
21-
WORKDIR /github3api
22-
23-
COPY . /github3api/
24-
19+
WORKDIR /code
20+
COPY . /code/
2521
RUN pip install pybuilder==0.11.17
2622
RUN pyb install_dependencies
2723
RUN pyb install
2824

2925

3026
FROM python:3.6-alpine
31-
3227
ENV PYTHONDONTWRITEBYTECODE 1
33-
3428
WORKDIR /opt/github3api
35-
36-
COPY --from=build-image /github3api/target/dist/github3api-*/dist/github3api-*.tar.gz /opt/github3api
37-
29+
COPY --from=build-image /code/target/dist/github3api-*/dist/github3api-*.tar.gz /opt/github3api
3830
RUN pip install github3api-*.tar.gz

README.md

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
# github3api #
12
[![GitHub Workflow Status](https://github.com/soda480/github3api/workflows/build/badge.svg)](https://github.com/soda480/github3api/actions)
23
[![Code Coverage](https://codecov.io/gh/soda480/github3api/branch/master/graph/badge.svg)](https://codecov.io/gh/soda480/github3api)
34
[![Code Grade](https://www.code-inspector.com/project/13337/status/svg)](https://frontend.code-inspector.com/project/13337/dashboard)
45
[![PyPI version](https://badge.fury.io/py/github3api.svg)](https://badge.fury.io/py/github3api)
56

6-
# github3api #
7-
An advanced REST client for the GitHub API. It is a subclass of [rest3client](https://pypi.org/project/rest3client/) tailored for the GitHub API with special optional directives for GET requests that can return all pages from an endpoint or return a generator that can be iterated over. By default all requests will be retried if ratelimit request limit is reached.
7+
An advanced REST client for the GitHub API. It is a subclass of [rest3client](https://pypi.org/project/rest3client/) tailored for the GitHub API with special optional directives for GET requests that can return all pages from an endpoint or return a generator that can be iterated over (for paged requests). By default all requests will be retried if ratelimit request limit is reached.
8+
9+
Support for executing Graphql queries including paging; Graphql queries are also retried if Graphql rate limiting occurs.
810

911

1012
### Installation ###
@@ -77,6 +79,58 @@ print(client.total('/user/repos'))
7779
6218
7880
```
7981

82+
`graphql` - execute graphql query
83+
```python
84+
query = """
85+
query($query:String!, $page_size:Int!) {
86+
search(query: $query, type: REPOSITORY, first: $page_size) {
87+
repositoryCount
88+
edges {
89+
node {
90+
... on Repository {
91+
nameWithOwner
92+
}
93+
}
94+
}
95+
}
96+
}
97+
"""
98+
variables = {"query": "org:edgexfoundry", "page_size":100}
99+
client.graphql(query, variables)
100+
```
101+
102+
`graphql paging` - execute paged graphql query
103+
```python
104+
query = """
105+
query ($query: String!, $page_size: Int!, $cursor: String!) {
106+
search(query: $query, type: REPOSITORY, first: $page_size, after: $cursor) {
107+
repositoryCount
108+
pageInfo {
109+
endCursor
110+
hasNextPage
111+
}
112+
edges {
113+
cursor
114+
node {
115+
... on Repository {
116+
nameWithOwner
117+
}
118+
}
119+
}
120+
}
121+
}
122+
"""
123+
variables = {"query": "org:edgexfoundry", "page_size":100}
124+
for page in client.graphql(query, variables, page=True, keys='data.search'):
125+
for repo in page:
126+
print(repo['node']['nameWithOwner'])
127+
```
128+
129+
For Graphql paged queries:
130+
- the query should include the necessary pageInfo and cursor attributes
131+
- the keys method argument is a dot annotated string that is used to access the resulting dictionary response object
132+
- the query is retried every 60 seconds (for up to an hour) if a ratelimit occur
133+
80134
### Projects using `github3api` ###
81135

82136
* [edgexfoundry/sync-github-labels](https://github.com/edgexfoundry/cd-management/tree/git-label-sync) A script that synchronizes GitHub labels and milestones
@@ -111,7 +165,7 @@ docker container run \
111165
-it \
112166
-e http_proxy \
113167
-e https_proxy \
114-
-v $PWD:/github3api \
168+
-v $PWD:/code \
115169
github3api:latest \
116170
/bin/sh
117171
```

build.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
authors = [Author('Emilio Reyes', 'emilio.reyes@intel.com')]
3131
summary = 'An advanced REST client for the GitHub API'
3232
url = 'https://github.com/soda480/github3api'
33-
version = '0.1.1'
33+
version = '0.1.2'
3434
default_task = [
3535
'clean',
3636
'analyze',

src/main/python/github3api/githubapi.py

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from os import getenv
1919
from datetime import datetime
2020

21+
from retrying import retry
2122
from rest3client import RESTclient
2223
from requests.exceptions import HTTPError
2324
from requests.exceptions import ChunkedEncodingError
@@ -29,6 +30,19 @@
2930
HOSTNAME = 'api.github.com'
3031
VERSION = 'v3'
3132
DEFAULT_PAGE_SIZE = 30
33+
DEFAULT_GRAPHQL_PAGE_SIZE = 100
34+
35+
36+
class GraphqlRateLimitError(Exception):
37+
""" GraphQL Rate Limit Error
38+
"""
39+
pass
40+
41+
42+
class GraphqlError(Exception):
43+
""" GraphQL Error
44+
"""
45+
pass
3246

3347

3448
class GitHubAPI(RESTclient):
@@ -221,15 +235,82 @@ def retry_ratelimit_error(exception):
221235
return False
222236

223237
@staticmethod
224-
def _retry_chunkedencodingerror_error(exception):
225-
""" return True if exception is ChunkedEncodingError, False otherwise
226-
retry:
227-
wait_fixed:10000
228-
stop_max_attempt_number:120
238+
def clear_cursor(query, cursor):
239+
""" return query with all cursor references removed if no cursor
240+
"""
241+
if not cursor:
242+
query = query.replace('after: $cursor', '')
243+
query = query.replace('$cursor: String!', '')
244+
return query
245+
246+
@staticmethod
247+
def sanitize_query(query):
248+
""" sanitize query
249+
"""
250+
return query.replace('\n', ' ').replace(' ', '').strip()
251+
252+
@staticmethod
253+
def raise_if_error(response):
254+
""" raise GraphqlRateLimitError if error exists in errors
255+
"""
256+
if 'errors' in response:
257+
logger.debug(f'errors detected in graphql response: {response}')
258+
for error in response['errors']:
259+
if error.get('type', '') == 'RATE_LIMITED':
260+
raise GraphqlRateLimitError(error.get('message', ''))
261+
raise GraphqlError(response['errors'][0]['message'])
262+
263+
@staticmethod
264+
def get_value(data, keys):
265+
""" return value represented by keys dot notated string from data dictionary
266+
"""
267+
if '.' in keys:
268+
key, rest = keys.split('.', 1)
269+
if key in data:
270+
return GitHubAPI.get_value(data[key], rest)
271+
raise KeyError(f'dictionary does not have key {key}')
272+
else:
273+
return data[keys]
274+
275+
def _get_graphql_page(self, query, variables, keys):
276+
""" return generator that yields page from graphql response
229277
"""
230-
logger.debug(f"checking if '{type(exception).__name__}' exception is a ChunkedEncodingError error")
231-
if isinstance(exception, ChunkedEncodingError):
232-
logger.info('ratelimit error encountered - retrying request in 10 seconds')
278+
variables['page_size'] = DEFAULT_GRAPHQL_PAGE_SIZE
279+
variables['cursor'] = ''
280+
while True:
281+
updated_query = GitHubAPI.clear_cursor(query, variables['cursor'])
282+
response = self.post('/graphql', json={'query': updated_query, 'variables': variables})
283+
GitHubAPI.raise_if_error(response)
284+
yield GitHubAPI.get_value(response, f'{keys}.edges')
285+
286+
page_info = GitHubAPI.get_value(response, f'{keys}.pageInfo')
287+
has_next_page = page_info['hasNextPage']
288+
if not has_next_page:
289+
logger.debug('no more pages')
290+
break
291+
variables['cursor'] = page_info['endCursor']
292+
293+
def check_graphqlratelimiterror(exception):
294+
""" return True if exception is GraphQL Rate Limit Error, False otherwise
295+
"""
296+
logger.debug(f"checking if '{type(exception).__name__}' exception is a GraphqlRateLimitError")
297+
if isinstance(exception, (GraphqlRateLimitError, TypeError)):
298+
logger.debug('exception is a GraphqlRateLimitError - retrying request in 60 seconds')
233299
return True
234-
logger.debug(f'exception is not a ratelimit error: {exception}')
300+
logger.debug(f'exception is not a GraphqlRateLimitError: {exception}')
235301
return False
302+
303+
@retry(retry_on_exception=check_graphqlratelimiterror, wait_fixed=60000, stop_max_attempt_number=60)
304+
def graphql(self, query, variables, page=False, keys=None):
305+
""" execute graphql query and return response or paged response if page is True
306+
"""
307+
query = GitHubAPI.sanitize_query(query)
308+
if page:
309+
response = self._get_graphql_page(query, variables, keys)
310+
else:
311+
updated_query = GitHubAPI.clear_cursor(query, variables.get('cursor'))
312+
response = self.post('/graphql', json={'query': updated_query, 'variables': variables})
313+
GitHubAPI.raise_if_error(response)
314+
return response
315+
316+
check_graphqlratelimiterror = staticmethod(check_graphqlratelimiterror)

0 commit comments

Comments
 (0)