]> jfr.im git - z_archive/twitter.git/blame - twitter/cmdline.py
adding list action
[z_archive/twitter.git] / twitter / cmdline.py
CommitLineData
3756d82e
AL
1#!/usr/bin/env python
2# encoding: utf-8
7364ea65 3"""
5251ea48 4USAGE:
7364ea65 5
5251ea48 6 twitter [action] [options]
7
086fc282 8
5251ea48 9ACTIONS:
086fc282 10 authorize authorize the command-line tool to interact with Twitter
1c11e6d7 11 follow add the specified user to your follow list
5251ea48 12 friends get latest tweets from your friends (default action)
45688301 13 help print this help text that you are currently reading
efa0ba89 14 leave remove the specified user from your following list
5251ea48 15 public get latest public tweets
69851f4c 16 list get list of user lists
9a9f7ae7 17 replies get latest replies
6af9fa5f 18 search search twitter (Beware: octothorpe, escape it)
5251ea48 19 set set your twitter status
05b85831 20 shell login the twitter shell
5251ea48 21
086fc282 22
5251ea48 23OPTIONS:
24
0ea01db7 25 -r --refresh run this command forever, polling every once
26 in a while (default: every 5 minutes)
27 -R --refresh-rate <rate> set the refresh rate (in seconds)
28 -f --format <format> specify the output format for status updates
21e3bd23 29 -c --config <filename> read username and password from given config
39a6f562
MV
30 file (default ~/.twitter)
31 -l --length <count> specify number of status updates shown
32 (default: 20, max: 200)
33 -t --timestamp show time before status lines
34 -d --datestamp shoe date before status lines
9a148ed1 35 --no-ssl use HTTP instead of more secure HTTPS
7f1dd286 36 --oauth <filename> filename to read/store oauth credentials to
086fc282 37
0ea01db7 38FORMATS for the --format option
39
40 default one line per status
41 verbose multiple lines per status, more verbose status info
327e556b
MV
42 urls nothing but URLs
43 ansi ansi colour (rainbow mode)
05b85831 44
086fc282 45
21e3bd23 46CONFIG FILES
47
086fc282
MV
48 The config file should be placed in your home directory and be named .twitter.
49 It must contain a [twitter] header, and all the desired options you wish to
50 set, like so:
21e3bd23 51
52[twitter]
327e556b 53format: <desired_default_format_for_output>
05b85831 54prompt: <twitter_shell_prompt e.g. '[cyan]twitter[R]> '>
086fc282
MV
55
56 OAuth authentication tokens are stored in the file .twitter_oauth in your
57 home directory.
7364ea65 58"""
59
6c527e72
MV
60CONSUMER_KEY='uS6hO2sV6tDKIOeVjhnFnQ'
61CONSUMER_SECRET='MEYTOS97VvlHX7K1rwHPEqVpTSqZ71HtvoK4sVuYk'
62
5251ea48 63import sys
0ea01db7 64import time
f2a7ce46 65from getopt import gnu_getopt as getopt, GetoptError
f068ff42 66from getpass import getpass
0ea01db7 67import re
21e3bd23 68import os.path
69from ConfigParser import SafeConfigParser
a4b5e65b 70import datetime
fd2bc885 71from urllib import quote
6c527e72 72import webbrowser
5251ea48 73
74from api import Twitter, TwitterError
1b31d642
MV
75from oauth import OAuth, write_token_file, read_token_file
76from oauth_dance import oauth_dance
0b9960a3 77import ansi
5251ea48 78
327e556b 79OPTIONS = {
5251ea48 80 'action': 'friends',
0ea01db7 81 'refresh': False,
82 'refresh_rate': 600,
83 'format': 'default',
05b85831 84 'prompt': '[cyan]twitter[R]> ',
21e3bd23 85 'config_filename': os.environ.get('HOME', '') + os.sep + '.twitter',
6c527e72 86 'oauth_filename': os.environ.get('HOME', '') + os.sep + '.twitter_oauth',
39a6f562
MV
87 'length': 20,
88 'timestamp': False,
89 'datestamp': False,
9a148ed1 90 'extra_args': [],
6c527e72 91 'secure': True,
5251ea48 92}
93
94def parse_args(args, options):
7f1dd286 95 long_opts = ['help', 'format=', 'refresh', 'oauth=',
8ddd8500 96 'refresh-rate=', 'config=', 'length=', 'timestamp',
9a148ed1 97 'datestamp', 'no-ssl']
39a6f562 98 short_opts = "e:p:f:h?rR:c:l:td"
8ddd8500 99 opts, extra_args = getopt(args, short_opts, long_opts)
efa0ba89 100
5251ea48 101 for opt, arg in opts:
086fc282 102 if opt in ('-f', '--format'):
0ea01db7 103 options['format'] = arg
104 elif opt in ('-r', '--refresh'):
105 options['refresh'] = True
106 elif opt in ('-R', '--refresh-rate'):
107 options['refresh_rate'] = int(arg)
39a6f562
MV
108 elif opt in ('-l', '--length'):
109 options["length"] = int(arg)
110 elif opt in ('-t', '--timestamp'):
111 options["timestamp"] = True
112 elif opt in ('-d', '--datestamp'):
113 options["datestamp"] = True
5251ea48 114 elif opt in ('-?', '-h', '--help'):
05b85831 115 options['action'] = 'help'
21e3bd23 116 elif opt in ('-c', '--config'):
117 options['config_filename'] = arg
9a148ed1
MV
118 elif opt == '--no-ssl':
119 options['secure'] = False
7f1dd286
MV
120 elif opt == '--oauth':
121 options['oauth_filename'] = arg
efa0ba89 122
05b85831 123 if extra_args and not ('action' in options and options['action'] == 'help'):
ae1d86aa 124 options['action'] = extra_args[0]
125 options['extra_args'] = extra_args[1:]
a8b5ad3e 126
87be041f 127def get_time_string(status, options, format="%a %b %d %H:%M:%S +0000 %Y"):
39a6f562
MV
128 timestamp = options["timestamp"]
129 datestamp = options["datestamp"]
87be041f 130 t = time.strptime(status['created_at'], format)
a4b5e65b
MV
131 i_hate_timezones = time.timezone
132 if (time.daylight):
133 i_hate_timezones = time.altzone
134 dt = datetime.datetime(*t[:-3]) - datetime.timedelta(
135 seconds=i_hate_timezones)
136 t = dt.timetuple()
39a6f562
MV
137 if timestamp and datestamp:
138 return time.strftime("%Y-%m-%d %H:%M:%S ", t)
139 elif timestamp:
140 return time.strftime("%H:%M:%S ", t)
141 elif datestamp:
142 return time.strftime("%Y-%m-%d ", t)
8ddd8500 143 return ""
5251ea48 144
145class StatusFormatter(object):
a55c0ac8
MV
146 def __call__(self, status, options):
147 return (u"%s%s %s" %(
39a6f562 148 get_time_string(status, options),
0ea01db7 149 status['user']['screen_name'], status['text']))
5251ea48 150
0b9960a3
MV
151class AnsiStatusFormatter(object):
152 def __init__(self):
153 self._colourMap = ansi.ColourMap()
a8b5ad3e 154
39a6f562 155 def __call__(self, status, options):
0b9960a3 156 colour = self._colourMap.colourFor(status['user']['screen_name'])
39a6f562
MV
157 return (u"%s%s%s%s %s" %(
158 get_time_string(status, options),
0b9960a3 159 ansi.cmdColour(colour), status['user']['screen_name'],
05b85831
HN
160 ansi.cmdReset(), status['text']))
161
f068ff42 162class VerboseStatusFormatter(object):
39a6f562 163 def __call__(self, status, options):
f068ff42 164 return (u"-- %s (%s) on %s\n%s\n" %(
165 status['user']['screen_name'],
166 status['user']['location'],
167 status['created_at'],
0ea01db7 168 status['text']))
f068ff42 169
0ea01db7 170class URLStatusFormatter(object):
171 urlmatch = re.compile(r'https?://\S+')
39a6f562 172 def __call__(self, status, options):
0ea01db7 173 urls = self.urlmatch.findall(status['text'])
174 return u'\n'.join(urls) if urls else ""
175
69851f4c
AL
176class ListsFormatter(object):
177 def __call__(self, list):
178 if list['description']:
179 list_str = u"%-30s (%s)" % (list['name'], list['description'])
180 else:
181 list_str = u"%-30s" % (list['name'])
182 return u"%s\n" % list_str
183
184class ListsVerboseFormatter(object):
185 def __call__(self, list):
186 list_str = u"%-30s\n description: %s\n members: %s\n mode:%s\n" % (list['name'], list['description'], list['member_count'], list['mode'])
187 return list_str
188
189class AnsiListsFormatter(object):
190 def __init__(self):
191 self._colourMap = ansi.ColourMap()
192
193 def __call__(self, list):
194 colour = self._colourMap.colourFor(list['name'])
195 return (u"%s%-15s%s %s" %(
196 ansi.cmdColour(colour), list['name'],
197 ansi.cmdReset(), list['description']))
198
199
1c11e6d7 200class AdminFormatter(object):
efa0ba89 201 def __call__(self, action, user):
da45d039
MV
202 user_str = u"%s (%s)" %(user['screen_name'], user['name'])
203 if action == "follow":
e02facc9 204 return u"You are now following %s.\n" %(user_str)
da45d039 205 else:
e02facc9 206 return u"You are no longer following %s.\n" %(user_str)
efa0ba89 207
1c11e6d7 208class VerboseAdminFormatter(object):
efa0ba89
MV
209 def __call__(self, action, user):
210 return(u"-- %s: %s (%s): %s" % (
05b85831
HN
211 "Following" if action == "follow" else "Leaving",
212 user['screen_name'],
efa0ba89
MV
213 user['name'],
214 user['url']))
215
87be041f
WD
216class SearchFormatter(object):
217 def __call__(self, result, options):
218 return(u"%s%s %s" %(
219 get_time_string(result, options, "%a, %d %b %Y %H:%M:%S +0000"),
220 result['from_user'], result['text']))
221
222class VerboseSearchFormatter(SearchFormatter):
a8b5ad3e
MV
223 pass #Default to the regular one
224
87be041f
WD
225class URLSearchFormatter(object):
226 urlmatch = re.compile(r'https?://\S+')
227 def __call__(self, result, options):
228 urls = self.urlmatch.findall(result['text'])
229 return u'\n'.join(urls) if urls else ""
230
231class AnsiSearchFormatter(object):
232 def __init__(self):
233 self._colourMap = ansi.ColourMap()
a8b5ad3e 234
87be041f
WD
235 def __call__(self, result, options):
236 colour = self._colourMap.colourFor(result['from_user'])
237 return (u"%s%s%s%s %s" %(
238 get_time_string(result, options, "%a, %d %b %Y %H:%M:%S +0000"),
239 ansi.cmdColour(colour), result['from_user'],
240 ansi.cmdReset(), result['text']))
241
5a77e17a
MV
242_term_encoding = None
243def get_term_encoding():
244 global _term_encoding
245 if not _term_encoding:
246 lang = os.getenv('LANG', 'unknown.UTF-8').split('.')
247 if lang[1:]:
248 _term_encoding = lang[1]
249 else:
250 _term_encoding = 'UTF-8'
251 return _term_encoding
252
87be041f 253formatters = {}
1c11e6d7 254status_formatters = {
0ea01db7 255 'default': StatusFormatter,
256 'verbose': VerboseStatusFormatter,
0b9960a3
MV
257 'urls': URLStatusFormatter,
258 'ansi': AnsiStatusFormatter
05b85831 259}
87be041f 260formatters['status'] = status_formatters
1c11e6d7
WD
261
262admin_formatters = {
efa0ba89
MV
263 'default': AdminFormatter,
264 'verbose': VerboseAdminFormatter,
327e556b
MV
265 'urls': AdminFormatter,
266 'ansi': AdminFormatter
1c11e6d7 267}
87be041f 268formatters['admin'] = admin_formatters
efa0ba89 269
87be041f
WD
270search_formatters = {
271 'default': SearchFormatter,
272 'verbose': VerboseSearchFormatter,
273 'urls': URLSearchFormatter,
274 'ansi': AnsiSearchFormatter
275}
276formatters['search'] = search_formatters
277
69851f4c
AL
278lists_formatters = {
279 'default': ListsFormatter,
280 'verbose': ListsVerboseFormatter,
281 'urls': None,
282 'ansi': AnsiListsFormatter
283}
284formatters['lists'] = lists_formatters
285
87be041f
WD
286def get_formatter(action_type, options):
287 formatters_dict = formatters.get(action_type)
288 if (not formatters_dict):
a8b5ad3e 289 raise TwitterError(
87be041f
WD
290 "There was an error finding a class of formatters for your type (%s)"
291 %(action_type))
292 f = formatters_dict.get(options['format'])
293 if (not f):
efa0ba89 294 raise TwitterError(
87be041f
WD
295 "Unknown formatter '%s' for status actions" %(options['format']))
296 return f()
efa0ba89 297
0ea01db7 298class Action(object):
ec894371
MV
299
300 def ask(self, subject='perform this action', careful=False):
05b85831
HN
301 '''
302 Requests fromt he user using `raw_input` if `subject` should be
303 performed. When `careful`, the default answer is NO, otherwise YES.
304 Returns the user answer in the form `True` or `False`.
305 '''
f47ab046
MV
306 sample = '(y/N)'
307 if not careful:
308 sample = '(Y/n)'
a8b5ad3e 309
05b85831
HN
310 prompt = 'You really want to %s %s? ' %(subject, sample)
311 try:
312 answer = raw_input(prompt).lower()
313 if careful:
f47ab046 314 return answer in ('yes', 'y')
05b85831 315 else:
f47ab046 316 return answer not in ('no', 'n')
05b85831
HN
317 except EOFError:
318 print >>sys.stderr # Put Newline since Enter was never pressed
319 # TODO:
320 # Figure out why on OS X the raw_input keeps raising
321 # EOFError and is never able to reset and get more input
322 # Hint: Look at how IPython implements their console
f47ab046
MV
323 default = True
324 if careful:
325 default = False
05b85831 326 return default
a8b5ad3e 327
05b85831
HN
328 def __call__(self, twitter, options):
329 action = actions.get(options['action'], NoSuchAction)()
330 try:
331 doAction = lambda : action(twitter, options)
332 if (options['refresh'] and isinstance(action, StatusAction)):
333 while True:
334 doAction()
335 time.sleep(options['refresh_rate'])
336 else:
337 doAction()
338 except KeyboardInterrupt:
339 print >>sys.stderr, '\n[Keyboard Interrupt]'
340 pass
341
342class NoSuchActionError(Exception):
0ea01db7 343 pass
344
345class NoSuchAction(Action):
346 def __call__(self, twitter, options):
05b85831 347 raise NoSuchActionError("No such action: %s" %(options['action']))
0ea01db7 348
8ddd8500 349def printNicely(string):
862cce81
MV
350 if sys.stdout.encoding:
351 print string.encode(sys.stdout.encoding, 'replace')
352 else:
353 print string.encode('utf-8')
a8b5ad3e 354
0ea01db7 355class StatusAction(Action):
356 def __call__(self, twitter, options):
39a6f562 357 statuses = self.getStatuses(twitter, options)
87be041f 358 sf = get_formatter('status', options)
0ea01db7 359 for status in statuses:
39a6f562 360 statusStr = sf(status, options)
0ea01db7 361 if statusStr.strip():
862cce81 362 printNicely(statusStr)
1c11e6d7 363
87be041f
WD
364class SearchAction(Action):
365 def __call__(self, twitter, options):
366 # We need to be pointing at search.twitter.com to work, and it is less
367 # tangly to do it here than in the main()
368 twitter.domain="search.twitter.com"
e15fbef3 369 twitter.uriparts=()
fd2bc885
WD
370 # We need to bypass the TwitterCall parameter encoding, so we
371 # don't encode the plus sign, so we have to encode it ourselves
5a77e17a
MV
372 query_string = "+".join(
373 [quote(term.decode(get_term_encoding()))
374 for term in options['extra_args']])
fd2bc885 375
e15fbef3 376 results = twitter.search(q=query_string)['results']
87be041f
WD
377 f = get_formatter('search', options)
378 for result in results:
379 resultStr = f(result, options)
380 if resultStr.strip():
381 printNicely(resultStr)
a8b5ad3e 382
1c11e6d7 383class AdminAction(Action):
efa0ba89 384 def __call__(self, twitter, options):
ec894371 385 if not (options['extra_args'] and options['extra_args'][0]):
e02facc9 386 raise TwitterError("You need to specify a user (screen name)")
87be041f 387 af = get_formatter('admin', options)
e02facc9
MV
388 try:
389 user = self.getUser(twitter, options['extra_args'][0])
390 except TwitterError, e:
391 print "There was a problem following or leaving the specified user."
f47ab046
MV
392 print "You may be trying to follow a user you are already following;"
393 print "Leaving a user you are not currently following;"
394 print "Or the user may not exist."
395 print "Sorry."
e02facc9 396 print
45688301 397 print e
e02facc9 398 else:
862cce81 399 printNicely(af(options['action'], user))
efa0ba89 400
69851f4c
AL
401class ListsAction(StatusAction):
402 def getStatuses(self, twitter, options):
403 screen_name = twitter.account.verify_credentials()['screen_name']
404 if not (options['extra_args'] and options['extra_args'][0]):
405 for list in twitter.user.lists(user=screen_name)['lists']:
406 lf = get_formatter('lists', options)
407 printNicely(lf(list))
408 raise SystemExit(0)
409 return reversed(twitter.user.lists.list.statuses(user=screen_name, list=options['extra_args'][0]))
410
0ea01db7 411class FriendsAction(StatusAction):
39a6f562
MV
412 def getStatuses(self, twitter, options):
413 return reversed(twitter.statuses.friends_timeline(count=options["length"]))
efa0ba89 414
0ea01db7 415class PublicAction(StatusAction):
39a6f562
MV
416 def getStatuses(self, twitter, options):
417 return reversed(twitter.statuses.public_timeline(count=options["length"]))
0ea01db7 418
9a9f7ae7 419class RepliesAction(StatusAction):
39a6f562
MV
420 def getStatuses(self, twitter, options):
421 return reversed(twitter.statuses.replies(count=options["length"]))
9a9f7ae7 422
1c11e6d7 423class FollowAction(AdminAction):
efa0ba89 424 def getUser(self, twitter, user):
70955aae 425 return twitter.friendships.create(id=user)
efa0ba89 426
1c11e6d7 427class LeaveAction(AdminAction):
efa0ba89 428 def getUser(self, twitter, user):
70955aae 429 return twitter.friendships.destroy(id=user)
1c11e6d7 430
0ea01db7 431class SetStatusAction(Action):
432 def __call__(self, twitter, options):
5a77e17a 433 statusTxt = (" ".join(options['extra_args']).decode(get_term_encoding())
05b85831 434 if options['extra_args']
772fbdd1 435 else unicode(raw_input("message: ")))
436 status = (statusTxt.encode('utf8', 'replace'))
0ea01db7 437 twitter.statuses.update(status=status)
5251ea48 438
05b85831 439class TwitterShell(Action):
ec894371
MV
440
441 def render_prompt(self, prompt):
05b85831
HN
442 '''Parses the `prompt` string and returns the rendered version'''
443 prompt = prompt.strip("'").replace("\\'","'")
444 for colour in ansi.COLOURS_NAMED:
445 if '[%s]' %(colour) in prompt:
446 prompt = prompt.replace(
a8b5ad3e 447 '[%s]' %(colour), ansi.cmdColourNamed(colour))
05b85831
HN
448 prompt = prompt.replace('[R]', ansi.cmdReset())
449 return prompt
a8b5ad3e 450
05b85831
HN
451 def __call__(self, twitter, options):
452 prompt = self.render_prompt(options.get('prompt', 'twitter> '))
453 while True:
ec894371 454 options['action'] = ""
05b85831
HN
455 try:
456 args = raw_input(prompt).split()
457 parse_args(args, options)
458 if not options['action']:
459 continue
460 elif options['action'] == 'exit':
461 raise SystemExit(0)
462 elif options['action'] == 'shell':
463 print >>sys.stderr, 'Sorry Xzibit does not work here!'
464 continue
465 elif options['action'] == 'help':
466 print >>sys.stderr, '''\ntwitter> `action`\n
a8b5ad3e 467 The Shell Accepts all the command line actions along with:
05b85831 468
a8b5ad3e 469 exit Leave the twitter shell (^D may also be used)
05b85831 470
a8b5ad3e 471 Full CMD Line help is appended below for your convinience.'''
05b85831
HN
472 Action()(twitter, options)
473 options['action'] = ''
474 except NoSuchActionError, e:
475 print >>sys.stderr, e
476 except KeyboardInterrupt:
477 print >>sys.stderr, '\n[Keyboard Interrupt]'
478 except EOFError:
479 print >>sys.stderr
480 leaving = self.ask(subject='Leave')
481 if not leaving:
482 print >>sys.stderr, 'Excellent!'
483 else:
484 raise SystemExit(0)
485
45688301
MV
486class HelpAction(Action):
487 def __call__(self, twitter, options):
488 print __doc__
489
086fc282
MV
490class DoNothingAction(Action):
491 def __call__(self, twitter, options):
492 pass
493
5251ea48 494actions = {
086fc282 495 'authorize' : DoNothingAction,
05b85831
HN
496 'follow' : FollowAction,
497 'friends' : FriendsAction,
69851f4c 498 'list' : ListsAction,
05b85831
HN
499 'help' : HelpAction,
500 'leave' : LeaveAction,
501 'public' : PublicAction,
502 'replies' : RepliesAction,
87be041f 503 'search' : SearchAction,
05b85831
HN
504 'set' : SetStatusAction,
505 'shell' : TwitterShell,
5251ea48 506}
507
21e3bd23 508def loadConfig(filename):
327e556b 509 options = dict(OPTIONS)
21e3bd23 510 if os.path.exists(filename):
511 cp = SafeConfigParser()
512 cp.read([filename])
086fc282 513 for option in ('format', 'prompt'):
327e556b
MV
514 if cp.has_option('twitter', option):
515 options[option] = cp.get('twitter', option)
516 return options
ae1d86aa 517
327e556b
MV
518def main(args=sys.argv[1:]):
519 arg_options = {}
44405280 520 try:
327e556b 521 parse_args(args, arg_options)
44405280
MV
522 except GetoptError, e:
523 print >> sys.stderr, "I can't do that, %s." %(e)
524 print >> sys.stderr
05b85831 525 raise SystemExit(1)
21e3bd23 526
7f22c021 527 config_path = os.path.expanduser(
327e556b 528 arg_options.get('config_filename') or OPTIONS.get('config_filename'))
7f22c021 529 config_options = loadConfig(config_path)
efa0ba89 530
327e556b
MV
531 # Apply the various options in order, the most important applied last.
532 # Defaults first, then what's read from config file, then command-line
533 # arguments.
534 options = dict(OPTIONS)
535 for d in config_options, arg_options:
536 for k,v in d.items():
537 if v: options[k] = v
05b85831 538
e02facc9
MV
539 if options['refresh'] and options['action'] not in (
540 'friends', 'public', 'replies'):
541 print >> sys.stderr, "You can only refresh the friends, public, or replies actions."
0ea01db7 542 print >> sys.stderr, "Use 'twitter -h' for help."
086fc282 543 return 1
05b85831 544
7f22c021
MV
545 oauth_filename = os.path.expanduser(options['oauth_filename'])
546
086fc282 547 if (options['action'] == 'authorize'
7f22c021 548 or not os.path.exists(oauth_filename)):
1b31d642
MV
549 oauth_dance(
550 "the Command-Line Tool", CONSUMER_KEY, CONSUMER_SECRET,
551 options['oauth_filename'])
6c527e72 552
7f22c021 553 oauth_token, oauth_token_secret = read_token_file(oauth_filename)
8ddd8500 554
9a148ed1 555 twitter = Twitter(
086fc282
MV
556 auth=OAuth(
557 oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET),
1cc9ab0b 558 secure=options['secure'],
9c07e547
MV
559 api_version='1',
560 domain='api.twitter.com')
086fc282 561
5251ea48 562 try:
05b85831
HN
563 Action()(twitter, options)
564 except NoSuchActionError, e:
565 print >>sys.stderr, e
566 raise SystemExit(1)
5251ea48 567 except TwitterError, e:
9c07e547 568 print >> sys.stderr, str(e)
5251ea48 569 print >> sys.stderr, "Use 'twitter -h' for help."
05b85831 570 raise SystemExit(1)