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