Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Newer
Older
100755 726 lines (604 sloc) 23.458 kB
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
1 #!/usr/bin/python
2 #
3 # Peteris Krumins (peter@catonmat.net)
4 # http://www.catonmat.net -- good coders code, great reuse
5 #
6 # Released under GNU GPL.
7 #
8 # A Hacker News `top' like program.
9 # http://www.catonmat.net/blog/follow-hacker-news-from-the-console
10 #
11
12 """ Hacker Top: Follow your hacker news from the console """
13
14 import os
15 import re
16 import sys
17 import time
18 import Queue
19 import fcntl
20 import getopt
21 import curses
22 import signal
23 import struct
24 import termios
25 import datetime
26 import threading
27 import webbrowser
28 from htmlentitydefs import entitydefs
29 from pyhackerstories import get_stories, RedesignError, SeriousError, stories_per_page
30
31 version = "1.0"
32
33 # Default refresh interval.
34 # Example values:
35 # 20 - 20 seconds (same as 20s), 1m - 1 minute, 20m - 20 minutes
36 default_interval = '3m'
37
38 # Is the terminal capable of outputting utf8? (this is the default value)
39 # Change with -u|--utf command option
40 default_can_utf8 = False
41
2e4e12f @eschulte add an option to print URLs
eschulte authored
42 # Do we want to show urls
43 default_show_url = False
44
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
45 # Monitor new stories?
46 default_new = False
47
48 # Terminal got resized?
49 RESIZE_EVENT = False
50
51 # Queue used by Retriever to send stories and exceptions
52 retr_queue_out = Queue.Queue()
53
54 # Queue used to notify Retriever about quitting or other events
55 retr_queue_in = Queue.Queue()
56
57 class ProgError(Exception):
58 """ This prog's exception class thrown from curse mode """
59
60 def __init__(self, msg=None, callback=None):
61 """ Sets error message and callback to be called after we quit
62 the curses mode """
63 self.msg = msg
64 self.callback = callback
65 Exception.__init__(self, msg)
66
67 class ArgError(Exception):
68 """ Invalid command line argument exception """
69 pass
70
71 class Retriever(threading.Thread):
72 """ Thread which runs "data retriever" """
73
74 def __init__(self, args):
75 self.running = False
76 self.pages = 1
77 self.old_pages = 1
78 self.new = args['new']
79 self.update_secs = Interval(args['interval']).to_secs()
80 threading.Thread.__init__(self)
81
82 def run(self):
83 self.running = True
84 while self.running:
85 try:
86 retr_queue_out.put(StartGettingData());
87 stories = get_stories(self.pages, self.new)
88 retr_queue_out.put(FinishedGettingData())
89 retr_queue_out.put(Story(stories))
90 if self.old_pages != self.pages:
91 self.old_pages = self.pages
92 retr_queue_out.put(ChangedPages())
93 except RedesignError, e:
94 retr_queue_out.put(FinishedGettingData())
95 retr_queue_out.put(DisplayError("Hacker News might have redesigned:", str(e)))
96 return
97 except SeriousError, e:
98 retr_queue_out.put(FinishedGettingData())
99 retr_queue_out.put(DisplayError("Serious error:", str(e)))
100 return
101
102 try:
103 # see if the main thread notified us to do something
104 action = retr_queue_in.get(True, self.update_secs)
105 action.do(self)
106 except Queue.Empty:
107 pass
108
109 class RetrieverNotification(object):
110 """ Base class for Retriever Notifications """
111 pass
112
113 class DisplayError(RetrieverNotification):
114 """ Display a retriever error """
115 def __init__(self, *msgs):
116 self.msgs = msgs;
117
118 def do(self, interface):
119 interface.body_win.erase()
120 error_msg = "Error!"
121 offset = len(self.msgs)/2
122 interface.body_win.addstr(interface.body_max_y/2-offset-1, interface.body_max_x/2 - len(error_msg)/2,
123 error_msg, curses.color_pair(5))
124 for idx, msg in enumerate(self.msgs):
125 interface.body_win.addstr(interface.body_max_y/2-offset+idx, interface.body_max_x/2 - len(msg)/2,
126 msg, curses.color_pair(3))
127
128 class StartGettingData(RetrieverNotification):
129 """ Notify user that retriever went after data """
130 def do(self, interface):
131 interface.head_win.addstr(1, 4, "Updating...", curses.color_pair(3))
132 interface.head_win.refresh()
133
134 class FinishedGettingData(RetrieverNotification):
135 """ Notify user that retriever finished going after data """
136 def do(self, interface):
137 interface.head_win.addstr(1, 4, " ", curses.color_pair(3))
138 interface.head_win.refresh()
139
140 class Story(RetrieverNotification):
141 """ Got Hacker News stories """
142 def __init__(self, stories):
143 self.stories = stories
144
145 def do(self, interface):
146 interface.stories = self.stories
147 interface.display.display(self.stories)
148
149 class RetrieverQuit(RetrieverNotification):
150 """ Notify retriever to quit """
151 def do(self, retriever):
152 retriever.running = False
153
154 class ForceUpdate(RetrieverNotification):
155 """ Notify retriever to do an update right now """
156 def do(self, retriever):
157 pass
158
159 class ChangePages(RetrieverNotification):
160 """ Notify retriever to retrieve more pages """
161 def __init__(self, pages):
162 self.pages = pages
163
164 def do(self, retriever):
165 retriever.pages = self.pages
166
167 class ChangedPages(RetrieverNotification):
168 """ Notify the interface that retriever changed a page """
169 def do(self, interface):
170 interface.pages_changed = True
171
172 class DisplayMode(object):
173 """ Base class for display modes """
174 def __init__(self, interface):
175 self.interface = interface
176
177 def display(self, stories):
178 self.interface.body_win.erase()
179 self.interface.body_win.move(0, 0)
180
181 if not stories:
182 no_story = "No stories found on Hacker News!"
183 self.interface.body_win.addstr(self.interface.body_max_y/2,
184 self.interface.body_max_x/2 - len(no_story)/2, no_story, curses.color_pair(3))
185 return
186
187 max_display = self.max_display()
188 for idx in range(max_display - 1):
189 try:
190 story = stories[idx + self.interface.start_pos]
191 story.title = html_unescape(story.title)
192 if not interface.can_utf8:
193 story.title = story.title.decode('utf8').encode('ascii', 'replace')
194 self.do_display(story)
195 except IndexError:
196 break
197
198 self.interface.body_win.refresh()
199
200 def max_display(self):
201 """ Returns max number of stories that can be displayed on screen """
202 return self.interface.body_max_y / self.lines_per_story()
203
204 class BasicDisplay(DisplayMode):
205 """ Base class for basic display modes """
206
207 def do_display(self, story):
208 title_line = self.format_title(story)
209 self.display_title(title_line)
2e4e12f @eschulte add an option to print URLs
eschulte authored
210 if interface.show_url:
211 self.display_url(story)
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
212 self.display_info(story)
213
2e4e12f @eschulte add an option to print URLs
eschulte authored
214 def display_url(self, story):
215 url = html_unescape(story.url)
216 if len(url) > (self.interface.body_max_x - 4):
217 url = url[:self.interface.body_max_x-8] + "..."
218 if not interface.can_utf8:
219 url = story.url.decode('utf8').encode('ascii', 'replace')
220 self.interface.body_win.addstr(" ")
221 self.interface.body_win.addstr(url[:4])
222 self.interface.body_win.addstr(url[4:] + "\n")
223
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
224 def display_title(self, title_line):
225 self.interface.body_win.addstr(title_line[:4])
226 self.interface.body_win.addstr(title_line[4:] + "\n", curses.color_pair(1))
227
228 def format_title(self, story):
229 title_line = "%2d. %s" % (story.position, story.title)
230 if len(title_line) > self.interface.body_max_x:
231 title_line = title_line[:self.interface.body_max_x-1]
232 return title_line
233
234 def display_info(self, story):
235 when = nice_date(datetime.datetime.fromtimestamp(story.unix_time), datetime.datetime.now())
236 self.interface.body_win.addstr(" ")
237 self.interface.body_win.addstr("points: ")
238 if story.score == 1:
239 points = "1"
240 elif story.score > 1:
241 points = "%d" % story.score
242 else:
243 points = "-"
244 self.interface.body_win.addstr(points.ljust(5), curses.color_pair(2) | curses.A_BOLD)
245
246 self.interface.body_win.addstr("comments: ")
247 if story.comments == 1:
248 comments = "1"
249 elif story.comments >= 0:
250 comments = "%d" % story.comments
251 else:
252 comments = '-'
253 self.interface.body_win.addstr(comments.ljust(5), curses.color_pair(2) | curses.A_BOLD)
254
255 self.interface.body_win.addstr("posted: ")
256 self.interface.body_win.addstr(when.ljust(17), curses.color_pair(2) | curses.A_BOLD)
257
258 self.interface.body_win.addstr("user: ") # this takes 63 chars on the screen
259 if len(story.user) > 79 - 63:
260 self.interface.body_win.addstr(story.user[:79-63], curses.color_pair(2) | curses.A_BOLD)
261 else:
262 self.interface.body_win.addstr(story.user, curses.color_pair(2) | curses.A_BOLD)
263
264 self.interface.body_win.addstr("\n")
265
266 class SpacedDetailed(BasicDisplay):
267 """
268 Spaced Detailed display mode.
269 Example:
270 --------
271 1. Story title
272 points: 37 comments: 23 posted: 3 hours ago user: username
273
274 --------
275 """
276 def lines_per_story(self):
277 """ Returns number of lines a story takes """
278 return 3
279
280 def display_info(self, story):
281 super(SpacedDetailed, self).display_info(story)
282 self.interface.body_win.addstr("\n")
283
284 class CompressedDetailed(BasicDisplay):
285 """
286 Compressed Detailed display mode.
287 Example:
288 --------
289 1. Story title
290 points: 37 comments: 23 posted: 3 hours ago user: username
291 --------
292 """
293 def lines_per_story(self):
294 return 2
295
296 class Compact(BasicDisplay):
297 """
298 Compact display mode.
299 Example:
300 --------
301 1. Story title
302 --------
303 """
304 def lines_per_story(self):
305 return 1
306
307 def display_info(self, story):
308 pass
309
310 class CompressedFull(BasicDisplay):
311 """
312 Compressed Full display mode.
313 Example:
314 --------
315 1. Story title
316 http://www.example.com
317 points: 37 comments: 23 posted: 3 hours ago user: username
318 --------
319 """
320 def lines_per_story(self):
321 return 3
322
323 def display_info(self, story):
324 if len(story.url) > self.interface.body_max_x - 4:
325 story.url = story.url[:self.interface.body_max_x-4-1]
326 self.interface.body_win.addstr(" ")
327 self.interface.body_win.addstr(story.url + "\n")
328 super(CompressedFull, self).display_info(story)
329
330 class Full(CompressedFull):
331 """
332 Full display mode.
333 Example:
334 --------
335 1. Story title
336 http://www.example.com
337 points: 37 comments: 23 posted: 3 hours ago user: username
338
339 --------
340 """
341 def lines_per_story(self):
342 return 4
343
344 def display_info(self, story):
345 super(Full, self).display_info(story)
346 self.interface.body_win.addstr("\n")
347
348
349 class Interface(object):
350 """ ncurses interface of the program """
351
352 display_modes = [CompressedFull, Full, SpacedDetailed, CompressedDetailed, Compact]
353
354 def __init__(self, args):
355 self.update_secs = Interval(args['interval']).to_secs()
356 self.can_utf8 = args['utf8']
2e4e12f @eschulte add an option to print URLs
eschulte authored
357 self.show_url = args['url']
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
358 self.new = args['new']
359 self.pages_changed = True
360 self.pages = 1
361 self.start_pos = 0
362 self.stories = []
363 self.display_mode = 2
364 self.display = self.display_modes[self.display_mode](self)
365
366 def init_and_run(self, stdscr):
367 """ called by ncurses.wrapper """
368 self.stdscr = stdscr
369
370 try:
371 curses.curs_set(0)
372 except:
373 pass
374
375 try:
376 curses.use_default_colors()
377 bg = -1
378 except:
379 bg = curses.COLOR_BLACK
380
381 curses.init_pair(1, curses.COLOR_CYAN, bg)
382 curses.init_pair(2, curses.COLOR_WHITE, bg)
383 curses.init_pair(3, curses.COLOR_GREEN, bg)
384 curses.init_pair(4, curses.COLOR_YELLOW, bg)
385 curses.init_pair(5, curses.COLOR_RED, bg)
386
387 self.max_y, self.max_x = stdscr.getmaxyx()
388
389 self.head_win = curses.newwin(4, self.max_x, 0, 0)
390 self.body_win = curses.newwin(self.max_y-4, self.max_x, 4, 0)
391
392 self.init_head_win()
393 self.init_body_win()
394 curses.doupdate()
395
396 self.run()
397
398 def init_head_win(self):
399 """ Initializes the head/information window """
400 info = "Hacker Top v" + version
401 self.head_win.addstr(0, 4, info, curses.color_pair(4))
402
403 x = self.head_win.getyx()[1]
404 x += 4
405 self.head_win.addstr(" Monitoring: ")
406
407 if self.new:
408 self.head_win.addstr("Hacker News newest stories", curses.A_BOLD)
409 else:
410 self.head_win.addstr("Hacker News front page", curses.A_BOLD)
411
412 self.head_win.addstr(1, x, "Interval: ")
413 if self.update_secs == 1:
414 self.head_win.addstr("1 second", curses.A_BOLD)
415 else:
416 self.head_win.addstr("%d seconds" % self.update_secs, curses.A_BOLD)
417
418 self.head_win.addstr(2, x, "Keys: 'j'/'k' - scroll, 'u' - update, 'm' - display mode")
419
420 self.head_win.noutrefresh()
421
422 def init_body_win(self):
423 """ Initializes the body/story window """
424 self.body_win.timeout(100)
425 self.body_win.keypad(1)
426 self.body_max_y, self.body_max_x = self.body_win.getmaxyx()
427 wait_msg = "Retrieving data from Hacker News."
428 self.body_win.addstr(self.body_max_y/2, self.body_max_x/2 - len(wait_msg)/2, wait_msg, curses.color_pair(3))
429
430 self.body_win.noutrefresh()
431
432 def resize(self):
433 h, w = gethw()
434 if not h:
435 return
436
437 curses.endwin()
438 os.environ["LINES"] = str(h)
439 os.environ["COLUMNS"] = str(w)
440 curses.doupdate()
441
442 self.body_max_y, self.body_max_x = self.body_win.getmaxyx()
443 self.display.display(self.stories)
444
445 def run(self):
446 global RESIZE_EVENT
447 while True:
448 try:
449 if RESIZE_EVENT:
450 RESIZE_EVENT = False
451 self.resize()
452 c = self.body_win.getch() # getch() has a 100ms timeout
453 ret = self.handle_keystroke(c)
454 if (ret == -1): return
455 if retr_queue_out.empty():
456 continue
457 action = retr_queue_out.get()
458 action.do(self)
459 except KeyboardInterrupt:
460 break
461 except curses.error, e:
462 raise ProgError, "Curses Error: %s" % e
463
464 def handle_keystroke(self, char):
465 if char == ord('q'):
466 # Notify Retriever to quit
467 retr_queue_in.put(RetrieverQuit())
468 return -1
469 elif char == ord('u'):
470 # Update stories NOW
471 retr_queue_in.put(ForceUpdate())
472 return
473 elif char == curses.KEY_DOWN or char== ord('j'):
474 # Scroll stories down by one
475 if len(self.stories) - self.start_pos > self.display.max_display() / 2:
476 self.start_pos += 1
477 if self.stories:
478 self.display.display(self.stories)
479 if len(self.stories) - self.start_pos < self.display.max_display() and self.pages_changed:
480 self.pages += 1
481 self.pages_changed = False
482 retr_queue_in.put(ChangePages(self.pages))
483 return
484 elif char == curses.KEY_UP or char == ord('k'):
485 # Scroll stories up by one
486 if self.start_pos > 0:
487 self.start_pos -= 1
488 if self.stories:
489 self.display.display(self.stories)
490 if len(self.stories) - self.start_pos - self.display.max_display() > stories_per_page() and self.pages_changed:
491 self.pages -= 1
492 self.pages_changed = False
493 retr_queue_in.put(ChangePages(self.pages))
494 return
495 elif char == ord('m'):
496 # Change display mode
497 self.display_mode += 1
498 self.display_mode %= len(self.display_modes)
499 self.display = self.display_modes[self.display_mode](self)
500 if self.stories:
501 self.display.display(self.stories)
502 return
503 elif char == ord('o'):
504 # Open topmost story in webbrrowser (new window)
505 webbrowser.open_new(self.stories[self.start_pos].url)
506 return
507 elif char == ord('t'):
508 # Open topmost story in webbrowser (new tab)
509 webbrowser.open_new_tab(self.stories[self.start_pos].url)
510 return
511 elif char == ord('c'):
512 # Open topmost story's comments in webbrowser (new tab)
513 webbrowser.open_new_tab(self.stories[self.start_pos].comments_url)
514 return
515
516 class Interval(object):
517 """ A class to dealing with refresh intervals """
518
519 class IntervalError(Exception):
520 """ Invalid interval error """
521 pass
522
523 interval_re = re.compile(r'^(\d+)(h|m|s)?$')
524
525 def __init__(self, _interval):
526 self._interval = _interval
527 if not self.interval_ok():
528 raise Interval.IntervalError, "Invalid interval format (%s)" % interval
529
530 def interval_ok(self):
531 if Interval.interval_re.match(self._interval):
532 return True
533 return False
534
535 def to_secs(self):
536 m = Interval.interval_re.match(self._interval)
537 num, unit = m.groups()
538 if not unit:
539 unit = 's'
540
541 num = int(num)
542
543 if unit == 's':
544 return num
545 elif unit == 'm':
546 return num*60
547 elif unit == 'h':
548 return num*60*60
549
550 interval = property(lambda self: self._interval)
551
552 def gethw():
553 """
554 Get height and width of the terminal. Thanks to bobf from #python @ Freenode
555 """
556
557 h, w = struct.unpack(
558 "hhhh", fcntl.ioctl(sys.__stdout__, termios.TIOCGWINSZ, "\000"*8))[0:2]
559 return h, w
560
561 def html_unescape(str):
562 """ Unescapes HTML entities """
563 def entity_replacer(m):
564 entity = m.group(1)
565 if entity in entitydefs:
566 return entitydefs[entity]
567 else:
568 return m.group(0)
569
570 return re.sub(r'&([^;]+);', entity_replacer, str)
571
572 def nice_date(then, now=None):
573 """
574 Converts a (UTC) datetime object to a nice string representation.
575
576 Taken from web.py
577 """
578 def agohence(n, what, divisor=None):
579 if divisor: n = n // divisor
580
581 out = str(abs(n)) + ' ' + what # '2 day'
582 if abs(n) != 1: out += 's' # '2 days'
583 out += ' ' # '2 days '
584 if n < 0:
585 out += 'from now'
586 else:
587 out += 'ago'
588 return out # '2 days ago'
589
590 oneday = 24 * 60 * 60
591
592 if not now: now = datetime.datetime.utcnow()
593 if type(now).__name__ == "DateTime":
594 now = datetime.datetime.fromtimestamp(now)
595 if type(then).__name__ == "DateTime":
596 then = datetime.datetime.fromtimestamp(then)
597 delta = now - then
598 deltaseconds = int(delta.days * oneday + delta.seconds + delta.microseconds * 1e-06)
599 deltadays = abs(deltaseconds) // oneday
600 if deltaseconds < 0: deltadays *= -1 # fix for oddity of floor
601
602 if deltadays:
603 if abs(deltadays) < 4:
604 return agohence(deltadays, 'day')
605
606 out = then.strftime('%B %e') # e.g. 'June 13'
607 if then.year != now.year or deltadays < 0:
608 out += ', %s' % then.year
609 return out
610
611 if int(deltaseconds):
612 if abs(deltaseconds) > (60 * 60):
613 return agohence(deltaseconds, 'hour', 60 * 60)
614 elif abs(deltaseconds) > 60:
615 return agohence(deltaseconds, 'minute', 60)
616 else:
617 return agohence(deltaseconds, 'second')
618
619 deltamicroseconds = delta.microseconds
620 if delta.days: deltamicroseconds = int(delta.microseconds - 1e6) # datetime oddity
621 if abs(deltamicroseconds) > 1000:
622 return agohence(deltamicroseconds, 'millisecond', 1000)
623
624 return agohence(deltamicroseconds, 'microsecond')
625
626 def parse_args(args):
627 """ Parse args given to program. Change appropriate variables.
628 ps. i don't like optparse. """
629
630 try:
2e4e12f @eschulte add an option to print URLs
eschulte authored
631 opts = getopt.getopt(args, "i:uUnh", ['interval=', 'utf8', 'url', 'new', 'help'])[0]
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
632 except getopt.GetoptError, e:
633 raise ArgError, str(e)
634
635 return_args = {
636 'interval': default_interval,
637 'utf8': default_can_utf8,
2e4e12f @eschulte add an option to print URLs
eschulte authored
638 'url': default_show_url,
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
639 'new': default_new
640 }
641
642 for opt, val in opts:
643 if opt in ("-h", "--help"):
644 print_help()
645 sys.exit(1)
646 elif opt in ("-i", "--interval"):
647 try:
648 return_args['interval'] = Interval(val).interval
649 except Interval.IntervalError, e:
650 raise ArgError, e
651 elif opt in ("-u", "--utf8"):
aea0ffa @eschulte fix typo
eschulte authored
652 return_args['utf8'] = True
2e4e12f @eschulte add an option to print URLs
eschulte authored
653 elif opt in ("-U", "--url"):
654 return_args['url'] = True
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
655 elif opt in ("-n", "--new"):
656 return_args['new'] = True
657 else:
658 raise ArgError, "Don't know how to handle argument %s" % opt
659
660 return return_args
661
662 def print_help():
663 print_head()
664 print
665 print_usage()
666
667 def print_head():
668 print "Hacker Top - follow your hacker news from the console!"
669 print
670 print "Made by Peteris Krumins (peter@catonmat.net)"
671 print "http://www.catonmat.net -- good coders code, great reuse"
672
673 def print_usage():
674 print "Usage: %s [-h|--help] - displays this" % sys.argv[0]
675 print "Usage: %s [-i|--interval interval] [-u|--utf8 <on|off>]" % sys.argv[0]
2e4e12f @eschulte add an option to print URLs
eschulte authored
676 print " [-n|--new] [-u|--utf8 <on|off>] [-U|--url <on|off>]"
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
677 print
678 print "-i|--interval specifies refresh interval."
679 print " Valid examples: 10s (10 seconds), 12m (12 minutes), 42h (42 hours)."
680 print " Default: %s" % default_interval
681 print "-u|--utf8 turns on utf8 output mode. Use this if you know for sure that"
682 print " your terminal supports it. Default: %s" % str(default_can_utf8)
2e4e12f @eschulte add an option to print URLs
eschulte authored
683 print "-U|--url print url. Default: %s" % str(default_show_url)
a79f4c3 @Nic0 name change of main script, setup.py updated
Nic0 authored
684 print "-n|--new specifies that new stories only should be monitored"
685 print " Default: %s" % default_new
686
687 def sigwinch_handler(*dummy):
688 global RESIZE_EVENT
689 RESIZE_EVENT = True
690
691 def sigint_handler(*dummy):
692 pass
693
694 if __name__ == "__main__":
695 try:
696 args = parse_args(sys.argv[1:])
697 except ArgError, e:
698 print "Argument Error: %s!" % e
699 print
700 print_usage()
701 sys.exit(1)
702
703 retriever = Retriever(args)
704 retriever.start()
705
706 exit_code = 0
707
708 signal.signal(signal.SIGWINCH, sigwinch_handler)
709
710 try:
711 interface = Interface(args)
712 curses.wrapper(interface.init_and_run)
713 except ProgError, e:
714 exit_code = 1
715 print "Program Error: %s!" % e
716 if e.callback:
717 e.callback()
718
719 signal.signal(signal.SIGINT, sigint_handler)
720 retr_queue_in.put(RetrieverQuit()) # notify thread to quit
721 print "Quitting in a few seconds (waiting for thread to finish)..."
722 sys.stdout.flush()
723 retriever.join()
724 sys.exit(exit_code)
725
Something went wrong with that request. Please try again.