diff --git a/README.md b/README.md index f67ff33..8f70b69 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,24 @@ forthlift — post sequences of texts on social media =================================================== -NOTE: FORTHLIFT IS IN AN EARLY PRE-ALPHA STAGE. - Forthlift is a command line application to post sequences of text lines on social media. -It is designed to ease automation and integration in existing tools. + +Its main use case is to post on Twitter several status that you have prepared first in your text editor. + +It is thus designed to ease automation and integration in existing tools +and had an Unix-like text-and-pipes command line interface. For example, it makes it easy to post "chained" twitter status (that answer to each others) from your text editor. Just send the selected text to its standard input, selecting the -twitter API, and voilà. Using Vim, you can tweet the selected lines with: -`:'<,'>w !forthlift -a twitter --chain` +twitter API, and voilà. Using Vim, you can tweet the visually-selected lines with: +`:'<,'>w !forthlift -a twitter` ## SYNOPSIS `forthlift` [-h] -`forthlift` [-a {stdout,twitter}] [-m MAXLEN] [-i] [-t] [-d] [-q] [-c] +`forthlift` [-a {stdout,twitter}] [-m MAXLEN] [-i|-t] [-c] [-q] [-d] [-s] ## DESCRIPTION @@ -40,26 +42,56 @@ Depending on the chosen API, this could means different things: as a sequence of *answers* and not as a list of independent tweets. * for the "stdout" API, this means that each printed line will start - with as many spaces as its index in the input list. + with its index in the input list. While it is recommended to prepare the input text with other text-processing tools (fold, fmt, tr, sed, grep, your text editor, etc.), forthlift comes with some rough text-processing capabilities, among which: -* ignore or trim lines that are longer than a given size, +* ignore or trim lines that are longer than a given size (see `--trim` and `--ignore`), -* add a counter of the form `/` at the end of the lines. +* add a counter of the form `/` at the end of the lines (see `--counter`). ## OPTIONS -TODO +* -h, --help: show this help message and exit + +* -a {stdout,twitter}, --api {stdout,twitter}: Name of the API to use. + (default: stdout) + +* -m MAXLEN, --max-len MAXLEN: Maximum number of characters in the lines. + (default: 140) + +* -i, --ignore: Ignore lines that are longer than MAXLEN (default: False) + +* -t, --trim: Trim down lines that are longer than MAXLEN. (default: False) + +* -c, --counter: Add a counter of the form " x/N" at the end of the lines, + with N being the number of lines read and x the current index of the line. + NOTE: this necessitate to read all the input before processing it. + (default: False) + +* -q, --quiet: Do not print errors and warnings on the standard error output. + (default: False) + +* -s, --setup: Setup the selected API (e.g. authenticate and get authorization to post). + (default: False) + +* -d, --independent: Post each item independently from the previous one + The behaviour of this option depends on the selected API. + For example on Twitter: do not post a line as an answer to the previous one but as a new tweet. + (default: False) ## INSTALLATION ### Twitter -Copy `twitter.conf-dist` as `twitter.conf` and indicate your developer's API keys and tokens. +1) Copy `twitter.conf-dist` as `twitter.conf` and indicate your developer's API keys in the corresponding fields. +2) Run `forthlift --api twitter --setup` and follow the instructions (go to the given URL, then paste the given PIN). + +Why should you get developer's API keys? Because Twitter does not like open-source desktop applications, see: +http://arstechnica.com/security/2010/09/twitter-a-case-study-on-how-to-do-oauth-wrong/ diff --git a/forthlift.py b/forthlift.py index 2f9aa69..4c1aad7 100755 --- a/forthlift.py +++ b/forthlift.py @@ -3,10 +3,17 @@ import sys import locale import argparse +import datetime +import ConfigParser import tweepy +class AppKeyError(Exception): + def __init__(self, msg): + self.msg = msg + + def write(data, stream = sys.stdout): """ Write "data" on the given stream, then flush, silently handling broken pipes. @@ -38,24 +45,24 @@ def readline( stream_in ): raise StopIteration -def setup_adorn( data, asked ): - assert(asked.adorn) +def setup_counter( data, asked ): + assert(asked.counter) # unroll everything data = list(data) nb = len(data) # " int/int" - adorn_size = len(str(nb))*2 + 1 + 1 + counter_size = len(str(nb))*2 + 1 + 1 for i,line in enumerate(data): - adorn = " %i/%i" % (i+1,nb) - curmax = asked.max_len - len(adorn) + counter = " %i/%i" % (i+1,nb) + curmax = asked.max_len - len(counter) if len(line) > curmax: if asked.ignore: data[i] = line elif asked.trim: data[i] = line[:curmax] - data[i] = data[i] + adorn + data[i] = data[i] + counter return data @@ -72,8 +79,8 @@ def setup_hem(data, asked): def setup( data, asked ): - if asked.adorn: - f = setup_adorn + if asked.counter: + f = setup_counter else: f = setup_hem @@ -81,21 +88,33 @@ def setup( data, asked ): yield line +def operate( func, *args ): + for line in func( readline(sys.stdin), *args ): + write(line) + + +# +# STDOUT API +# + def on_stdout( data, asked, endline="\n" ): lines = setup(data, asked) # You can do something on the whole set of lines if you want. - if asked.chain: - prefix = " " - else: - prefix = "" - for i,line in enumerate(lines): if endline: if line[-1] != endline: line += endline - yield prefix*i + line + if asked.independent: + yield line + else: + yield i + " " + line + + +# +# TWITTER API +# def on_twitter( data, api, asked, endline="\n" ): lines = setup(data, asked) @@ -106,91 +125,172 @@ def on_twitter( data, api, asked, endline="\n" ): # API.update_status(status[, in_reply_to_status_id][, lat][, long][, source][, place_id]) status = api.update_status(line, prev_status_id) - if asked.chain: + if asked.independent: + prev_status_id = None + else: prev_status_id = status.id yield status.text + endline -def operate( func, *args ): - for line in func( readline(sys.stdin), *args ): - write(line) - - - -usage = "Post text read on the standard input to a website or an API." -apis =["stdout", "twitter"] # TODO "http_post", "http_get", - -parser = argparse.ArgumentParser( description=usage, - formatter_class=argparse.ArgumentDefaultsHelpFormatter) - -parser.add_argument("-a", "--api", choices=apis, default="stdout", - help="Name of the API to use.") - -# Generic options - -parser.add_argument("-m", "--max-len", metavar="MAXLEN", type=int, default=140, - help="Maximum number of characters in the lines.") - -parser.add_argument("-i", "--ignore", action="store_true", - help="Ignore lines that are longer than MAXLEN") - -parser.add_argument("-t", "--trim", action="store_true", - help="Trim down lines that are longer than MAXLEN.") - -parser.add_argument("-d", "--adorn", action="store_true", - help="Add a counter of the form \" x/N\" at the end of the lines, \ - with N being the number of lines read and x the current index of the line. \ - NOTE: this necessitate to read all the input before processing it.") - -parser.add_argument("-q", "--quiet", action="store_true", - help="Do not print errors and warnings on the standard error output.") - -# TODO option: rate at which to post lines - -# API-dependant options - -parser.add_argument("-c", "--chain", action="store_true", - help="Chained actions. Whatever that means depends on the chosen API.") - -asked = parser.parse_args() - - -# Consistency checks - -if asked.ignore and asked.trim: - if not asked.quiet: - sys.stderr.write("WARNING: asking to trim AND to ignore is not logical, I will ignore.") - assert( not (asked.ignore and asked.trim) ) - - -# APIs - -if asked.api == "stdout": - - operate( on_stdout, asked ) - - -elif asked.api == "twitter": - - import ConfigParser +def setup_twitter(configfile="twitter.conf"): config = ConfigParser.RawConfigParser() - config.read('twitter.conf') - consumer_key = config.get("Auth","key") - consumer_secret = config.get("Auth","key_secret") + # Authenticate the application. + config.read(configfile) - try: - verifier_code = config.get("Auth","code") - except: - access_token = config.get("Auth","token") - access_token_secret = config.get("Auth","token_secret") + if not config.has_section("App"): + raise AppKeyError("ERROR: did not found application keys, ask your distribution maintainer or get keys from Twitter first.") - auth = tweepy.OAuthHandler(consumer_key, consumer_secret, "https://api.twitter.com/1.1/") - auth.set_access_token(access_token, access_token_secret) + app_key = config.get("App","app_key") + app_secret = config.get("App","app_key_secret") + auth = tweepy.OAuthHandler(app_key, app_secret) + + + # Authenticate the user. + auth_url = auth.get_authorization_url() + print "Copy and paste this URL in your browser while your are logged in the twitter account where you want to post." + print "Authorization URL: " + auth_url + + print "Then paste the Personal Identification Number given by Twitter:" + verifier = raw_input('PIN: ').strip() + auth.get_access_token(verifier) + # print 'ACCESS_KEY = "%s"' % auth.access_token.key + # print 'ACCESS_SECRET = "%s"' % auth.access_token.secret + + # Authenticate and get the user name. + auth.set_access_token(auth.access_token.key, auth.access_token.secret) api = tweepy.API(auth) + username = api.me().name + print "Authentication successful, ready to post to account: " + username - operate( on_twitter, api, asked ) + + # Save authentication tokens. + if not config.has_section("Info"): + config.add_section('Info') + config.set('Info','account', username) + config.set('Info','auth_date', datetime.datetime.utcnow().isoformat()) + + if not config.has_section("Auth"): + config.add_section('Auth') + config.set('Auth', 'local_token', auth.access_token.key) + config.set('Auth', 'local_token_secret', auth.access_token.secret) + + # Writing our configuration file to 'example.cfg' + with open(configfile, 'wb') as fd: + config.write(fd) + + +# +# CLI +# + +if __name__=="__main__": + + errors = {"NO_ERROR":0, "UNKNOWN_ERROR":1, "NO_SETUP_NEEDED":2, "NO_APP_KEY":10} + + usage = "Post text read on the standard input to a website or an API." + apis =["stdout", "twitter"] # TODO "http_post", "http_get", + + parser = argparse.ArgumentParser( description=usage, + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + + parser.add_argument("-a", "--api", choices=apis, default="stdout", + help="Name of the API to use.") + + # Generic options + + parser.add_argument("-m", "--max-len", metavar="MAXLEN", type=int, default=140, + help="Maximum number of characters in the lines.") + + parser.add_argument("-i", "--ignore", action="store_true", + help="Ignore lines that are longer than MAXLEN") + + parser.add_argument("-t", "--trim", action="store_true", + help="Trim down lines that are longer than MAXLEN.") + + parser.add_argument("-c", "--counter", action="store_true", + help="Add a counter of the form \" x/N\" at the end of the lines, \ + with N being the number of lines read and x the current index of the line. \ + NOTE: this necessitate to read all the input before processing it.") + + parser.add_argument("-q", "--quiet", action="store_true", + help="Do not print errors and warnings on the standard error output.") + + # TODO option: rate at which to post lines + + # API-dependent options + + parser.add_argument("-s", "--setup", action="store_true", + help="Setup the selected API (e.g. authenticate and get authorization to post).") + + parser.add_argument("-d", "--independent", action="store_true", + help="Post each item independently from the previous one. \ + The behaviour of this option depends on the selected API. \ + For example on Twitter: do not post a line as an answer to the previous one but as a new tweet.") + + asked = parser.parse_args() + + + # Setup + if asked.setup: + configfile = asked.api+".conf" + if asked.api == "twitter": + try: + setup_twitter(configfile) + except AppKeyError as e: + if not asked.quiet: + sys.stderr.write(e.msg) + sys.exit(errors["NO_APP_KEY"]) + except: + print "Unexpected error:", sys.exc_info()[0] + raise + else: + sys.exit(errors["NO_ERROR"]) + + else: # other API + if not asked.quiet: + sys.stderr.write("This API does not need setup.") + sys.exit(errors["NO_SETUP_NEEDED"]) + + + # Consistency checks + + if asked.ignore and asked.trim: + if not asked.quiet: + sys.stderr.write("WARNING: asking to trim AND to ignore is not logical, I will ignore.") + assert( not (asked.ignore and asked.trim) ) + + + # APIs + + if asked.api == "stdout": + + operate( on_stdout, asked ) + + + elif asked.api == "twitter": + + # Authenticate + config = ConfigParser.RawConfigParser() + config.read('twitter.conf') + + app_key = config.get("App","app_key") + app_secret = config.get("App","app_key_secret") + + try: + verifier_code = config.get("Auth","code") + except: + access_token = config.get("Auth","local_token") + access_token_secret = config.get("Auth","local_token_secret") + + auth = tweepy.OAuthHandler(app_key, app_secret, "https://api.twitter.com/1.1/") + auth.set_access_token(access_token, access_token_secret) + + api = tweepy.API(auth) + + # Post + operate( on_twitter, api, asked ) diff --git a/twitter.conf-dist b/twitter.conf-dist index aa3b3ff..2f009fb 100644 --- a/twitter.conf-dist +++ b/twitter.conf-dist @@ -1,5 +1,3 @@ -[Auth] -key= -key_secret= -token= -token_secret= +[App] +app_key= +app_key_secret=