diff --git a/README.md b/README.md index f67ff33..6b7e022 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,61 @@ 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) + +* --twitter-images FILENAME(S) [FILENAME(S) ...]: + Upload each given image files along with the corresponding tweets in the sequence. + If there are more images than tweets, they are silently ignored. + (default: None) ## 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 f2bc78c..036b8dd 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) @@ -118,114 +137,195 @@ 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.") - -# Twitter -parser.add_argument("--twitter-images", metavar="FILENAME(S)", nargs="+", type=str, - help="Upload each given image files along with the corresponding tweets in sequence. If there are more images than tweets, they are silently ignored.") - -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) ) - -if asked.twitter_images: - if not asked.api == "twitter": - if not asked.quiet: - sys.stderr.write("WARNING: asking to upload images on twitter while not using the twitter API is not logical, I will ignore.") - assert( not (asked.twitter_images and not asked.api=="twitter") ) - - else: # Test readable images - cannot=[] - for img in asked.twitter_images: - try: - with open(img) as f: - f.read() - except OSError: - cannot.append(img) - if cannot: - print("Cannot open the following image files, I will not continue: ") - for img in cannot: - print(img) - sys.exit(5) # I/O Error - - -# 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.") + + # Twitter + parser.add_argument("--twitter-images", metavar="FILENAME(S)", nargs="+", type=str, + help="Upload each given image files along with the corresponding tweets in the sequence. If there are more images than tweets, they are silently ignored.") + + 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) ) + + if asked.twitter_images: + if not asked.api == "twitter": + if not asked.quiet: + sys.stderr.write("WARNING: asking to upload images on twitter while not using the twitter API is not logical, I will ignore.") + assert( not (asked.twitter_images and not asked.api=="twitter") ) + + else: # Test readable images + cannot=[] + for img in asked.twitter_images: + try: + with open(img) as f: + f.read() + except OSError: + cannot.append(img) + if cannot: + print("Cannot open the following image files, I will not continue: ") + for img in cannot: + print(img) + sys.exit(5) # I/O Error + + # 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=