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