]> jfr.im git - z_archive/twitter.git/blame - twitter/cmdline.py
Version bump 1.4.2
[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=',
8ddd8500 93 'refresh-rate=', 'config=', 'length=', 'timestamp',
9a148ed1 94 'datestamp', 'no-ssl']
39a6f562 95 short_opts = "e:p:f:h?rR:c:l:td"
8ddd8500 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)
8ddd8500 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
8ddd8500 314def printNicely(string):
862cce81
MV
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"
e15fbef3 334 twitter.uriparts=()
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 340
e15fbef3 341 results = twitter.search(q=query_string)['results']
87be041f
WD
342 f = get_formatter('search', options)
343 for result in results:
344 resultStr = f(result, options)
345 if resultStr.strip():
346 printNicely(resultStr)
a8b5ad3e 347
1c11e6d7 348class AdminAction(Action):
efa0ba89 349 def __call__(self, twitter, options):
ec894371 350 if not (options['extra_args'] and options['extra_args'][0]):
e02facc9 351 raise TwitterError("You need to specify a user (screen name)")
87be041f 352 af = get_formatter('admin', options)
e02facc9
MV
353 try:
354 user = self.getUser(twitter, options['extra_args'][0])
355 except TwitterError, e:
356 print "There was a problem following or leaving the specified user."
f47ab046
MV
357 print "You may be trying to follow a user you are already following;"
358 print "Leaving a user you are not currently following;"
359 print "Or the user may not exist."
360 print "Sorry."
e02facc9 361 print
45688301 362 print e
e02facc9 363 else:
862cce81 364 printNicely(af(options['action'], user))
efa0ba89 365
0ea01db7 366class FriendsAction(StatusAction):
39a6f562
MV
367 def getStatuses(self, twitter, options):
368 return reversed(twitter.statuses.friends_timeline(count=options["length"]))
efa0ba89 369
0ea01db7 370class PublicAction(StatusAction):
39a6f562
MV
371 def getStatuses(self, twitter, options):
372 return reversed(twitter.statuses.public_timeline(count=options["length"]))
0ea01db7 373
9a9f7ae7 374class RepliesAction(StatusAction):
39a6f562
MV
375 def getStatuses(self, twitter, options):
376 return reversed(twitter.statuses.replies(count=options["length"]))
9a9f7ae7 377
1c11e6d7 378class FollowAction(AdminAction):
efa0ba89 379 def getUser(self, twitter, user):
70955aae 380 return twitter.friendships.create(id=user)
efa0ba89 381
1c11e6d7 382class LeaveAction(AdminAction):
efa0ba89 383 def getUser(self, twitter, user):
70955aae 384 return twitter.friendships.destroy(id=user)
1c11e6d7 385
0ea01db7 386class SetStatusAction(Action):
387 def __call__(self, twitter, options):
5a77e17a 388 statusTxt = (" ".join(options['extra_args']).decode(get_term_encoding())
05b85831 389 if options['extra_args']
772fbdd1 390 else unicode(raw_input("message: ")))
391 status = (statusTxt.encode('utf8', 'replace'))
0ea01db7 392 twitter.statuses.update(status=status)
5251ea48 393
05b85831 394class TwitterShell(Action):
ec894371
MV
395
396 def render_prompt(self, prompt):
05b85831
HN
397 '''Parses the `prompt` string and returns the rendered version'''
398 prompt = prompt.strip("'").replace("\\'","'")
399 for colour in ansi.COLOURS_NAMED:
400 if '[%s]' %(colour) in prompt:
401 prompt = prompt.replace(
a8b5ad3e 402 '[%s]' %(colour), ansi.cmdColourNamed(colour))
05b85831
HN
403 prompt = prompt.replace('[R]', ansi.cmdReset())
404 return prompt
a8b5ad3e 405
05b85831
HN
406 def __call__(self, twitter, options):
407 prompt = self.render_prompt(options.get('prompt', 'twitter> '))
408 while True:
ec894371 409 options['action'] = ""
05b85831
HN
410 try:
411 args = raw_input(prompt).split()
412 parse_args(args, options)
413 if not options['action']:
414 continue
415 elif options['action'] == 'exit':
416 raise SystemExit(0)
417 elif options['action'] == 'shell':
418 print >>sys.stderr, 'Sorry Xzibit does not work here!'
419 continue
420 elif options['action'] == 'help':
421 print >>sys.stderr, '''\ntwitter> `action`\n
a8b5ad3e 422 The Shell Accepts all the command line actions along with:
05b85831 423
a8b5ad3e 424 exit Leave the twitter shell (^D may also be used)
05b85831 425
a8b5ad3e 426 Full CMD Line help is appended below for your convinience.'''
05b85831
HN
427 Action()(twitter, options)
428 options['action'] = ''
429 except NoSuchActionError, e:
430 print >>sys.stderr, e
431 except KeyboardInterrupt:
432 print >>sys.stderr, '\n[Keyboard Interrupt]'
433 except EOFError:
434 print >>sys.stderr
435 leaving = self.ask(subject='Leave')
436 if not leaving:
437 print >>sys.stderr, 'Excellent!'
438 else:
439 raise SystemExit(0)
440
45688301
MV
441class HelpAction(Action):
442 def __call__(self, twitter, options):
443 print __doc__
444
086fc282
MV
445class DoNothingAction(Action):
446 def __call__(self, twitter, options):
447 pass
448
5251ea48 449actions = {
086fc282 450 'authorize' : DoNothingAction,
05b85831
HN
451 'follow' : FollowAction,
452 'friends' : FriendsAction,
453 'help' : HelpAction,
454 'leave' : LeaveAction,
455 'public' : PublicAction,
456 'replies' : RepliesAction,
87be041f 457 'search' : SearchAction,
05b85831
HN
458 'set' : SetStatusAction,
459 'shell' : TwitterShell,
5251ea48 460}
461
21e3bd23 462def loadConfig(filename):
327e556b 463 options = dict(OPTIONS)
21e3bd23 464 if os.path.exists(filename):
465 cp = SafeConfigParser()
466 cp.read([filename])
086fc282 467 for option in ('format', 'prompt'):
327e556b
MV
468 if cp.has_option('twitter', option):
469 options[option] = cp.get('twitter', option)
470 return options
ae1d86aa 471
327e556b
MV
472def main(args=sys.argv[1:]):
473 arg_options = {}
44405280 474 try:
327e556b 475 parse_args(args, arg_options)
44405280
MV
476 except GetoptError, e:
477 print >> sys.stderr, "I can't do that, %s." %(e)
478 print >> sys.stderr
05b85831 479 raise SystemExit(1)
21e3bd23 480
7f22c021 481 config_path = os.path.expanduser(
327e556b 482 arg_options.get('config_filename') or OPTIONS.get('config_filename'))
7f22c021 483 config_options = loadConfig(config_path)
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
7f22c021
MV
499 oauth_filename = os.path.expanduser(options['oauth_filename'])
500
086fc282 501 if (options['action'] == 'authorize'
7f22c021 502 or not os.path.exists(oauth_filename)):
1b31d642
MV
503 oauth_dance(
504 "the Command-Line Tool", CONSUMER_KEY, CONSUMER_SECRET,
505 options['oauth_filename'])
6c527e72 506
7f22c021 507 oauth_token, oauth_token_secret = read_token_file(oauth_filename)
8ddd8500 508
9a148ed1 509 twitter = Twitter(
086fc282
MV
510 auth=OAuth(
511 oauth_token, oauth_token_secret, CONSUMER_KEY, CONSUMER_SECRET),
1cc9ab0b 512 secure=options['secure'],
9c07e547
MV
513 api_version='1',
514 domain='api.twitter.com')
086fc282 515
5251ea48 516 try:
05b85831
HN
517 Action()(twitter, options)
518 except NoSuchActionError, e:
519 print >>sys.stderr, e
520 raise SystemExit(1)
5251ea48 521 except TwitterError, e:
9c07e547 522 print >> sys.stderr, str(e)
5251ea48 523 print >> sys.stderr, "Use 'twitter -h' for help."
05b85831 524 raise SystemExit(1)