|
| 1 | +"""Functions for Github API requests.""" |
| 2 | +from __future__ import print_function |
| 3 | + |
| 4 | +try: |
| 5 | + input = raw_input |
| 6 | +except NameError: |
| 7 | + pass |
| 8 | + |
| 9 | +import os |
| 10 | +import re |
| 11 | +import sys |
| 12 | + |
| 13 | +import requests |
| 14 | +import getpass |
| 15 | +import json |
| 16 | + |
| 17 | +try: |
| 18 | + import requests_cache |
| 19 | +except ImportError: |
| 20 | + print("no cache", file=sys.stderr) |
| 21 | +else: |
| 22 | + requests_cache.install_cache("gh_api", expire_after=3600) |
| 23 | + |
| 24 | +# Keyring stores passwords by a 'username', but we're not storing a username and |
| 25 | +# password |
| 26 | +fake_username = 'ipython_tools' |
| 27 | + |
| 28 | +class Obj(dict): |
| 29 | + """Dictionary with attribute access to names.""" |
| 30 | + def __getattr__(self, name): |
| 31 | + try: |
| 32 | + return self[name] |
| 33 | + except KeyError: |
| 34 | + raise AttributeError(name) |
| 35 | + |
| 36 | + def __setattr__(self, name, val): |
| 37 | + self[name] = val |
| 38 | + |
| 39 | +token = None |
| 40 | +def get_auth_token(): |
| 41 | + global token |
| 42 | + |
| 43 | + if token is not None: |
| 44 | + return token |
| 45 | + |
| 46 | + import keyring |
| 47 | + token = keyring.get_password('github', fake_username) |
| 48 | + if token is not None: |
| 49 | + return token |
| 50 | + |
| 51 | + print("Please enter your github username and password. These are not " |
| 52 | + "stored, only used to get an oAuth token. You can revoke this at " |
| 53 | + "any time on Github.") |
| 54 | + user = input("Username: ") |
| 55 | + pw = getpass.getpass("Password: ") |
| 56 | + |
| 57 | + auth_request = { |
| 58 | + "scopes": [ |
| 59 | + "public_repo", |
| 60 | + "gist" |
| 61 | + ], |
| 62 | + "note": "IPython tools", |
| 63 | + "note_url": "https://github.com/ipython/ipython/tree/master/tools", |
| 64 | + } |
| 65 | + response = requests.post('https://api.github.com/authorizations', |
| 66 | + auth=(user, pw), data=json.dumps(auth_request)) |
| 67 | + response.raise_for_status() |
| 68 | + token = json.loads(response.text)['token'] |
| 69 | + keyring.set_password('github', fake_username, token) |
| 70 | + return token |
| 71 | + |
| 72 | +def make_auth_header(): |
| 73 | + return {'Authorization': 'token ' + get_auth_token()} |
| 74 | + |
| 75 | +def post_issue_comment(project, num, body): |
| 76 | + url = 'https://api.github.com/repos/{project}/issues/{num}/comments'.format(project=project, num=num) |
| 77 | + payload = json.dumps({'body': body}) |
| 78 | + requests.post(url, data=payload, headers=make_auth_header()) |
| 79 | + |
| 80 | +def post_gist(content, description='', filename='file', auth=False): |
| 81 | + """Post some text to a Gist, and return the URL.""" |
| 82 | + post_data = json.dumps({ |
| 83 | + "description": description, |
| 84 | + "public": True, |
| 85 | + "files": { |
| 86 | + filename: { |
| 87 | + "content": content |
| 88 | + } |
| 89 | + } |
| 90 | + }).encode('utf-8') |
| 91 | + |
| 92 | + headers = make_auth_header() if auth else {} |
| 93 | + response = requests.post("https://api.github.com/gists", data=post_data, headers=headers) |
| 94 | + response.raise_for_status() |
| 95 | + response_data = json.loads(response.text) |
| 96 | + return response_data['html_url'] |
| 97 | + |
| 98 | +def get_pull_request(project, num, auth=False): |
| 99 | + """get pull request info by number |
| 100 | + """ |
| 101 | + url = "https://api.github.com/repos/{project}/pulls/{num}".format(project=project, num=num) |
| 102 | + if auth: |
| 103 | + header = make_auth_header() |
| 104 | + else: |
| 105 | + header = None |
| 106 | + response = requests.get(url, headers=header) |
| 107 | + response.raise_for_status() |
| 108 | + return json.loads(response.text, object_hook=Obj) |
| 109 | + |
| 110 | +def get_pull_request_files(project, num, auth=False): |
| 111 | + """get list of files in a pull request""" |
| 112 | + url = "https://api.github.com/repos/{project}/pulls/{num}/files".format(project=project, num=num) |
| 113 | + if auth: |
| 114 | + header = make_auth_header() |
| 115 | + else: |
| 116 | + header = None |
| 117 | + return get_paged_request(url, headers=header) |
| 118 | + |
| 119 | +element_pat = re.compile(r'<(.+?)>') |
| 120 | +rel_pat = re.compile(r'rel=[\'"](\w+)[\'"]') |
| 121 | + |
| 122 | +def get_paged_request(url, headers=None, **params): |
| 123 | + """get a full list, handling APIv3's paging""" |
| 124 | + results = [] |
| 125 | + params.setdefault("per_page", 100) |
| 126 | + while True: |
| 127 | + if '?' in url: |
| 128 | + params = None |
| 129 | + print("fetching %s" % url, file=sys.stderr) |
| 130 | + else: |
| 131 | + print("fetching %s with %s" % (url, params), file=sys.stderr) |
| 132 | + response = requests.get(url, headers=headers, params=params) |
| 133 | + response.raise_for_status() |
| 134 | + results.extend(response.json()) |
| 135 | + if 'next' in response.links: |
| 136 | + url = response.links['next']['url'] |
| 137 | + else: |
| 138 | + break |
| 139 | + return results |
| 140 | + |
| 141 | +def get_pulls_list(project, auth=False, **params): |
| 142 | + """get pull request list""" |
| 143 | + params.setdefault("state", "closed") |
| 144 | + url = "https://api.github.com/repos/{project}/pulls".format(project=project) |
| 145 | + if auth: |
| 146 | + headers = make_auth_header() |
| 147 | + else: |
| 148 | + headers = None |
| 149 | + pages = get_paged_request(url, headers=headers, **params) |
| 150 | + return pages |
| 151 | + |
| 152 | +def get_issues_list(project, auth=False, **params): |
| 153 | + """get issues list""" |
| 154 | + params.setdefault("state", "closed") |
| 155 | + url = "https://api.github.com/repos/{project}/issues".format(project=project) |
| 156 | + if auth: |
| 157 | + headers = make_auth_header() |
| 158 | + else: |
| 159 | + headers = None |
| 160 | + pages = get_paged_request(url, headers=headers, **params) |
| 161 | + return pages |
| 162 | + |
| 163 | +def get_milestones(project, auth=False, **params): |
| 164 | + url = "https://api.github.com/repos/{project}/milestones".format(project=project) |
| 165 | + if auth: |
| 166 | + headers = make_auth_header() |
| 167 | + else: |
| 168 | + headers = None |
| 169 | + milestones = get_paged_request(url, headers=headers, **params) |
| 170 | + return milestones |
| 171 | + |
| 172 | +def get_milestone_id(project, milestone, auth=False, **params): |
| 173 | + milestones = get_milestones(project, auth=auth, **params) |
| 174 | + for mstone in milestones: |
| 175 | + if mstone['title'] == milestone: |
| 176 | + return mstone['number'] |
| 177 | + else: |
| 178 | + raise ValueError("milestone %s not found" % milestone) |
| 179 | + |
| 180 | +def is_pull_request(issue): |
| 181 | + """Return True if the given issue is a pull request.""" |
| 182 | + return bool(issue.get('pull_request', {}).get('html_url', None)) |
| 183 | + |
| 184 | +def get_authors(pr): |
| 185 | + print("getting authors for #%i" % pr['number'], file=sys.stderr) |
| 186 | + h = make_auth_header() |
| 187 | + r = requests.get(pr['commits_url'], headers=h) |
| 188 | + r.raise_for_status() |
| 189 | + commits = r.json() |
| 190 | + authors = [] |
| 191 | + for commit in commits: |
| 192 | + author = commit['commit']['author'] |
| 193 | + authors.append("%s <%s>" % (author['name'], author['email'])) |
| 194 | + return authors |
| 195 | + |
| 196 | +# encode_multipart_formdata is from urllib3.filepost |
| 197 | +# The only change is to iter_fields, to enforce S3's required key ordering |
| 198 | + |
| 199 | +def iter_fields(fields): |
| 200 | + fields = fields.copy() |
| 201 | + for key in ('key', 'acl', 'Filename', 'success_action_status', 'AWSAccessKeyId', |
| 202 | + 'Policy', 'Signature', 'Content-Type', 'file'): |
| 203 | + yield (key, fields.pop(key)) |
| 204 | + for (k,v) in fields.items(): |
| 205 | + yield k,v |
| 206 | + |
| 207 | +def encode_multipart_formdata(fields, boundary=None): |
| 208 | + """ |
| 209 | + Encode a dictionary of ``fields`` using the multipart/form-data mime format. |
| 210 | +
|
| 211 | + :param fields: |
| 212 | + Dictionary of fields or list of (key, value) field tuples. The key is |
| 213 | + treated as the field name, and the value as the body of the form-data |
| 214 | + bytes. If the value is a tuple of two elements, then the first element |
| 215 | + is treated as the filename of the form-data section. |
| 216 | +
|
| 217 | + Field names and filenames must be unicode. |
| 218 | +
|
| 219 | + :param boundary: |
| 220 | + If not specified, then a random boundary will be generated using |
| 221 | + :func:`mimetools.choose_boundary`. |
| 222 | + """ |
| 223 | + # copy requests imports in here: |
| 224 | + from io import BytesIO |
| 225 | + from requests.packages.urllib3.filepost import ( |
| 226 | + choose_boundary, six, writer, b, get_content_type |
| 227 | + ) |
| 228 | + body = BytesIO() |
| 229 | + if boundary is None: |
| 230 | + boundary = choose_boundary() |
| 231 | + |
| 232 | + for fieldname, value in iter_fields(fields): |
| 233 | + body.write(b('--%s\r\n' % (boundary))) |
| 234 | + |
| 235 | + if isinstance(value, tuple): |
| 236 | + filename, data = value |
| 237 | + writer(body).write('Content-Disposition: form-data; name="%s"; ' |
| 238 | + 'filename="%s"\r\n' % (fieldname, filename)) |
| 239 | + body.write(b('Content-Type: %s\r\n\r\n' % |
| 240 | + (get_content_type(filename)))) |
| 241 | + else: |
| 242 | + data = value |
| 243 | + writer(body).write('Content-Disposition: form-data; name="%s"\r\n' |
| 244 | + % (fieldname)) |
| 245 | + body.write(b'Content-Type: text/plain\r\n\r\n') |
| 246 | + |
| 247 | + if isinstance(data, int): |
| 248 | + data = str(data) # Backwards compatibility |
| 249 | + if isinstance(data, six.text_type): |
| 250 | + writer(body).write(data) |
| 251 | + else: |
| 252 | + body.write(data) |
| 253 | + |
| 254 | + body.write(b'\r\n') |
| 255 | + |
| 256 | + body.write(b('--%s--\r\n' % (boundary))) |
| 257 | + |
| 258 | + content_type = b('multipart/form-data; boundary=%s' % boundary) |
| 259 | + |
| 260 | + return body.getvalue(), content_type |
| 261 | + |
| 262 | + |
| 263 | +def post_download(project, filename, name=None, description=""): |
| 264 | + """Upload a file to the GitHub downloads area""" |
| 265 | + if name is None: |
| 266 | + name = os.path.basename(filename) |
| 267 | + with open(filename, 'rb') as f: |
| 268 | + filedata = f.read() |
| 269 | + |
| 270 | + url = "https://api.github.com/repos/{project}/downloads".format(project=project) |
| 271 | + |
| 272 | + payload = json.dumps(dict(name=name, size=len(filedata), |
| 273 | + description=description)) |
| 274 | + response = requests.post(url, data=payload, headers=make_auth_header()) |
| 275 | + response.raise_for_status() |
| 276 | + reply = json.loads(response.content) |
| 277 | + s3_url = reply['s3_url'] |
| 278 | + |
| 279 | + fields = dict( |
| 280 | + key=reply['path'], |
| 281 | + acl=reply['acl'], |
| 282 | + success_action_status=201, |
| 283 | + Filename=reply['name'], |
| 284 | + AWSAccessKeyId=reply['accesskeyid'], |
| 285 | + Policy=reply['policy'], |
| 286 | + Signature=reply['signature'], |
| 287 | + file=(reply['name'], filedata), |
| 288 | + ) |
| 289 | + fields['Content-Type'] = reply['mime_type'] |
| 290 | + data, content_type = encode_multipart_formdata(fields) |
| 291 | + s3r = requests.post(s3_url, data=data, headers={'Content-Type': content_type}) |
| 292 | + return s3r |
0 commit comments