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