├── .gitignore ├── ReadMe.md └── repl.py /.gitignore: -------------------------------------------------------------------------------- 1 | # When rending ReadMe.md locally. 2 | ReadMe.html 3 | -------------------------------------------------------------------------------- /ReadMe.md: -------------------------------------------------------------------------------- 1 | # Dropbox API v2 REPL 2 | 3 | A Python REPL that lets you make calls to the Dropbox API v2. 4 | 5 | 1. Get a Dropbox API access token. You can use the Dropbox website to [get an access token for your own account](https://blogs.dropbox.com/developers/2014/05/generate-an-access-token-for-your-own-account/). 6 | 7 | 2. Put the access token in a file called `auth.json`: 8 | 9 | ```json 10 | { 11 | "access_token": "" 12 | } 13 | ``` 14 | 15 | 3. Run the command: `python repl.py auth.json`. 16 | 17 | ``` 18 | For help, type 'hint' 19 | 20 | In [1]: hint 21 | Out[1]: 22 | 23 | Use 'a' to make requests to the "api" server. 24 | Use 'c' to make requests to the "content" server. 25 | 26 | Examples: 27 | a.rpc('files/get_metadata', path='/Camera Uploads') 28 | c.up('files/upload', path='/faq.txt', mode='add', _b=b'What?') 29 | c.down('files/download', path='/faq.txt', _h={'If-None-Match': 'W/"1234"'}) 30 | ``` 31 | -------------------------------------------------------------------------------- /repl.py: -------------------------------------------------------------------------------- 1 | # Requires Python 2.7 or Python 3.3+ 2 | # 3 | # To get usage information: python repl.py 4 | 5 | from __future__ import absolute_import, division, print_function, unicode_literals 6 | 7 | import code 8 | import collections 9 | import contextlib 10 | import json 11 | import sys 12 | 13 | if (3,3) <= sys.version_info < (4,0): 14 | import http.client as httplib 15 | import urllib.parse as urlparse 16 | unicode = str 17 | elif (2,7) <= sys.version_info < (3,0): 18 | import httplib 19 | import urllib as urlparse 20 | bytes = str 21 | else: 22 | sys.stderr.write( 23 | "This program requires Python 2.7+ or 3.3+. Currently running under Python {}.{}.\n" 24 | .format(*sys.version_info[:2])) 25 | sys.exit(1) 26 | 27 | def main(): 28 | prog_name, args = sys.argv[0], sys.argv[1:] 29 | auth_file, repl = parse_args_or_exit(prog_name, sys.stderr, sys.stdout, args) 30 | 31 | try: 32 | access_token, host_suffix = load_auth_json(auth_file) 33 | except AuthJsonLoadError as e: 34 | sys.stderr.write("Error loading \"{}\": {}\n".format(auth_file, e)) 35 | sys.exit(1) 36 | 37 | repl_symbols = dict( 38 | a=Host(access_token, 'api' + host_suffix), 39 | c=Host(access_token, 'content' + host_suffix), 40 | hint=hint, 41 | ) 42 | 43 | print("") 44 | print("For help, type 'hint'") 45 | repl(repl_symbols) 46 | 47 | class Host(object): 48 | def __init__(self, access_token, hostname): 49 | self.access_token = access_token 50 | self.hostname = hostname 51 | 52 | def __str__(self): 53 | return "Host({!r})".format(self.hostname) 54 | 55 | @classmethod 56 | def _make_api_arg(cls, args, kwargs): 57 | if len(args) == 0: 58 | if len(kwargs) == 0: 59 | return False, None 60 | return True, kwargs 61 | elif len(args) == 1: 62 | arg = args[0] 63 | if len(kwargs) != 0: 64 | raise AssertionError( 65 | "You provided an explicit argument {!r} as well as keyword-style arguments " 66 | "{!r}. You can't provide both.".format(arg, kwargs)) 67 | return True, arg 68 | else: 69 | raise AssertionError("Too many non-keyword arguments: {!r}".format(args)) 70 | 71 | def rpc(self, function, *args, **kwargs): 72 | headers = self._copy_headers(kwargs, 'content-type') 73 | 74 | assert '_b' not in kwargs, "Not expecting body value '_b'" 75 | 76 | include_arg, api_arg = self._make_api_arg(args, kwargs) 77 | if include_arg: 78 | headers['Content-Type'] = 'application/json' 79 | body = json.dumps(api_arg, ensure_ascii=False).encode('utf-8') 80 | else: 81 | body = b'' 82 | 83 | with self._request('POST', function, headers, body=body) as r: 84 | if r.status == 200: 85 | return self._handle_json_body(r, ()) 86 | return self._handle_error(r) 87 | 88 | def up(self, function, *args, **kwargs): 89 | headers = self._copy_headers(kwargs, 'dropbox-api-arg', 'content-type') 90 | 91 | assert '_b' in kwargs, "Missing body value '_b'" 92 | body = kwargs.pop('_b') 93 | assert isinstance(body, bytes), "Expected '_b' to be a bytestring, but got {!r}".format(body) 94 | 95 | include_arg, api_arg = self._make_api_arg(args, kwargs) 96 | if include_arg: 97 | headers['Dropbox-API-Arg'] = json.dumps(api_arg, ensure_ascii=True) 98 | headers['Content-Type'] = 'application/octet-stream' 99 | 100 | with self._request('POST', function, headers, body=body) as r: 101 | if r.status == 200: 102 | return self._handle_json_body(r, ()) 103 | return self._handle_error(r) 104 | 105 | def down(self, function, *args, **kwargs): 106 | headers = self._copy_headers(kwargs) 107 | 108 | assert '_b' not in kwargs, "Not expecting body value '_b'" 109 | 110 | include_arg, api_arg = self._make_api_arg(args, kwargs) 111 | if include_arg: 112 | url_params = {'arg': json.dumps(api_arg, ensure_ascii=False).encode('utf-8')} 113 | 114 | with self._request('GET', function, headers, url_params=url_params) as r: 115 | if r.status in (200, 206): 116 | result_str = r.getheader('Dropbox-API-Result').encode('ascii') 117 | assert result_str is not None, "Missing Dropbox-API-Result response header." 118 | result = json_loads_ordered(result_str) 119 | headers = extract_headers(r, 120 | "ETag", "Cache-Control", "Original-Content-Length", "Content-Range") 121 | return Response(r.status, headers, result, r.read()) 122 | if r.status == 304: 123 | return Response(304, extract_headers(r, "ETag", "Cache-Control")) 124 | return self._handle_error(r) 125 | 126 | def _copy_headers(self, kwargs, *disallowed): 127 | headers = kwargs.pop('_h', {}) 128 | assert isinstance(headers, dict), "Expected '_h' to be a 'dict', got {!r}".format(headers) 129 | disallowed = disallowed + ('authorization',) 130 | for key in headers: 131 | assert key.lower() not in disallowed, "Disallowed header: {!r}".format(key) 132 | headers = headers.copy() 133 | headers['Authorization'] = 'Bearer {}'.format(self.access_token) 134 | return headers 135 | 136 | def _request(self, method, function, headers, url_params=None, body=None): 137 | url_path = "/2/{}".format(urlparse.quote(function)) 138 | if url_params is not None: 139 | url_path = url_path + '?' + urlparse.urlencode(list(url_params.items())) 140 | 141 | # Py2.7 expects byte strings, Py3+ expects unicode strings. 142 | if str == bytes: 143 | method = method.encode('ascii') 144 | url_path = url_path.encode('ascii') 145 | headers = {k.encode('ascii'): v.encode('ascii') for k, v in headers.items()} 146 | 147 | c = httplib.HTTPSConnection(self.hostname) 148 | c.request(method, url_path, body, headers) 149 | return contextlib.closing(c.getresponse()) 150 | 151 | def _handle_json_body(self, r, headers): 152 | ct = r.getheader('Content-Type').encode('ascii') 153 | assert ct == b'application/json', "Bad Content-Type: {!r}".format(ct) 154 | return Response(r.status, headers, json_loads_ordered(r.read())) 155 | 156 | def _handle_error(self, r): 157 | if r.status == 400: 158 | ct = r.getheader('Content-Type').encode('ascii') 159 | assert ct == b'text/plain; charset=utf-8', "Bad Content-Type: {!r}".format(ct) 160 | headers = extract_headers(r, "X-Dropbox-Request-Id") 161 | return Response(r.status, headers, r.read().decode('utf-8')) 162 | if r.status in (401, 403, 404, 409): 163 | headers = extract_headers(r, "X-Dropbox-Request-Id") 164 | return self._handle_json_body(r, headers) 165 | if r.status == 429: 166 | headers = extract_headers(r, "Retry-After", "X-Dropbox-Request-Id") 167 | return Response(r.status, headers) 168 | if r.status in (500, 503): 169 | headers = extract_headers(r, "X-Dropbox-Request-Id") 170 | return Response(r.status, headers) 171 | raise AssertionError("unexpected response code: {!r}, {!r}".format(r.status, r.read())) 172 | 173 | def json_loads_ordered(s): 174 | assert isinstance(s, bytes), repr(s) 175 | u = s.decode('utf-8') 176 | return json.JSONDecoder(object_pairs_hook=collections.OrderedDict).decode(u) 177 | 178 | def extract_headers(r, *targets): 179 | targets_lower = {t.lower(): t for t in targets} 180 | h = [] 181 | for k, v in r.getheaders(): 182 | t = targets_lower.get(k.lower()) 183 | if t is not None: 184 | h.append((t, v.encode('ascii'))) 185 | return h 186 | 187 | class Response(object): 188 | def __init__(self, status, headers, result=None, content=None): 189 | self.status = status 190 | self.headers = headers 191 | self.result = result 192 | self.content = content 193 | 194 | def __repr__(self): 195 | r = ["HTTP {}".format(self.status)] 196 | for key, value in self.headers: 197 | r.append("{}: {}".format(key, devq(value))) 198 | if self.result is not None: 199 | r.append(json.dumps(self.result, indent=4)) 200 | if self.content is not None: 201 | r.append("<{} bytes> {!r}".format(len(self.content), self.content[:50])) 202 | return '\n'.join(r) 203 | 204 | class StringRepr(object): 205 | def __init__(self, s): self.s = s 206 | def __repr__(self): return self.s 207 | 208 | hint = StringRepr('\n'.join([ 209 | "", 210 | "Use 'a' to make requests to the \"api\" server.", 211 | "Use 'c' to make requests to the \"content\" server.", 212 | "", 213 | "Examples:", 214 | " a.rpc('files/get_metadata', path='/Camera Uploads')", 215 | " c.up('files/upload', path='/faq.txt', mode='add', _b=b'What?')", 216 | " c.down('files/download', path='/faq.txt', _h={'If-None-Match': 'W/\"1234\"'})", 217 | "", 218 | ])) 219 | 220 | def load_auth_json(auth_file): 221 | try: 222 | with open(auth_file, 'rb') as f: 223 | data = f.read() 224 | except OSError as e: 225 | raise AuthJsonLoadError("unable to read file: {}".format(e)) 226 | 227 | try: 228 | auth_json = json.loads(data.decode('utf-8')) 229 | except UnicodeDecodeError as e: 230 | raise AuthJsonLoadError("invalid UTF-8: {}".format(e)) 231 | except ValueError as e: 232 | raise AuthJsonLoadError("not valid JSON: {}".format(e)) 233 | 234 | if not isinstance(auth_json, dict): 235 | raise AuthJsonLoadError("doesn't contain a JSON object at the top level") 236 | 237 | access_token = auth_json.pop('access_token', None) 238 | if access_token is None: 239 | raise AuthJsonLoadError("missing field \"access_token\"") 240 | elif not isinstance(access_token, unicode): 241 | raise AuthJsonLoadError("expecting \"access_token\" to be a string"); 242 | 243 | host_suffix = auth_json.pop('host_suffix', None) 244 | 245 | if host_suffix is None: 246 | host_suffix = '.dropboxapi.com' 247 | elif not isinstance(host_suffix, unicode): 248 | raise AuthJsonLoadError("expecting \"host_suffix\" to be a string"); 249 | 250 | if len(auth_json) > 0: 251 | raise AuthJsonLoadError("unexpected fields: {}".format(devql(auth_json.keys()))) 252 | 253 | return access_token, host_suffix 254 | 255 | class AuthJsonLoadError(Exception): 256 | pass 257 | 258 | def devq(s): 259 | if isinstance(s, bytes): 260 | s = s.decode('ascii') 261 | elif isinstance(s, unicode): 262 | pass 263 | else: 264 | raise AssertionError("bad type: {!r}".format(s)) 265 | return json.dumps(s) 266 | 267 | def devql(l): 268 | return ', '.join(map(devq, l)) 269 | 270 | def parse_args_or_exit(prog_name, err, out, args): 271 | remaining = [] 272 | repl_prev = None 273 | repl_preference = None 274 | 275 | def check_prev(arg): 276 | if repl_prev is not None: 277 | err.write("Duplicate/conflicting flags: \"{}\", \"-ri\".\n".format(repl_prev, arg)) 278 | err.write("Run with \"--help\" for more information.\n") 279 | sys.exit(1) 280 | return arg 281 | 282 | for i in range(len(args)): 283 | arg = args[i] 284 | if arg.startswith('-'): 285 | if arg == '-ri': 286 | repl_prev = check_prev(arg) 287 | repl_preference = 'ipython' 288 | elif arg == '-rs': 289 | repl_prev = check_prev(arg) 290 | repl_preference = 'standard' 291 | elif arg in ('-h', '--help'): 292 | if len(args) != 1: 293 | err.write("\"{}\" must be used by itself.\n".format(arg)) 294 | err.write("Run with \"--help\" for more information.\n") 295 | sys.exit(1) 296 | print_usage(prog_name, out) 297 | sys.exit(0) 298 | else: 299 | err.write("Invalid option: {}.\n".format(devq(arg))) 300 | err.write("Run with \"--help\" for more information.\n") 301 | sys.exit(1) 302 | else: 303 | remaining.append(arg) 304 | 305 | if len(remaining) == 0: 306 | err.write("Missing argument.\n") 307 | err.write("Run with \"--help\" for more information.\n") 308 | sys.exit(1) 309 | 310 | if len(remaining) != 1: 311 | err.write("Expecting one non-option argument, got {}: {}" 312 | .format(len(remaining), devql(remaining))) 313 | err.write("Run with \"--help\" for more information.\n") 314 | sys.exit(1) 315 | 316 | auth_file = remaining[0] 317 | 318 | # Load the appropriate REPL. 319 | if repl_preference == 'ipython': 320 | # IPython required. 321 | repl = try_creating_ipython_repl(err) 322 | if repl is None: 323 | err.write("To fall back to the standard Python REPL, don't use the \"-ri\" option.\n") 324 | sys.exit(1) 325 | elif repl_preference == 'standard': 326 | # Use the standard REPL. 327 | repl = standard_repl 328 | elif repl_preference is None: 329 | # Try IPython. If that fails, use the standard REPL. 330 | repl = try_creating_ipython_repl(None) 331 | if repl is None: 332 | err.write("Unable to load IPython; falling back to the standard Python REPL.\n") 333 | err.write("(Run with \"-ri\" to see details; run with \"-rs\" to hide this warning.)\n") 334 | repl = standard_repl 335 | else: 336 | raise AssertionError("bad value: {!r}".format(repl_preference)) 337 | 338 | return auth_file, repl 339 | 340 | def standard_repl(symbols): 341 | code.interact(banner='', local=symbols) 342 | 343 | def try_creating_ipython_repl(err): 344 | try: 345 | import IPython 346 | if IPython.__version__.startswith('0.'): 347 | if err is not None: 348 | err.write("The current IPython version is {}, but this script requires at least 1.0.\n" 349 | .format(IPython.__version__)) 350 | err.write("To upgrade IPython, try: \"sudo pip install ipython --upgrade\"\n") 351 | return None 352 | 353 | def repl(symbols): 354 | ipython = IPython.terminal.interactiveshell.TerminalInteractiveShell(user_ns=symbols) 355 | ipython.confirm_exit = False 356 | ipython.interact() 357 | return repl 358 | 359 | except ImportError as e: 360 | if err is not None: 361 | err.write("Unable to import \"IPython\": {}\n".format(e)) 362 | err.write("To install IPython, try: \"sudo pip install ipython\"\n") 363 | return None 364 | 365 | def print_usage(prog_name, out): 366 | out.write("Usage: {} [options...] \n") 367 | out.write("\n") 368 | out.write(" : See ReadMe.md for information on how to create this file.\n") 369 | out.write("\n") 370 | out.write(" -ri: Use IPython for the REPL.\n") 371 | out.write(" -rs: Use the standard Python REPL.\n") 372 | out.write("\n") 373 | 374 | if __name__ == '__main__': 375 | main() 376 | --------------------------------------------------------------------------------