#!/usr/bin/env python # # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to You under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # # SvnTweet - Subscribe to a SvnPubSub stream, and Twitter about it! # # Example: # svntweet.py my-config.json # # With my-config.json containing stream paths and the twitter auth info: # {"stream": "http://svn.apache.org:2069/commits", # "username": "asfcommits", # "password": "MyLuggageComboIs1234"} # # # import threading import sys import os try: import simplejson as json except ImportError: import json from twisted.internet import defer, reactor, task, threads from twisted.python import failure, log from twisted.web.client import HTTPClientFactory, HTTPPageDownloader from urlparse import urlparse import time import posixpath sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "twitty-twister", "lib")) try: import twitter except: print "Get a copy of twitty-twister from " sys.exit(-1) class Config(object): def __init__(self, path): self.path = path self.mtime_path = 0 self.config = {} self._load_config() def _load_config(self): mtime = os.path.getmtime(self.path) if mtime != self.mtime_path: fp = open(self.path, "rb") self.mtime_path = mtime self.config = json.loads(fp.read()) class HTTPStream(HTTPClientFactory): protocol = HTTPPageDownloader def __init__(self, url): HTTPClientFactory.__init__(self, url, method="GET", agent="SvnTweet/0.1.0") def pageStart(self, partial): pass def pagePart(self, data): pass def pageEnd(self): pass class Commit(object): def __init__(self, commit): self.__dict__.update(commit) class JSONRecordHandler: def __init__(self, bdec): self.bdec = bdec def feed(self, record): obj = json.loads(record) if 'svnpubsub' in obj: actual_version = obj['svnpubsub'].get('version') EXPECTED_VERSION = 1 if actual_version != EXPECTED_VERSION: raise ValueException("Unknown svnpubsub format: %r != %d" % (actual_format, expected_format)) elif 'commit' in obj: commit = Commit(obj['commit']) if not hasattr(commit, 'type'): raise ValueException("Commit object is missing type field.") if not hasattr(commit, 'format'): raise ValueException("Commit object is missing format field.") if commit.type != 'svn' and commit.format != 1: raise ValueException("Unexpected type and/or format: %s:%s" % (commit.type, commit.format)) self.bdec.commit(commit) elif 'stillalive' in obj: self.bdec.stillalive() class JSONHTTPStream(HTTPStream): def __init__(self, url, bdec): HTTPStream.__init__(self, url) self.bdec = bdec self.ibuffer = [] self.parser = JSONRecordHandler(bdec) def pageStart(self, partial): self.bdec.pageStart() def pagePart(self, data): eor = data.find("\0") if eor >= 0: self.ibuffer.append(data[0:eor]) self.parser.feed(''.join(self.ibuffer)) self.ibuffer = [data[eor+1:]] else: self.ibuffer.append(data) def connectTo(url, bdec): u = urlparse(url) port = u.port if not port: port = 80 s = JSONHTTPStream(url, bdec) conn = reactor.connectTCP(u.hostname, u.port, s) return [s, conn] CHECKBEAT_TIME = 90 class BigDoEverythingClasss(object): def __init__(self, config): self.c = config self.c._load_config() self.url = str(self.c.config.get('stream')) self.failures = 0 self.alive = time.time() self.checker = task.LoopingCall(self._checkalive) self.transport = None self.stream = None self._restartStream() self.watch = [] self.twit = twitter.Twitter(self.c.config.get('username'), self.c.config.get('password')) def pageStart(self): log.msg("Stream Connection Established") self.failures = 0 def _restartStream(self): (self.stream, self.transport) = connectTo(self.url, self) self.stream.deferred.addBoth(self.streamDead) self.alive = time.time() self.checker.start(CHECKBEAT_TIME) def _checkalive(self): n = time.time() if n - self.alive > CHECKBEAT_TIME: log.msg("Stream is dead, reconnecting") self.transport.disconnect() def stillalive(self): self.alive = time.time() def streamDead(self, v): BACKOFF_SECS = 5 BACKOFF_MAX = 60 self.checker.stop() self.stream = None self.failures += 1 backoff = min(self.failures * BACKOFF_SECS, BACKOFF_MAX) log.msg("Stream disconnected, trying again in %d seconds.... %s" % (backoff, self.url)) reactor.callLater(backoff, self._restartStream) def _normalize_path(self, path): if path[0] != '/': return "/" + path return posixpath.abspath(path) def tweet(self, msg): log.msg("SEND TWEET: %s" % (msg)) self.twit.update(msg).addCallback(self.tweet_done).addErrback(log.msg) def tweet_done(self, x): log.msg("TWEET: Success!") def build_tweet(self, commit): maxlen = 144 left = maxlen paths = map(self._normalize_path, commit.changed) if not len(paths): return None path = posixpath.commonprefix(paths) if path[0:1] == '/' and len(path) > 1: path = path[1:] #TODO: allow URL to be configurable. link = " - http://svn.apache.org/r%d" % (commit.id) left -= len(link) msg = "r%d in %s by %s: " % (commit.id, path, commit.committer) left -= len(msg) if left > 3: msg += commit.log[0:left] msg += link return msg def commit(self, commit): log.msg("COMMIT r%d (%d paths)" % (commit.id, len(commit.changed))) msg = self.build_tweet(commit) if msg: self.tweet(msg) #print "Common Prefix: %s" % (pre) def main(config_file): c = Config(config_file) big = BigDoEverythingClasss(c) reactor.run() if __name__ == "__main__": if len(sys.argv) != 2: print "invalid args, read source code" sys.exit(0) log.startLogging(sys.stdout) main(sys.argv[1])