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