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