blob: 66edf42105b76abb2dcf2edce170c5a397eeeecc [file] [log] [blame]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +00001#!/usr/bin/env python
2# Copyright (c) 2012 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Get stats about your activity.
7
8Example:
9 - my_activity.py for stats for the current week (last week on mondays).
10 - my_activity.py -Q for stats for last quarter.
11 - my_activity.py -Y for stats for this year.
12 - my_activity.py -b 4/5/12 for stats since 4/5/12.
13 - my_activity.py -b 4/5/12 -e 6/7/12 for stats between 4/5/12 and 6/7/12.
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000014 - my_activity.py -jd to output stats for the week to json with deltas data.
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000015"""
16
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000017# TODO(vadimsh): This script knows too much about ClientLogin and cookies. It
18# will stop to work on ~20 Apr 2015.
19
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000020# These services typically only provide a created time and a last modified time
21# for each item for general queries. This is not enough to determine if there
22# was activity in a given time period. So, we first query for all things created
23# before end and modified after begin. Then, we get the details of each item and
24# check those details to determine if there was activity in the given period.
25# This means that query time scales mostly with (today() - begin).
26
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000027from datetime import datetime
28from datetime import timedelta
29from functools import partial
30import json
Tobias Sargeantffb3c432017-03-08 14:09:14 +000031import logging
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000032import optparse
33import os
34import subprocess
Tobias Sargeantffb3c432017-03-08 14:09:14 +000035from string import Formatter
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000036import sys
37import urllib
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +000038import re
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000039
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000040import auth
stevefung@chromium.org832d51e2015-05-27 12:52:51 +000041import fix_encoding
deymo@chromium.orgf8be2762013-11-06 01:01:59 +000042import gerrit_util
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000043import rietveld
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000044
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000045from third_party import httplib2
46
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000047try:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000048 import dateutil # pylint: disable=import-error
49 import dateutil.parser
50 from dateutil.relativedelta import relativedelta
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000051except ImportError:
Tobias Sargeantffb3c432017-03-08 14:09:14 +000052 logging.error('python-dateutil package required')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000053 exit(1)
54
Tobias Sargeantffb3c432017-03-08 14:09:14 +000055
56class DefaultFormatter(Formatter):
57 def __init__(self, default = ''):
58 super(DefaultFormatter, self).__init__()
59 self.default = default
60
61 def get_value(self, key, args, kwds):
62 if isinstance(key, basestring) and key not in kwds:
63 return self.default
64 return Formatter.get_value(self, key, args, kwds)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000065
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000066rietveld_instances = [
67 {
68 'url': 'codereview.chromium.org',
69 'shorturl': 'crrev.com',
70 'supports_owner_modified_query': True,
71 'requires_auth': False,
72 'email_domain': 'chromium.org',
Varun Khanejad9f97bc2017-08-02 12:55:01 -070073 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +000074 },
75 {
76 'url': 'chromereviews.googleplex.com',
77 'shorturl': 'go/chromerev',
78 'supports_owner_modified_query': True,
79 'requires_auth': True,
80 'email_domain': 'google.com',
81 },
82 {
83 'url': 'codereview.appspot.com',
84 'supports_owner_modified_query': True,
85 'requires_auth': False,
86 'email_domain': 'chromium.org',
87 },
88 {
89 'url': 'breakpad.appspot.com',
90 'supports_owner_modified_query': False,
91 'requires_auth': False,
92 'email_domain': 'chromium.org',
93 },
94]
95
96gerrit_instances = [
97 {
deymo@chromium.org6c039202013-09-12 12:28:12 +000098 'url': 'chromium-review.googlesource.com',
Jeremy Romanf475a472017-06-06 16:49:11 -040099 'shorturl': 'crrev.com/c',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700100 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000101 },
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000102 {
103 'url': 'chrome-internal-review.googlesource.com',
Jeremy Romanf475a472017-06-06 16:49:11 -0400104 'shorturl': 'crrev.com/i',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700105 'short_url_protocol': 'https',
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000106 },
deymo@chromium.org56dc57a2015-09-10 18:26:54 +0000107 {
108 'url': 'android-review.googlesource.com',
109 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000110]
111
112google_code_projects = [
113 {
114 'name': 'chromium',
115 'shorturl': 'crbug.com',
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700116 'short_url_protocol': 'https',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000117 },
118 {
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000119 'name': 'google-breakpad',
120 },
121 {
122 'name': 'gyp',
enne@chromium.orgf01fad32012-11-26 18:09:38 +0000123 },
124 {
125 'name': 'skia',
126 },
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000127]
128
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000129def username(email):
130 """Keeps the username of an email address."""
131 return email and email.split('@', 1)[0]
132
133
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000134def datetime_to_midnight(date):
135 return date - timedelta(hours=date.hour, minutes=date.minute,
136 seconds=date.second, microseconds=date.microsecond)
137
138
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000139def get_quarter_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000140 begin = (datetime_to_midnight(date) -
141 relativedelta(months=(date.month % 3) - 1, days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000142 return begin, begin + relativedelta(months=3)
143
144
145def get_year_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000146 begin = (datetime_to_midnight(date) -
147 relativedelta(months=(date.month - 1), days=(date.day - 1)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000148 return begin, begin + relativedelta(years=1)
149
150
151def get_week_of(date):
cjhopman@chromium.org426557a2012-10-22 20:18:52 +0000152 begin = (datetime_to_midnight(date) - timedelta(days=date.weekday()))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000153 return begin, begin + timedelta(days=7)
154
155
156def get_yes_or_no(msg):
157 while True:
158 response = raw_input(msg + ' yes/no [no] ')
159 if response == 'y' or response == 'yes':
160 return True
161 elif not response or response == 'n' or response == 'no':
162 return False
163
164
deymo@chromium.org6c039202013-09-12 12:28:12 +0000165def datetime_from_gerrit(date_string):
166 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f000')
167
168
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000169def datetime_from_rietveld(date_string):
deymo@chromium.org29eb6e62014-03-20 01:55:55 +0000170 try:
171 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S.%f')
172 except ValueError:
173 # Sometimes rietveld returns a value without the milliseconds part, so we
174 # attempt to parse those cases as well.
175 return datetime.strptime(date_string, '%Y-%m-%d %H:%M:%S')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000176
177
178def datetime_from_google_code(date_string):
179 return datetime.strptime(date_string, '%Y-%m-%dT%H:%M:%S.%fZ')
180
181
182class MyActivity(object):
183 def __init__(self, options):
184 self.options = options
185 self.modified_after = options.begin
186 self.modified_before = options.end
187 self.user = options.user
188 self.changes = []
189 self.reviews = []
190 self.issues = []
191 self.check_cookies()
192 self.google_code_auth_token = None
193
194 # Check the codereview cookie jar to determine which Rietveld instances to
195 # authenticate to.
196 def check_cookies(self):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000197 filtered_instances = []
198
199 def has_cookie(instance):
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000200 auth_config = auth.extract_auth_config_from_options(self.options)
201 a = auth.get_authenticator_for_host(instance['url'], auth_config)
202 return a.has_cached_credentials()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000203
204 for instance in rietveld_instances:
205 instance['auth'] = has_cookie(instance)
206
207 if filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000208 logging.warning('No cookie found for the following Rietveld instance%s:',
209 's' if len(filtered_instances) > 1 else '')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000210 for instance in filtered_instances:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000211 logging.warning('\t' + instance['url'])
212 logging.warning('Use --auth if you would like to authenticate to them.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000213
214 def rietveld_search(self, instance, owner=None, reviewer=None):
215 if instance['requires_auth'] and not instance['auth']:
216 return []
217
218
219 email = None if instance['auth'] else ''
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000220 auth_config = auth.extract_auth_config_from_options(self.options)
221 remote = rietveld.Rietveld('https://' + instance['url'], auth_config, email)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000222
223 # See def search() in rietveld.py to see all the filters you can use.
224 query_modified_after = None
225
226 if instance['supports_owner_modified_query']:
227 query_modified_after = self.modified_after.strftime('%Y-%m-%d')
228
229 # Rietveld does not allow search by both created_before and modified_after.
230 # (And some instances don't allow search by both owner and modified_after)
231 owner_email = None
232 reviewer_email = None
233 if owner:
234 owner_email = owner + '@' + instance['email_domain']
235 if reviewer:
236 reviewer_email = reviewer + '@' + instance['email_domain']
237 issues = remote.search(
238 owner=owner_email,
239 reviewer=reviewer_email,
240 modified_after=query_modified_after,
241 with_messages=True)
242
243 issues = filter(
244 lambda i: (datetime_from_rietveld(i['created']) < self.modified_before),
245 issues)
246 issues = filter(
247 lambda i: (datetime_from_rietveld(i['modified']) > self.modified_after),
248 issues)
249
250 should_filter_by_user = True
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000251 issues = map(partial(self.process_rietveld_issue, remote, instance), issues)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000252 issues = filter(
253 partial(self.filter_issue, should_filter_by_user=should_filter_by_user),
254 issues)
255 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
256
257 return issues
258
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000259 def extract_bug_number_from_description(self, issue):
260 description = None
261
262 if 'description' in issue:
263 # Getting the description for Rietveld
264 description = issue['description']
265 elif 'revisions' in issue:
266 # Getting the description for REST Gerrit
267 revision = issue['revisions'][issue['current_revision']]
268 description = revision['commit']['message']
269
270 bugs = []
271 if description:
Nicolas Dossou-gbete903ea732017-07-10 16:46:59 +0100272 # Handle both "Bug: 99999" and "BUG=99999" bug notations
273 # Multiple bugs can be noted on a single line or in multiple ones.
274 matches = re.findall(r'BUG[=:]\s?(((\d+)(,\s?)?)+)', description,
275 flags=re.IGNORECASE)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000276 if matches:
277 for match in matches:
278 bugs.extend(match[0].replace(' ', '').split(','))
279
280 return bugs
281
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000282 def process_rietveld_issue(self, remote, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000283 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000284 if self.options.deltas:
285 patchset_props = remote.get_patchset_properties(
286 issue['issue'],
287 issue['patchsets'][-1])
288 ret['delta'] = '+%d,-%d' % (
289 sum(f['num_added'] for f in patchset_props['files'].itervalues()),
290 sum(f['num_removed'] for f in patchset_props['files'].itervalues()))
291
292 if issue['landed_days_ago'] != 'unknown':
293 ret['status'] = 'committed'
294 elif issue['closed']:
295 ret['status'] = 'closed'
296 elif len(issue['reviewers']) and issue['all_required_reviewers_approved']:
297 ret['status'] = 'ready'
298 else:
299 ret['status'] = 'open'
300
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000301 ret['owner'] = issue['owner_email']
302 ret['author'] = ret['owner']
303
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000304 ret['reviewers'] = set(issue['reviewers'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000305
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000306 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700307 url = instance['shorturl']
308 protocol = instance.get('short_url_protocol', 'http')
309 else:
310 url = instance['url']
311 protocol = 'https'
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000312
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700313 ret['review_url'] = '%s://%s/%d' % (protocol, url, issue['issue'])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000314
315 # Rietveld sometimes has '\r\n' instead of '\n'.
316 ret['header'] = issue['description'].replace('\r', '').split('\n')[0]
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000317
318 ret['modified'] = datetime_from_rietveld(issue['modified'])
319 ret['created'] = datetime_from_rietveld(issue['created'])
320 ret['replies'] = self.process_rietveld_replies(issue['messages'])
321
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000322 ret['bug'] = self.extract_bug_number_from_description(issue)
323 ret['landed_days_ago'] = issue['landed_days_ago']
324
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000325 return ret
326
327 @staticmethod
328 def process_rietveld_replies(replies):
329 ret = []
330 for reply in replies:
331 r = {}
332 r['author'] = reply['sender']
333 r['created'] = datetime_from_rietveld(reply['date'])
334 r['content'] = ''
335 ret.append(r)
336 return ret
337
deymo@chromium.org6c039202013-09-12 12:28:12 +0000338 @staticmethod
339 def gerrit_changes_over_ssh(instance, filters):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000340 # See https://review.openstack.org/Documentation/cmd-query.html
341 # Gerrit doesn't allow filtering by created time, only modified time.
deymo@chromium.org6c039202013-09-12 12:28:12 +0000342 gquery_cmd = ['ssh', '-p', str(instance['port']), instance['host'],
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000343 'gerrit', 'query',
344 '--format', 'JSON',
345 '--comments',
deymo@chromium.org6c039202013-09-12 12:28:12 +0000346 '--'] + filters
347 (stdout, _) = subprocess.Popen(gquery_cmd, stdout=subprocess.PIPE,
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000348 stderr=subprocess.PIPE).communicate()
deymo@chromium.org6c039202013-09-12 12:28:12 +0000349 # Drop the last line of the output with the stats.
350 issues = stdout.splitlines()[:-1]
351 return map(json.loads, issues)
352
353 @staticmethod
354 def gerrit_changes_over_rest(instance, filters):
Michael Achenbach6fbf12f2017-07-06 10:54:11 +0200355 # Convert the "key:value" filter to a list of (key, value) pairs.
356 req = list(f.split(':', 1) for f in filters)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000357 try:
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000358 # Instantiate the generator to force all the requests now and catch the
359 # errors here.
360 return list(gerrit_util.GenerateAllChanges(instance['url'], req,
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000361 o_params=['MESSAGES', 'LABELS', 'DETAILED_ACCOUNTS',
362 'CURRENT_REVISION', 'CURRENT_COMMIT']))
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000363 except gerrit_util.GerritError, e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000364 logging.error('Looking up %r: %s', instance['url'], e)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000365 return []
366
deymo@chromium.org6c039202013-09-12 12:28:12 +0000367 def gerrit_search(self, instance, owner=None, reviewer=None):
368 max_age = datetime.today() - self.modified_after
369 max_age = max_age.days * 24 * 3600 + max_age.seconds
370 user_filter = 'owner:%s' % owner if owner else 'reviewer:%s' % reviewer
371 filters = ['-age:%ss' % max_age, user_filter]
372
373 # Determine the gerrit interface to use: SSH or REST API:
374 if 'host' in instance:
375 issues = self.gerrit_changes_over_ssh(instance, filters)
376 issues = [self.process_gerrit_ssh_issue(instance, issue)
377 for issue in issues]
378 elif 'url' in instance:
379 issues = self.gerrit_changes_over_rest(instance, filters)
380 issues = [self.process_gerrit_rest_issue(instance, issue)
381 for issue in issues]
382 else:
383 raise Exception('Invalid gerrit_instances configuration.')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000384
385 # TODO(cjhopman): should we filter abandoned changes?
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000386 issues = filter(self.filter_issue, issues)
387 issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
388
389 return issues
390
deymo@chromium.org6c039202013-09-12 12:28:12 +0000391 def process_gerrit_ssh_issue(self, instance, issue):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000392 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000393 if self.options.deltas:
394 ret['delta'] = DefaultFormatter().format(
395 '+{insertions},-{deletions}',
396 **issue)
397 ret['status'] = issue['status']
deymo@chromium.orge52bd5a2013-08-29 18:05:21 +0000398 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700399 protocol = instance.get('short_url_protocol', 'http')
400 ret['review_url'] = '%s://%s/%s' % (protocol, instance['shorturl'],
401 issue['number'])
402 else:
403 ret['review_url'] = issue['url']
404
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000405 ret['header'] = issue['subject']
406 ret['owner'] = issue['owner']['email']
407 ret['author'] = ret['owner']
408 ret['created'] = datetime.fromtimestamp(issue['createdOn'])
409 ret['modified'] = datetime.fromtimestamp(issue['lastUpdated'])
410 if 'comments' in issue:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000411 ret['replies'] = self.process_gerrit_ssh_issue_replies(issue['comments'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000412 else:
413 ret['replies'] = []
deymo@chromium.org6c039202013-09-12 12:28:12 +0000414 ret['reviewers'] = set(r['author'] for r in ret['replies'])
415 ret['reviewers'].discard(ret['author'])
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000416 ret['bug'] = self.extract_bug_number_from_description(issue)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000417 return ret
418
419 @staticmethod
deymo@chromium.org6c039202013-09-12 12:28:12 +0000420 def process_gerrit_ssh_issue_replies(replies):
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000421 ret = []
422 replies = filter(lambda r: 'email' in r['reviewer'], replies)
423 for reply in replies:
deymo@chromium.org6c039202013-09-12 12:28:12 +0000424 ret.append({
425 'author': reply['reviewer']['email'],
426 'created': datetime.fromtimestamp(reply['timestamp']),
427 'content': '',
428 })
429 return ret
430
431 def process_gerrit_rest_issue(self, instance, issue):
432 ret = {}
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000433 if self.options.deltas:
434 ret['delta'] = DefaultFormatter().format(
435 '+{insertions},-{deletions}',
436 **issue)
437 ret['status'] = issue['status']
deymo@chromium.org6c039202013-09-12 12:28:12 +0000438 if 'shorturl' in instance:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700439 protocol = instance.get('short_url_protocol', 'http')
440 url = instance['shorturl']
441 else:
442 protocol = 'https'
443 url = instance['url']
444 ret['review_url'] = '%s://%s/%s' % (protocol, url, issue['_number'])
445
deymo@chromium.org6c039202013-09-12 12:28:12 +0000446 ret['header'] = issue['subject']
447 ret['owner'] = issue['owner']['email']
448 ret['author'] = ret['owner']
449 ret['created'] = datetime_from_gerrit(issue['created'])
450 ret['modified'] = datetime_from_gerrit(issue['updated'])
451 if 'messages' in issue:
452 ret['replies'] = self.process_gerrit_rest_issue_replies(issue['messages'])
453 else:
454 ret['replies'] = []
455 ret['reviewers'] = set(r['author'] for r in ret['replies'])
456 ret['reviewers'].discard(ret['author'])
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000457 ret['bug'] = self.extract_bug_number_from_description(issue)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000458 return ret
459
460 @staticmethod
461 def process_gerrit_rest_issue_replies(replies):
462 ret = []
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000463 replies = filter(lambda r: 'author' in r and 'email' in r['author'],
464 replies)
deymo@chromium.org6c039202013-09-12 12:28:12 +0000465 for reply in replies:
466 ret.append({
467 'author': reply['author']['email'],
468 'created': datetime_from_gerrit(reply['date']),
469 'content': reply['message'],
470 })
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000471 return ret
472
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000473 def project_hosting_issue_search(self, instance):
474 auth_config = auth.extract_auth_config_from_options(self.options)
475 authenticator = auth.get_authenticator_for_host(
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000476 'bugs.chromium.org', auth_config)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000477 http = authenticator.authorize(httplib2.Http())
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000478 url = ('https://monorail-prod.appspot.com/_ah/api/monorail/v1/projects'
479 '/%s/issues') % instance['name']
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000480 epoch = datetime.utcfromtimestamp(0)
481 user_str = '%s@chromium.org' % self.user
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000482
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000483 query_data = urllib.urlencode({
484 'maxResults': 10000,
485 'q': user_str,
486 'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
487 'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000488 })
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000489 url = url + '?' + query_data
490 _, body = http.request(url)
491 content = json.loads(body)
492 if not content:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000493 logging.error('Unable to parse %s response from projecthosting.',
494 instance['name'])
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000495 return []
496
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000497 issues = []
498 if 'items' in content:
499 items = content['items']
500 for item in items:
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000501 if instance.get('shorturl'):
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700502 protocol = instance.get('short_url_protocol', 'http')
503 item_url = '%s://%s/%d' % (protocol, instance['shorturl'], item['id'])
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000504 else:
505 item_url = 'https://bugs.chromium.org/p/%s/issues/detail?id=%d' % (
506 instance['name'], item['id'])
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000507 issue = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000508 'header': item['title'],
509 'created': dateutil.parser.parse(item['published']),
510 'modified': dateutil.parser.parse(item['updated']),
511 'author': item['author']['name'],
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000512 'url': item_url,
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000513 'comments': [],
514 'status': item['status'],
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000515 'labels': [],
516 'components': []
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000517 }
518 if 'owner' in item:
519 issue['owner'] = item['owner']['name']
520 else:
521 issue['owner'] = 'None'
522 if issue['owner'] == user_str or issue['author'] == user_str:
523 issues.append(issue)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000524 if 'labels' in item:
525 issue['labels'] = item['labels']
526 if 'components' in item:
527 issue['components'] = item['components']
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000528
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000529 return issues
530
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000531 def print_heading(self, heading):
532 print
533 print self.options.output_format_heading.format(heading=heading)
534
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000535 def match(self, author):
536 if '@' in self.user:
537 return author == self.user
538 return author.startswith(self.user + '@')
539
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000540 def print_change(self, change):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000541 activity = len([
542 reply
543 for reply in change['replies']
544 if self.match(reply['author'])
545 ])
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000546 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000547 'created': change['created'].date().isoformat(),
548 'modified': change['modified'].date().isoformat(),
549 'reviewers': ', '.join(change['reviewers']),
550 'status': change['status'],
551 'activity': activity,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000552 }
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000553 if self.options.deltas:
554 optional_values['delta'] = change['delta']
555
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000556 self.print_generic(self.options.output_format,
557 self.options.output_format_changes,
558 change['header'],
559 change['review_url'],
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000560 change['author'],
561 optional_values)
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000562
563 def print_issue(self, issue):
564 optional_values = {
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000565 'created': issue['created'].date().isoformat(),
566 'modified': issue['modified'].date().isoformat(),
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000567 'owner': issue['owner'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000568 'status': issue['status'],
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000569 }
570 self.print_generic(self.options.output_format,
571 self.options.output_format_issues,
572 issue['header'],
573 issue['url'],
574 issue['author'],
575 optional_values)
576
577 def print_review(self, review):
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000578 activity = len([
579 reply
580 for reply in review['replies']
581 if self.match(reply['author'])
582 ])
583 optional_values = {
584 'created': review['created'].date().isoformat(),
585 'modified': review['modified'].date().isoformat(),
586 'activity': activity,
587 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000588 self.print_generic(self.options.output_format,
589 self.options.output_format_reviews,
590 review['header'],
591 review['review_url'],
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000592 review['author'],
593 optional_values)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000594
595 @staticmethod
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000596 def print_generic(default_fmt, specific_fmt,
597 title, url, author,
598 optional_values=None):
599 output_format = specific_fmt if specific_fmt is not None else default_fmt
600 output_format = unicode(output_format)
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000601 values = {
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000602 'title': title,
603 'url': url,
604 'author': author,
605 }
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000606 if optional_values is not None:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000607 values.update(optional_values)
608 print DefaultFormatter().format(output_format, **values).encode(
609 sys.getdefaultencoding())
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000610
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000611
612 def filter_issue(self, issue, should_filter_by_user=True):
613 def maybe_filter_username(email):
614 return not should_filter_by_user or username(email) == self.user
615 if (maybe_filter_username(issue['author']) and
616 self.filter_modified(issue['created'])):
617 return True
618 if (maybe_filter_username(issue['owner']) and
619 (self.filter_modified(issue['created']) or
620 self.filter_modified(issue['modified']))):
621 return True
622 for reply in issue['replies']:
623 if self.filter_modified(reply['created']):
624 if not should_filter_by_user:
625 break
626 if (username(reply['author']) == self.user
627 or (self.user + '@') in reply['content']):
628 break
629 else:
630 return False
631 return True
632
633 def filter_modified(self, modified):
634 return self.modified_after < modified and modified < self.modified_before
635
636 def auth_for_changes(self):
637 #TODO(cjhopman): Move authentication check for getting changes here.
638 pass
639
640 def auth_for_reviews(self):
641 # Reviews use all the same instances as changes so no authentication is
642 # required.
643 pass
644
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000645 def get_changes(self):
646 for instance in rietveld_instances:
647 self.changes += self.rietveld_search(instance, owner=self.user)
648
649 for instance in gerrit_instances:
650 self.changes += self.gerrit_search(instance, owner=self.user)
651
652 def print_changes(self):
653 if self.changes:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000654 self.print_heading('Changes')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000655 for change in self.changes:
656 self.print_change(change)
657
658 def get_reviews(self):
659 for instance in rietveld_instances:
660 self.reviews += self.rietveld_search(instance, reviewer=self.user)
661
662 for instance in gerrit_instances:
663 reviews = self.gerrit_search(instance, reviewer=self.user)
664 reviews = filter(lambda r: not username(r['owner']) == self.user, reviews)
665 self.reviews += reviews
666
667 def print_reviews(self):
668 if self.reviews:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000669 self.print_heading('Reviews')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000670 for review in self.reviews:
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000671 self.print_review(review)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000672
673 def get_issues(self):
674 for project in google_code_projects:
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000675 self.issues += self.project_hosting_issue_search(project)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000676
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000677 def print_issues(self):
678 if self.issues:
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000679 self.print_heading('Issues')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000680 for issue in self.issues:
681 self.print_issue(issue)
682
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000683 def print_activity(self):
684 self.print_changes()
685 self.print_reviews()
686 self.print_issues()
687
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000688 def dump_json(self, ignore_keys=None):
689 if ignore_keys is None:
690 ignore_keys = ['replies']
691
692 def format_for_json_dump(in_array):
693 output = {}
694 for item in in_array:
695 url = item.get('url') or item.get('review_url')
696 if not url:
697 raise Exception('Dumped item %s does not specify url' % item)
698 output[url] = dict(
699 (k, v) for k,v in item.iteritems() if k not in ignore_keys)
700 return output
701
702 class PythonObjectEncoder(json.JSONEncoder):
703 def default(self, obj): # pylint: disable=method-hidden
704 if isinstance(obj, datetime):
705 return obj.isoformat()
706 if isinstance(obj, set):
707 return list(obj)
708 return json.JSONEncoder.default(self, obj)
709
710 output = {
711 'reviews': format_for_json_dump(self.reviews),
712 'changes': format_for_json_dump(self.changes),
713 'issues': format_for_json_dump(self.issues)
714 }
715 print json.dumps(output, indent=2, cls=PythonObjectEncoder)
716
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000717
718def main():
719 # Silence upload.py.
720 rietveld.upload.verbosity = 0
721
722 parser = optparse.OptionParser(description=sys.modules[__name__].__doc__)
723 parser.add_option(
724 '-u', '--user', metavar='<email>',
725 default=os.environ.get('USER'),
726 help='Filter on user, default=%default')
727 parser.add_option(
728 '-b', '--begin', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000729 help='Filter issues created after the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000730 parser.add_option(
731 '-e', '--end', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000732 help='Filter issues created before the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000733 quarter_begin, quarter_end = get_quarter_of(datetime.today() -
734 relativedelta(months=2))
735 parser.add_option(
736 '-Q', '--last_quarter', action='store_true',
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000737 help='Use last quarter\'s dates, i.e. %s to %s' % (
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000738 quarter_begin.strftime('%Y-%m-%d'), quarter_end.strftime('%Y-%m-%d')))
739 parser.add_option(
740 '-Y', '--this_year', action='store_true',
741 help='Use this year\'s dates')
742 parser.add_option(
743 '-w', '--week_of', metavar='<date>',
wychen@chromium.org85cab632015-05-28 21:04:37 +0000744 help='Show issues for week of the date (mm/dd/yy)')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000745 parser.add_option(
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000746 '-W', '--last_week', action='count',
747 help='Show last week\'s issues. Use more times for more weeks.')
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000748 parser.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000749 '-a', '--auth',
750 action='store_true',
751 help='Ask to authenticate for instances with no auth cookie')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000752 parser.add_option(
753 '-d', '--deltas',
754 action='store_true',
755 help='Fetch deltas for changes (slow).')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000756
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000757 activity_types_group = optparse.OptionGroup(parser, 'Activity Types',
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000758 'By default, all activity will be looked up and '
759 'printed. If any of these are specified, only '
760 'those specified will be searched.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000761 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000762 '-c', '--changes',
763 action='store_true',
764 help='Show changes.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000765 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000766 '-i', '--issues',
767 action='store_true',
768 help='Show issues.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000769 activity_types_group.add_option(
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000770 '-r', '--reviews',
771 action='store_true',
772 help='Show reviews.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000773 parser.add_option_group(activity_types_group)
774
775 output_format_group = optparse.OptionGroup(parser, 'Output Format',
776 'By default, all activity will be printed in the '
777 'following format: {url} {title}. This can be '
778 'changed for either all activity types or '
779 'individually for each activity type. The format '
780 'is defined as documented for '
781 'string.format(...). The variables available for '
782 'all activity types are url, title and author. '
783 'Format options for specific activity types will '
784 'override the generic format.')
785 output_format_group.add_option(
786 '-f', '--output-format', metavar='<format>',
787 default=u'{url} {title}',
788 help='Specifies the format to use when printing all your activity.')
789 output_format_group.add_option(
790 '--output-format-changes', metavar='<format>',
791 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000792 help='Specifies the format to use when printing changes. Supports the '
793 'additional variable {reviewers}')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000794 output_format_group.add_option(
795 '--output-format-issues', metavar='<format>',
796 default=None,
cjhopman@chromium.org53c1e562013-03-11 20:02:38 +0000797 help='Specifies the format to use when printing issues. Supports the '
798 'additional variable {owner}.')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000799 output_format_group.add_option(
800 '--output-format-reviews', metavar='<format>',
801 default=None,
802 help='Specifies the format to use when printing reviews.')
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000803 output_format_group.add_option(
804 '--output-format-heading', metavar='<format>',
805 default=u'{heading}:',
806 help='Specifies the format to use when printing headings.')
807 output_format_group.add_option(
808 '-m', '--markdown', action='store_true',
809 help='Use markdown-friendly output (overrides --output-format '
810 'and --output-format-heading)')
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000811 output_format_group.add_option(
812 '-j', '--json', action='store_true',
813 help='Output json data (overrides other format options)')
nyquist@chromium.org18bc90d2012-12-20 19:26:47 +0000814 parser.add_option_group(output_format_group)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000815 auth.add_auth_options(parser)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000816
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000817 parser.add_option(
818 '-v', '--verbose',
819 action='store_const',
820 dest='verbosity',
821 default=logging.WARN,
822 const=logging.INFO,
823 help='Output extra informational messages.'
824 )
825 parser.add_option(
826 '-q', '--quiet',
827 action='store_const',
828 dest='verbosity',
829 const=logging.ERROR,
830 help='Suppress non-error messages.'
831 )
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000832 parser.add_option(
833 '-o', '--output', metavar='<file>',
834 help='Where to output the results. By default prints to stdout.')
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000835
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000836 # Remove description formatting
837 parser.format_description = (
Quinten Yearsleyb2cc4a92016-12-15 13:53:26 -0800838 lambda _: parser.description) # pylint: disable=no-member
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000839
840 options, args = parser.parse_args()
841 options.local_user = os.environ.get('USER')
842 if args:
843 parser.error('Args unsupported')
844 if not options.user:
845 parser.error('USER is not set, please use -u')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000846 options.user = username(options.user)
847
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000848 logging.basicConfig(level=options.verbosity)
849
850 # python-keyring provides easy access to the system keyring.
851 try:
852 import keyring # pylint: disable=unused-import,unused-variable,F0401
853 except ImportError:
854 logging.warning('Consider installing python-keyring')
855
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000856 if not options.begin:
857 if options.last_quarter:
858 begin, end = quarter_begin, quarter_end
859 elif options.this_year:
860 begin, end = get_year_of(datetime.today())
861 elif options.week_of:
862 begin, end = (get_week_of(datetime.strptime(options.week_of, '%m/%d/%y')))
jsbell@chromium.org74bfde02014-04-09 17:14:54 +0000863 elif options.last_week:
wychen@chromium.org8ba1ddb2015-04-29 00:04:25 +0000864 begin, end = (get_week_of(datetime.today() -
865 timedelta(days=1 + 7 * options.last_week)))
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000866 else:
867 begin, end = (get_week_of(datetime.today() - timedelta(days=1)))
868 else:
869 begin = datetime.strptime(options.begin, '%m/%d/%y')
870 if options.end:
871 end = datetime.strptime(options.end, '%m/%d/%y')
872 else:
873 end = datetime.today()
874 options.begin, options.end = begin, end
875
jsbell@chromium.orgc92f5822014-01-06 23:49:11 +0000876 if options.markdown:
877 options.output_format = ' * [{title}]({url})'
878 options.output_format_heading = '### {heading} ###'
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000879 logging.info('Searching for activity by %s', options.user)
880 logging.info('Using range %s to %s', options.begin, options.end)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000881
882 my_activity = MyActivity(options)
883
884 if not (options.changes or options.reviews or options.issues):
885 options.changes = True
886 options.issues = True
887 options.reviews = True
888
889 # First do any required authentication so none of the user interaction has to
890 # wait for actual work.
891 if options.changes:
892 my_activity.auth_for_changes()
893 if options.reviews:
894 my_activity.auth_for_reviews()
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000895
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000896 logging.info('Looking up activity.....')
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000897
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000898 try:
899 if options.changes:
900 my_activity.get_changes()
901 if options.reviews:
902 my_activity.get_reviews()
903 if options.issues:
904 my_activity.get_issues()
905 except auth.AuthenticationError as e:
Tobias Sargeantffb3c432017-03-08 14:09:14 +0000906 logging.error('auth.AuthenticationError: %s', e)
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000907
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000908 output_file = None
909 try:
910 if options.output:
911 output_file = open(options.output, 'w')
912 logging.info('Printing output to "%s"', options.output)
913 sys.stdout = output_file
914 except (IOError, OSError) as e:
Varun Khanejad9f97bc2017-08-02 12:55:01 -0700915 logging.error('Unable to write output: %s', e)
Nicolas Dossou-gbetee5deedf2017-03-15 16:26:47 +0000916 else:
917 if options.json:
918 my_activity.dump_json()
919 else:
920 my_activity.print_changes()
921 my_activity.print_reviews()
922 my_activity.print_issues()
923 finally:
924 if output_file:
925 logging.info('Done printing to file.')
926 sys.stdout = sys.__stdout__
927 output_file.close()
928
cjhopman@chromium.org04d119d2012-10-17 22:41:53 +0000929 return 0
930
931
932if __name__ == '__main__':
stevefung@chromium.org832d51e2015-05-27 12:52:51 +0000933 # Fix encoding to support non-ascii issue titles.
934 fix_encoding.fix_encoding()
935
sbc@chromium.org013731e2015-02-26 18:28:43 +0000936 try:
937 sys.exit(main())
938 except KeyboardInterrupt:
939 sys.stderr.write('interrupted\n')
940 sys.exit(1)