diff options
Diffstat (limited to 'codereview/proto_client.py')
-rwxr-xr-x | codereview/proto_client.py | 349 |
1 files changed, 349 insertions, 0 deletions
diff --git a/codereview/proto_client.py b/codereview/proto_client.py new file mode 100755 index 00000000..e11beff0 --- /dev/null +++ b/codereview/proto_client.py | |||
@@ -0,0 +1,349 @@ | |||
1 | # Copyright 2007, 2008 Google Inc. | ||
2 | # | ||
3 | # Licensed under the Apache License, Version 2.0 (the "License"); | ||
4 | # you may not use this file except in compliance with the License. | ||
5 | # You may obtain a copy of the License at | ||
6 | # | ||
7 | # http://www.apache.org/licenses/LICENSE-2.0 | ||
8 | # | ||
9 | # Unless required by applicable law or agreed to in writing, software | ||
10 | # distributed under the License is distributed on an "AS IS" BASIS, | ||
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
12 | # See the License for the specific language governing permissions and | ||
13 | # limitations under the License. | ||
14 | |||
15 | import base64 | ||
16 | import cookielib | ||
17 | import getpass | ||
18 | import logging | ||
19 | import md5 | ||
20 | import os | ||
21 | import random | ||
22 | import socket | ||
23 | import time | ||
24 | import urllib | ||
25 | import urllib2 | ||
26 | import urlparse | ||
27 | |||
28 | from froofle.protobuf.service import RpcChannel | ||
29 | from froofle.protobuf.service import RpcController | ||
30 | from need_retry_pb2 import RetryRequestLaterResponse; | ||
31 | |||
32 | class ClientLoginError(urllib2.HTTPError): | ||
33 | """Raised to indicate an error authenticating with ClientLogin.""" | ||
34 | |||
35 | def __init__(self, url, code, msg, headers, args): | ||
36 | urllib2.HTTPError.__init__(self, url, code, msg, headers, None) | ||
37 | self.args = args | ||
38 | self.reason = args["Error"] | ||
39 | |||
40 | |||
41 | class Proxy(object): | ||
42 | class _ResultHolder(object): | ||
43 | def __call__(self, result): | ||
44 | self._result = result | ||
45 | |||
46 | class _RemoteController(RpcController): | ||
47 | def Reset(self): | ||
48 | pass | ||
49 | |||
50 | def Failed(self): | ||
51 | pass | ||
52 | |||
53 | def ErrorText(self): | ||
54 | pass | ||
55 | |||
56 | def StartCancel(self): | ||
57 | pass | ||
58 | |||
59 | def SetFailed(self, reason): | ||
60 | raise RuntimeError, reason | ||
61 | |||
62 | def IsCancelled(self): | ||
63 | pass | ||
64 | |||
65 | def NotifyOnCancel(self, callback): | ||
66 | pass | ||
67 | |||
68 | def __init__(self, stub): | ||
69 | self._stub = stub | ||
70 | |||
71 | def __getattr__(self, key): | ||
72 | method = getattr(self._stub, key) | ||
73 | |||
74 | def call(request): | ||
75 | done = self._ResultHolder() | ||
76 | method(self._RemoteController(), request, done) | ||
77 | return done._result | ||
78 | |||
79 | return call | ||
80 | |||
81 | |||
82 | class HttpRpc(RpcChannel): | ||
83 | """Simple protobuf over HTTP POST implementation.""" | ||
84 | |||
85 | def __init__(self, host, auth_function, | ||
86 | host_override=None, | ||
87 | extra_headers={}, | ||
88 | cookie_file=None): | ||
89 | """Creates a new HttpRpc. | ||
90 | |||
91 | Args: | ||
92 | host: The host to send requests to. | ||
93 | auth_function: A function that takes no arguments and returns an | ||
94 | (email, password) tuple when called. Will be called if authentication | ||
95 | is required. | ||
96 | host_override: The host header to send to the server (defaults to host). | ||
97 | extra_headers: A dict of extra headers to append to every request. | ||
98 | cookie_file: If not None, name of the file in ~/ to save the | ||
99 | cookie jar into. Applications are encouraged to set this to | ||
100 | '.$appname_cookies' or some otherwise unique name. | ||
101 | """ | ||
102 | self.host = host.lower() | ||
103 | self.host_override = host_override | ||
104 | self.auth_function = auth_function | ||
105 | self.authenticated = False | ||
106 | self.extra_headers = extra_headers | ||
107 | self.xsrf_token = None | ||
108 | if cookie_file is None: | ||
109 | self.cookie_file = None | ||
110 | else: | ||
111 | self.cookie_file = os.path.expanduser("~/%s" % cookie_file) | ||
112 | self.opener = self._GetOpener() | ||
113 | if self.host_override: | ||
114 | logging.info("Server: %s; Host: %s", self.host, self.host_override) | ||
115 | else: | ||
116 | logging.info("Server: %s", self.host) | ||
117 | |||
118 | def CallMethod(self, method, controller, request, response_type, done): | ||
119 | pat = "application/x-google-protobuf; name=%s" | ||
120 | |||
121 | url = "/proto/%s/%s" % (method.containing_service.name, method.name) | ||
122 | reqbin = request.SerializeToString() | ||
123 | reqtyp = pat % request.DESCRIPTOR.full_name | ||
124 | reqmd5 = base64.b64encode(md5.new(reqbin).digest()) | ||
125 | |||
126 | start = time.time() | ||
127 | while True: | ||
128 | t, b = self._Send(url, reqbin, reqtyp, reqmd5) | ||
129 | if t == (pat % RetryRequestLaterResponse.DESCRIPTOR.full_name): | ||
130 | if time.time() >= (start + 1800): | ||
131 | controller.SetFailed("timeout") | ||
132 | return | ||
133 | s = random.uniform(0.250, 2.000) | ||
134 | print "Busy, retrying in %.3f seconds ..." % s | ||
135 | time.sleep(s) | ||
136 | continue | ||
137 | |||
138 | if t == (pat % response_type.DESCRIPTOR.full_name): | ||
139 | response = response_type() | ||
140 | response.ParseFromString(b) | ||
141 | done(response) | ||
142 | else: | ||
143 | controller.SetFailed("Unexpected %s response" % t) | ||
144 | break | ||
145 | |||
146 | def _CreateRequest(self, url, data=None): | ||
147 | """Creates a new urllib request.""" | ||
148 | logging.debug("Creating request for: '%s' with payload:\n%s", url, data) | ||
149 | req = urllib2.Request(url, data=data) | ||
150 | if self.host_override: | ||
151 | req.add_header("Host", self.host_override) | ||
152 | for key, value in self.extra_headers.iteritems(): | ||
153 | req.add_header(key, value) | ||
154 | return req | ||
155 | |||
156 | def _GetAuthToken(self, email, password): | ||
157 | """Uses ClientLogin to authenticate the user, returning an auth token. | ||
158 | |||
159 | Args: | ||
160 | email: The user's email address | ||
161 | password: The user's password | ||
162 | |||
163 | Raises: | ||
164 | ClientLoginError: If there was an error authenticating with ClientLogin. | ||
165 | HTTPError: If there was some other form of HTTP error. | ||
166 | |||
167 | Returns: | ||
168 | The authentication token returned by ClientLogin. | ||
169 | """ | ||
170 | req = self._CreateRequest( | ||
171 | url="https://www.google.com/accounts/ClientLogin", | ||
172 | data=urllib.urlencode({ | ||
173 | "Email": email, | ||
174 | "Passwd": password, | ||
175 | "service": "ah", | ||
176 | "source": "gerrit-codereview-client", | ||
177 | "accountType": "HOSTED_OR_GOOGLE", | ||
178 | }) | ||
179 | ) | ||
180 | try: | ||
181 | response = self.opener.open(req) | ||
182 | response_body = response.read() | ||
183 | response_dict = dict(x.split("=") | ||
184 | for x in response_body.split("\n") if x) | ||
185 | return response_dict["Auth"] | ||
186 | except urllib2.HTTPError, e: | ||
187 | if e.code == 403: | ||
188 | body = e.read() | ||
189 | response_dict = dict(x.split("=", 1) for x in body.split("\n") if x) | ||
190 | raise ClientLoginError(req.get_full_url(), e.code, e.msg, | ||
191 | e.headers, response_dict) | ||
192 | else: | ||
193 | raise | ||
194 | |||
195 | def _GetAuthCookie(self, auth_token): | ||
196 | """Fetches authentication cookies for an authentication token. | ||
197 | |||
198 | Args: | ||
199 | auth_token: The authentication token returned by ClientLogin. | ||
200 | |||
201 | Raises: | ||
202 | HTTPError: If there was an error fetching the authentication cookies. | ||
203 | """ | ||
204 | # This is a dummy value to allow us to identify when we're successful. | ||
205 | continue_location = "http://localhost/" | ||
206 | args = {"continue": continue_location, "auth": auth_token} | ||
207 | req = self._CreateRequest("http://%s/_ah/login?%s" % | ||
208 | (self.host, urllib.urlencode(args))) | ||
209 | try: | ||
210 | response = self.opener.open(req) | ||
211 | except urllib2.HTTPError, e: | ||
212 | response = e | ||
213 | if (response.code != 302 or | ||
214 | response.info()["location"] != continue_location): | ||
215 | raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, | ||
216 | response.headers, response.fp) | ||
217 | self.authenticated = True | ||
218 | |||
219 | def _GetXsrfToken(self): | ||
220 | """Fetches /proto/_token for use in X-XSRF-Token HTTP header. | ||
221 | |||
222 | Raises: | ||
223 | HTTPError: If there was an error fetching a new token. | ||
224 | """ | ||
225 | tries = 0 | ||
226 | while True: | ||
227 | url = "http://%s/proto/_token" % self.host | ||
228 | req = self._CreateRequest(url) | ||
229 | try: | ||
230 | response = self.opener.open(req) | ||
231 | self.xsrf_token = response.read() | ||
232 | return | ||
233 | except urllib2.HTTPError, e: | ||
234 | if tries > 3: | ||
235 | raise | ||
236 | elif e.code == 401: | ||
237 | self._Authenticate() | ||
238 | else: | ||
239 | raise | ||
240 | |||
241 | def _Authenticate(self): | ||
242 | """Authenticates the user. | ||
243 | |||
244 | The authentication process works as follows: | ||
245 | 1) We get a username and password from the user | ||
246 | 2) We use ClientLogin to obtain an AUTH token for the user | ||
247 | (see http://code.google.com/apis/accounts/AuthForInstalledApps.html). | ||
248 | 3) We pass the auth token to /_ah/login on the server to obtain an | ||
249 | authentication cookie. If login was successful, it tries to redirect | ||
250 | us to the URL we provided. | ||
251 | |||
252 | If we attempt to access the upload API without first obtaining an | ||
253 | authentication cookie, it returns a 401 response and directs us to | ||
254 | authenticate ourselves with ClientLogin. | ||
255 | """ | ||
256 | for i in range(3): | ||
257 | credentials = self.auth_function() | ||
258 | auth_token = self._GetAuthToken(credentials[0], credentials[1]) | ||
259 | self._GetAuthCookie(auth_token) | ||
260 | if self.cookie_file is not None: | ||
261 | self.cookie_jar.save() | ||
262 | return | ||
263 | |||
264 | def _Send(self, request_path, payload, content_type, content_md5): | ||
265 | """Sends an RPC and returns the response. | ||
266 | |||
267 | Args: | ||
268 | request_path: The path to send the request to, eg /api/appversion/create. | ||
269 | payload: The body of the request, or None to send an empty request. | ||
270 | content_type: The Content-Type header to use. | ||
271 | content_md5: The Content-MD5 header to use. | ||
272 | |||
273 | Returns: | ||
274 | The content type, as a string. | ||
275 | The response body, as a string. | ||
276 | """ | ||
277 | if not self.authenticated: | ||
278 | self._Authenticate() | ||
279 | if not self.xsrf_token: | ||
280 | self._GetXsrfToken() | ||
281 | |||
282 | old_timeout = socket.getdefaulttimeout() | ||
283 | socket.setdefaulttimeout(None) | ||
284 | try: | ||
285 | tries = 0 | ||
286 | while True: | ||
287 | tries += 1 | ||
288 | url = "http://%s%s" % (self.host, request_path) | ||
289 | req = self._CreateRequest(url=url, data=payload) | ||
290 | req.add_header("Content-Type", content_type) | ||
291 | req.add_header("Content-MD5", content_md5) | ||
292 | req.add_header("X-XSRF-Token", self.xsrf_token) | ||
293 | try: | ||
294 | f = self.opener.open(req) | ||
295 | hdr = f.info() | ||
296 | type = hdr.getheader('Content-Type', | ||
297 | 'application/octet-stream') | ||
298 | response = f.read() | ||
299 | f.close() | ||
300 | return type, response | ||
301 | except urllib2.HTTPError, e: | ||
302 | if tries > 3: | ||
303 | raise | ||
304 | elif e.code == 401: | ||
305 | self._Authenticate() | ||
306 | elif e.code == 403: | ||
307 | if not hasattr(e, 'read'): | ||
308 | e.read = lambda self: '' | ||
309 | raise RuntimeError, '403\nxsrf: %s\n%s' \ | ||
310 | % (self.xsrf_token, e.read()) | ||
311 | else: | ||
312 | raise | ||
313 | finally: | ||
314 | socket.setdefaulttimeout(old_timeout) | ||
315 | |||
316 | def _GetOpener(self): | ||
317 | """Returns an OpenerDirector that supports cookies and ignores redirects. | ||
318 | |||
319 | Returns: | ||
320 | A urllib2.OpenerDirector object. | ||
321 | """ | ||
322 | opener = urllib2.OpenerDirector() | ||
323 | opener.add_handler(urllib2.ProxyHandler()) | ||
324 | opener.add_handler(urllib2.UnknownHandler()) | ||
325 | opener.add_handler(urllib2.HTTPHandler()) | ||
326 | opener.add_handler(urllib2.HTTPDefaultErrorHandler()) | ||
327 | opener.add_handler(urllib2.HTTPSHandler()) | ||
328 | opener.add_handler(urllib2.HTTPErrorProcessor()) | ||
329 | if self.cookie_file is not None: | ||
330 | self.cookie_jar = cookielib.MozillaCookieJar(self.cookie_file) | ||
331 | if os.path.exists(self.cookie_file): | ||
332 | try: | ||
333 | self.cookie_jar.load() | ||
334 | self.authenticated = True | ||
335 | except (cookielib.LoadError, IOError): | ||
336 | # Failed to load cookies - just ignore them. | ||
337 | pass | ||
338 | else: | ||
339 | # Create an empty cookie file with mode 600 | ||
340 | fd = os.open(self.cookie_file, os.O_CREAT, 0600) | ||
341 | os.close(fd) | ||
342 | # Always chmod the cookie file | ||
343 | os.chmod(self.cookie_file, 0600) | ||
344 | else: | ||
345 | # Don't save cookies across runs of update.py. | ||
346 | self.cookie_jar = cookielib.CookieJar() | ||
347 | opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar)) | ||
348 | return opener | ||
349 | |||