From d70943cc949df17b3dae901da055940bc4c92310 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Wed, 22 Feb 2012 11:02:06 -0200 Subject: [PATCH 01/68] Removing trailing whitespaces --- examplebot.py | 9 ++++----- ircbotframe.py | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/examplebot.py b/examplebot.py index ff6fd5c..d069933 100644 --- a/examplebot.py +++ b/examplebot.py @@ -2,14 +2,14 @@ import sys # Bot specific function definitions - + def authFailure(recipient, name): bot.say(recipient, "You could not be identified") def quitSuccess(quitMessage): bot.disconnect(quitMessage) bot.stop() - + def joinSuccess(channel): bot.join(channel) @@ -21,7 +21,7 @@ def kickSuccess(nick, channel, reason): def identPass(): pass - + def identFail(): pass @@ -46,7 +46,7 @@ def privmsg(sender, headers, message): bot.identify(sender, kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) else: print "PRIVMSG: \"" + message + "\"" - + def actionmsg(sender, headers, message): print "An ACTION message was sent by " + sender + " with the headers " + headers + ". It says: \"" + sender + " " + message @@ -75,4 +75,3 @@ def endMOTD(sender, headers, message): else: print "Usage: python examplebot.py " - diff --git a/ircbotframe.py b/ircbotframe.py index f374c02..2d973ab 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -86,8 +86,8 @@ def __init__(self, network, port, name, description): self.debug = True # PRIVATE FUNCTIONS def __identAccept(self, nick): - # Calls the given "approved" callbacks for all functions called by that nick. - i = 0 + # Calls the given "approved" callbacks for all functions called by that nick. + i = 0 while i < len(self.identifyNickCommands): (nickName, accept, acceptParams, reject, rejectParams) = self.identifyNickCommands[i] if nick == nickName: @@ -203,7 +203,7 @@ def run(self): if self.outBuf.isInError(): self.reconnect() def say(self, recipient, message): - self.outBuf.sendBuffered("PRIVMSG " + recipient + " :" + message) + self.outBuf.sendBuffered("PRIVMSG " + recipient + " :" + message) def send(self, string): self.outBuf.sendBuffered(string) def stop(self): From 2ccdd4595423ba7626736d9d0e032796f95aa628 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 09:53:04 -0200 Subject: [PATCH 02/68] Fix: Wrong number of paramente When using command !say it brokes because of a wrong number of parameters --- examplebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examplebot.py b/examplebot.py index d069933..caaa188 100644 --- a/examplebot.py +++ b/examplebot.py @@ -29,7 +29,7 @@ def privmsg(sender, headers, message): if message.startswith("!say "): firstSpace = message[5:].find(" ") + 5 if sender == owner: - bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (sender,)) + bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) elif message.startswith("!quit"): if sender == owner: if len(message) > 6: From 3819239c5f564ee69d9ea6967379269c322cd3bf Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 16:16:02 -0200 Subject: [PATCH 03/68] Fix type --- ircbotframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ircbotframe.py b/ircbotframe.py index 2d973ab..4a6d49a 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -128,7 +128,7 @@ def __processLine(self, line): self.__identAccept(headers[3]) if headers[1] == "318" and len(headers) >= 4: self.__identReject(headers[3]) - #identifys the next user in the nick commands list + #identifies the next user in the nick commands list if len(self.identifyNickCommands) == 0: self.identifyLock = False else: From 54ec9f30a2c03d4044102daeb8c49ae8b67ecf4f Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:09:52 -0200 Subject: [PATCH 04/68] Adding tags from ctags --- tags | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tags diff --git a/tags b/tags new file mode 100644 index 0000000..3bcce0b --- /dev/null +++ b/tags @@ -0,0 +1,48 @@ +!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/ +!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/ +!_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/ +!_TAG_PROGRAM_NAME Exuberant Ctags // +!_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/ +!_TAG_PROGRAM_VERSION 5.9~svn20110310 // +__callBind ircbotframe.py /^ def __callBind(self, msgtype, sender, headers, message):$/;" m class:ircBot file: +__debugPrint ircbotframe.py /^ def __debugPrint(self, s):$/;" m class:ircBot file: +__identAccept ircbotframe.py /^ def __identAccept(self, nick):$/;" m class:ircBot file: +__identReject ircbotframe.py /^ def __identReject(self, nick):$/;" m class:ircBot file: +__init__ ircbotframe.py /^ def __init__(self, irc):$/;" m class:ircInputBuffer +__init__ ircbotframe.py /^ def __init__(self, irc):$/;" m class:ircOutputBuffer +__init__ ircbotframe.py /^ def __init__(self, network, port, name, description):$/;" m class:ircBot +__pop ircbotframe.py /^ def __pop(self):$/;" m class:ircOutputBuffer file: +__processLine ircbotframe.py /^ def __processLine(self, line):$/;" m class:ircBot file: +__recv ircbotframe.py /^ def __recv(self):$/;" m class:ircInputBuffer file: +__startPopTimer ircbotframe.py /^ def __startPopTimer(self):$/;" m class:ircOutputBuffer file: +actionmsg examplebot.py /^def actionmsg(sender, headers, message):$/;" f +authFailure examplebot.py /^def authFailure(recipient, name):$/;" f +ban ircbotframe.py /^ def ban(self, banMask, channel, reason):$/;" m class:ircBot +bind ircbotframe.py /^ def bind(self, msgtype, callback):$/;" m class:ircBot +connect ircbotframe.py /^ def connect(self):$/;" m class:ircBot +debug ircbotframe.py /^ def debug(self, state):$/;" m class:ircBot +disconnect ircbotframe.py /^ def disconnect(self, qMessage):$/;" m class:ircBot +endMOTD examplebot.py /^def endMOTD(sender, headers, message):$/;" f +getLine ircbotframe.py /^ def getLine(self):$/;" m class:ircInputBuffer +identFail examplebot.py /^def identFail():$/;" f +identPass examplebot.py /^def identPass():$/;" f +identify ircbotframe.py /^ def identify(self, nick, approvedFunc, approvedParams, deniedFunc, deniedParams):$/;" m class:ircBot +ircBot ircbotframe.py /^class ircBot:$/;" c +ircInputBuffer ircbotframe.py /^class ircInputBuffer:$/;" c +ircOutputBuffer ircbotframe.py /^class ircOutputBuffer:$/;" c +isInError ircbotframe.py /^ def isInError(self):$/;" m class:ircOutputBuffer +join ircbotframe.py /^ def join(self, channel):$/;" m class:ircBot +joinSuccess examplebot.py /^def joinSuccess(channel):$/;" f +kick ircbotframe.py /^ def kick(self, nick, channel, reason):$/;" m class:ircBot +kickSuccess examplebot.py /^def kickSuccess(nick, channel, reason):$/;" f +privmsg examplebot.py /^def privmsg(sender, headers, message):$/;" f +quitSuccess examplebot.py /^def quitSuccess(quitMessage):$/;" f +reconnect ircbotframe.py /^ def reconnect(self):$/;" m class:ircBot +run ircbotframe.py /^ def run(self):$/;" m class:ircBot +say ircbotframe.py /^ def say(self, recipient, message):$/;" m class:ircBot +saySuccess examplebot.py /^def saySuccess(channel, message):$/;" f +send ircbotframe.py /^ def send(self, string):$/;" m class:ircBot +sendBuffered ircbotframe.py /^ def sendBuffered(self, string):$/;" m class:ircOutputBuffer +sendImmediately ircbotframe.py /^ def sendImmediately(self, string):$/;" m class:ircOutputBuffer +stop ircbotframe.py /^ def stop(self):$/;" m class:ircBot +unban ircbotframe.py /^ def unban(self, banMask, channel):$/;" m class:ircBot From 4435ec809e1895464d2c4accb948b3a9f2682401 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:10:57 -0200 Subject: [PATCH 05/68] Add .gitignore - Ignore .swp files from VIM - Ignore .pyc files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bb2efd --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.*.swp +*.pyc From 49a96f517276ca04720d25377b3608cfb3ba2137 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:17:49 -0200 Subject: [PATCH 06/68] Make msg more understandable --- examplebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examplebot.py b/examplebot.py index caaa188..79d7366 100644 --- a/examplebot.py +++ b/examplebot.py @@ -4,7 +4,7 @@ # Bot specific function definitions def authFailure(recipient, name): - bot.say(recipient, "You could not be identified") + bot.say(recipient, "You could not be identified to use comand") def quitSuccess(quitMessage): bot.disconnect(quitMessage) From 6708643789febc277bd0b6cb8b255ae22b180008 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:18:54 -0200 Subject: [PATCH 07/68] Check the owner just once If only the owner is allowed to use the commands, it needs to be checked just once --- examplebot.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/examplebot.py b/examplebot.py index 79d7366..b27a5fc 100644 --- a/examplebot.py +++ b/examplebot.py @@ -26,25 +26,23 @@ def identFail(): pass def privmsg(sender, headers, message): - if message.startswith("!say "): - firstSpace = message[5:].find(" ") + 5 - if sender == owner: + if sender == owner: + if message.startswith("!say "): + firstSpace = message[5:].find(" ") + 5 bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) - elif message.startswith("!quit"): - if sender == owner: + elif message.startswith("!quit"): if len(message) > 6: bot.identify(sender, quitSuccess, (message[6:],), authFailure, (headers[0], sender)) else: bot.identify(sender, quitSuccess, ("",), authFailure, (headers[0], sender)) - elif message.startswith("!join "): - if sender == owner: + elif message.startswith("!join "): bot.identify(sender, joinSuccess, (message[6:],), authFailure, (headers[0], sender)) - elif message.startswith("!kick "): - firstSpace = message[6:].find(" ") + 6 - secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) - if sender == owner: + elif message.startswith("!kick "): + firstSpace = message[6:].find(" ") + 6 + secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) bot.identify(sender, kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) else: + authFailure(sender, '') print "PRIVMSG: \"" + message + "\"" def actionmsg(sender, headers, message): From 9adc6a0f14fc080a486204d73789e479d2f39af5 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:24:48 -0200 Subject: [PATCH 08/68] Add new command !help New command !help added and can be accessed without permission --- examplebot.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examplebot.py b/examplebot.py index b27a5fc..03258d7 100644 --- a/examplebot.py +++ b/examplebot.py @@ -26,6 +26,15 @@ def identFail(): pass def privmsg(sender, headers, message): + if message.startswith("!help "): + bot.say(chanName, "I am a bot.") + bot.say(chanName, "I have this commands:") + bot.say(chanName, "Join (joins a channel); Usage: \"!join #\"") + bot.say(chanName, "Kick (kicks a user); Usage: \"!kick # \"") + bot.say(chanName, "Quit (disconnects from the IRC server); Usage: \"!quit []\"") + bot.say(chanName, "Say (makes the bot say something); Usage: \"!say \"") + bot.say(chanName, "Help (makes the bot say this message); Usage: \"!help \"") + if sender == owner: if message.startswith("!say "): firstSpace = message[5:].find(" ") + 5 @@ -50,14 +59,6 @@ def actionmsg(sender, headers, message): def endMOTD(sender, headers, message): bot.join(chanName) - bot.say(chanName, "I am an example bot.") - bot.say(chanName, "I have 4 functions, they are Join, Kick, Quit and Say.") - bot.say(chanName, "Join (joins a channel); Usage: \"!join #\"") - bot.say(chanName, "Kick (kicks a user); Usage: \"!kick # \"") - bot.say(chanName, "Quit (disconnects from the IRC server); Usage: \"!quit []\"") - bot.say(chanName, "Say (makes the bot say something); Usage: \"!say \"") - bot.say(chanName, "The underlying framework is in no way limited to the above functions.") - bot.say(chanName, "This is merely an example of the framework's usage") # Main program begins here if __name__ == "__main__": From ae3fca7814a64be3c6b7cffe1114dab901f26114 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:29:50 -0200 Subject: [PATCH 09/68] Network as a parameter Network is now a parameter for the commandline --- examplebot.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/examplebot.py b/examplebot.py index 03258d7..45bb7a6 100644 --- a/examplebot.py +++ b/examplebot.py @@ -63,14 +63,15 @@ def endMOTD(sender, headers, message): # Main program begins here if __name__ == "__main__": if len(sys.argv) == 3: - owner = sys.argv[1] - chanName = "#" + sys.argv[2] - bot = ircBot("irc.synirc.net", 6667, "ExampleBot", "An example bot written with the new IRC bot framework") + owner = sys.argv[2] + chanName = "#" + sys.argv[3] + network = sys.argv[1] + bot = ircBot(network, 6667, "ExampleBot", "An example bot written with the new IRC bot framework") bot.bind("PRIVMSG", privmsg) bot.bind("ACTION", actionmsg) bot.bind("376", endMOTD) bot.connect() bot.run() else: - print "Usage: python examplebot.py " + print "Usage: python examplebot.py " From 265acd85c47007abfaad1ca5207876d4b1e4b046 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:31:31 -0200 Subject: [PATCH 10/68] Fix: wrong number of parameters --- examplebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examplebot.py b/examplebot.py index 45bb7a6..8746ca7 100644 --- a/examplebot.py +++ b/examplebot.py @@ -62,7 +62,7 @@ def endMOTD(sender, headers, message): # Main program begins here if __name__ == "__main__": - if len(sys.argv) == 3: + if len(sys.argv) == 4: owner = sys.argv[2] chanName = "#" + sys.argv[3] network = sys.argv[1] From 3e5b3b0f1c28991af8af0edb1bcd4c6183fa80ae Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 23 Feb 2012 17:32:58 -0200 Subject: [PATCH 11/68] Help doesnt need space --- examplebot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examplebot.py b/examplebot.py index 8746ca7..1131285 100644 --- a/examplebot.py +++ b/examplebot.py @@ -26,7 +26,7 @@ def identFail(): pass def privmsg(sender, headers, message): - if message.startswith("!help "): + if message.startswith("!help"): bot.say(chanName, "I am a bot.") bot.say(chanName, "I have this commands:") bot.say(chanName, "Join (joins a channel); Usage: \"!join #\"") From 940d8a23e9755c52e1df7e7f1a7bdb85de0185e3 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:44:53 -0300 Subject: [PATCH 12/68] Add topic change support --- ircbotframe.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ircbotframe.py b/ircbotframe.py index 4a6d49a..8b0c4a9 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -181,6 +181,11 @@ def join(self, channel): def kick(self, nick, channel, reason): self.__debugPrint("Kicking " + nick + "...") self.outBuf.sendBuffered("KICK " + channel + " " + nick + " :" + reason) + + def topic(self, channel, msg): + self.__debugPrint("Seting topic: " + msg ) + self.outBuf.sendBuffered("TOPIC " + channel + " :" + msg) + def reconnect(self): self.disconnect("Reconnecting") self.__debugPrint("Pausing before reconnecting...") From af58b6e25f79e6403f7dbe2916dc44abd9588473 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:45:22 -0300 Subject: [PATCH 13/68] Add sqlite3 support --- ircbotframe.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/ircbotframe.py b/ircbotframe.py index 8b0c4a9..557caed 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -3,6 +3,8 @@ import re import time +import sqlite3 as sql + class ircOutputBuffer: # Delays consecutive messages by at least 1 second. # This prevents the bot spamming the IRC server. @@ -73,7 +75,7 @@ def getLine(self): return str(line) class ircBot: - def __init__(self, network, port, name, description): + def __init__(self, network, port, name, description, db): self.keepGoing = True self.name = name self.desc = description @@ -84,6 +86,8 @@ def __init__(self, network, port, name, description): self.serverName = "" self.binds = [] self.debug = True + self.__db = db + # PRIVATE FUNCTIONS def __identAccept(self, nick): # Calls the given "approved" callbacks for all functions called by that nick. @@ -145,6 +149,19 @@ def __processLine(self, line): def __debugPrint(self, s): if self.debug: print s + + def __dbConnect(self): + self.connection = sql.connect(self.__db) + self.cursor = self.connection.cursor() + + def query(self, querymsg, param): + self.__dbConnect() + + return self.cursor.execute(querymsg, param) + + def commit(self): + self.connection.commit() + # PUBLIC FUNCTIONS def ban(self, banMask, channel, reason): self.__debugPrint("Banning " + banMask + "...") From 0f917cdbacabaa4222f497921a4be72021d4ea34 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:47:15 -0300 Subject: [PATCH 14/68] Change examplebot to pomobot --- examplebot.py => pomobot.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename examplebot.py => pomobot.py (100%) diff --git a/examplebot.py b/pomobot.py similarity index 100% rename from examplebot.py rename to pomobot.py From da8fc569dee900b1ab280b8f48fa1b27f6460da7 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:47:40 -0300 Subject: [PATCH 15/68] This options does not exist on this network --- ircbotframe.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ircbotframe.py b/ircbotframe.py index 557caed..c69d9c2 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -128,8 +128,10 @@ def __processLine(self, line): if sender == self.serverName: if(self.debug): print "[" + headers[1] + "] " + message - if headers[1] == "307" and len(headers) >= 4: - self.__identAccept(headers[3]) + #if headers[1] == "307" and len(headers) >= 4: + + self.__identAccept(headers[3]) + if headers[1] == "318" and len(headers) >= 4: self.__identReject(headers[3]) #identifies the next user in the nick commands list From 2ac9cae079f68956cf284a5047b48160e14715e6 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:48:11 -0300 Subject: [PATCH 16/68] New rank and pomo+ option --- pomobot.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 1131285..7f5ffe2 100644 --- a/pomobot.py +++ b/pomobot.py @@ -35,6 +35,44 @@ def privmsg(sender, headers, message): bot.say(chanName, "Say (makes the bot say something); Usage: \"!say \"") bot.say(chanName, "Help (makes the bot say this message); Usage: \"!help \"") + if message.startswith("!pomo+"): + r = bot.query("select count(name) from ranking where name =?", (sender,)) + + for i in r: + count = i[0] + + # if theres someone qts++: else add it with 1 + if count == 1: + r = bot.query("select name, qts from ranking where name =?", (sender,)) + for i in r: + qt = i[1] + 1 + bot.query("update ranking set qts =? where name = ? ", (qt, sender)) + else: + bot.query("insert into ranking (name, qts) values(?,1)", (sender,)) + + bot.commit() + + # rank for topic + r = bot.query('select name, qts from ranking order by qts desc', ()) + + pos = 1 + topic = "" + for i in r: + topic += str(pos) + " - " + i[0] + ": " + str(i[1]) + " / " + pos += 1 + + bot.topic(chanName, topic) + + if message.startswith("!rank"): + r = bot.query('select name, qts from ranking order by qts desc', ()) + + bot.say(chanName, "Rank:") + + pos = 1 + for i in r: + bot.say(chanName, str(pos) + " - " + i[0] + ": " + str(i[1])) + pos += 1 + if sender == owner: if message.startswith("!say "): firstSpace = message[5:].find(" ") + 5 @@ -66,7 +104,7 @@ def endMOTD(sender, headers, message): owner = sys.argv[2] chanName = "#" + sys.argv[3] network = sys.argv[1] - bot = ircBot(network, 6667, "ExampleBot", "An example bot written with the new IRC bot framework") + bot = ircBot(network, 6667, "PomodoroBot", "An example bot written with the new IRC bot framework", "pomodoro.db") bot.bind("PRIVMSG", privmsg) bot.bind("ACTION", actionmsg) bot.bind("376", endMOTD) From ce16d69a572f2c877ac5541b2efca85fdda715fb Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:51:29 -0300 Subject: [PATCH 17/68] Ordering functions --- ircbotframe.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ircbotframe.py b/ircbotframe.py index c69d9c2..8cebecc 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -156,15 +156,9 @@ def __dbConnect(self): self.connection = sql.connect(self.__db) self.cursor = self.connection.cursor() - def query(self, querymsg, param): - self.__dbConnect() - - return self.cursor.execute(querymsg, param) - - def commit(self): - self.connection.commit() # PUBLIC FUNCTIONS + def ban(self, banMask, channel, reason): self.__debugPrint("Banning " + banMask + "...") self.outBuf.sendBuffered("MODE +b " + channel + " " + banMask) @@ -174,6 +168,8 @@ def bind(self, msgtype, callback): if self.binds[i][0] == msgtype: self.binds.remove(i) self.binds.append((msgtype, callback)) + def commit(self): + self.connection.commit() def connect(self): self.__debugPrint("Connecting...") self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -200,10 +196,11 @@ def join(self, channel): def kick(self, nick, channel, reason): self.__debugPrint("Kicking " + nick + "...") self.outBuf.sendBuffered("KICK " + channel + " " + nick + " :" + reason) + def query(self, querymsg, param): + self.__dbConnect() + + return self.cursor.execute(querymsg, param) - def topic(self, channel, msg): - self.__debugPrint("Seting topic: " + msg ) - self.outBuf.sendBuffered("TOPIC " + channel + " :" + msg) def reconnect(self): self.disconnect("Reconnecting") @@ -232,6 +229,9 @@ def send(self, string): self.outBuf.sendBuffered(string) def stop(self): self.keepGoing = False + def topic(self, channel, msg): + self.__debugPrint("Seting topic: " + msg ) + self.outBuf.sendBuffered("TOPIC " + channel + " :" + msg) def unban(self, banMask, channel): self.__debugPrint("Unbanning " + banMask + "...") self.outBuf.sendBuffered("MODE -b " + channel + " " + banMask) From c823acdefa87b7233967b245287c646292d1c4bc Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 1 Mar 2012 14:53:01 -0300 Subject: [PATCH 18/68] Always close a connection --- ircbotframe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ircbotframe.py b/ircbotframe.py index 8cebecc..1fd44ad 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -156,7 +156,6 @@ def __dbConnect(self): self.connection = sql.connect(self.__db) self.cursor = self.connection.cursor() - # PUBLIC FUNCTIONS def ban(self, banMask, channel, reason): @@ -170,6 +169,8 @@ def bind(self, msgtype, callback): self.binds.append((msgtype, callback)) def commit(self): self.connection.commit() + self.cursor.close() + def connect(self): self.__debugPrint("Connecting...") self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -201,7 +202,6 @@ def query(self, querymsg, param): return self.cursor.execute(querymsg, param) - def reconnect(self): self.disconnect("Reconnecting") self.__debugPrint("Pausing before reconnecting...") From 5ddc88d6e423eb680595c1fe261621289c12d2bd Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Fri, 2 Mar 2012 09:40:53 -0300 Subject: [PATCH 19/68] - Add more options to !help - Create specific functions to commands --- pomobot.py | 83 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/pomobot.py b/pomobot.py index 7f5ffe2..f51156b 100644 --- a/pomobot.py +++ b/pomobot.py @@ -25,53 +25,64 @@ def identPass(): def identFail(): pass -def privmsg(sender, headers, message): - if message.startswith("!help"): - bot.say(chanName, "I am a bot.") - bot.say(chanName, "I have this commands:") - bot.say(chanName, "Join (joins a channel); Usage: \"!join #\"") - bot.say(chanName, "Kick (kicks a user); Usage: \"!kick # \"") - bot.say(chanName, "Quit (disconnects from the IRC server); Usage: \"!quit []\"") - bot.say(chanName, "Say (makes the bot say something); Usage: \"!say \"") - bot.say(chanName, "Help (makes the bot say this message); Usage: \"!help \"") +def updateRankTopic(): + # rank for topic + r = bot.query('select name, qts from ranking order by qts desc', ()) - if message.startswith("!pomo+"): - r = bot.query("select count(name) from ranking where name =?", (sender,)) + pos = 1 + topic = "" + for i in r: + topic += str(pos) + " - " + i[0] + ": " + str(i[1]) + " / " + pos += 1 + bot.topic(chanName, topic[:-2]) + +def pomoadd(): + r = bot.query("select count(name) from ranking where name =?", (sender,)) + + for i in r: + count = i[0] + + # if theres someone qts++: else add it with 1 + if count == 1: + r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: - count = i[0] + qt = i[1] + 1 + bot.query("update ranking set qts =? where name = ? ", (qt, sender)) + else: + bot.query("insert into ranking (name, qts) values(?,1)", (sender,)) - # if theres someone qts++: else add it with 1 - if count == 1: - r = bot.query("select name, qts from ranking where name =?", (sender,)) - for i in r: - qt = i[1] + 1 - bot.query("update ranking set qts =? where name = ? ", (qt, sender)) - else: - bot.query("insert into ranking (name, qts) values(?,1)", (sender,)) + bot.commit() - bot.commit() + updateRankTopic() - # rank for topic - r = bot.query('select name, qts from ranking order by qts desc', ()) +def showRank(): + r = bot.query('select name, qts from ranking order by qts desc', ()) - pos = 1 - topic = "" - for i in r: - topic += str(pos) + " - " + i[0] + ": " + str(i[1]) + " / " - pos += 1 + bot.say(chanName, "Rank:") - bot.topic(chanName, topic) + pos = 1 + for i in r: + bot.say(chanName, str(pos) + " - " + i[0] + ": " + str(i[1])) + pos += 1 - if message.startswith("!rank"): - r = bot.query('select name, qts from ranking order by qts desc', ()) - bot.say(chanName, "Rank:") +def privmsg(sender, headers, message): + if message.startswith("!help"): + bot.say(chanName, "I am a bot.") + bot.say(chanName, "I have this commands:") + bot.say(chanName, "Help (makes the bot say this message) - Usage: \"!help \"") + bot.say(chanName, "!rank tells the rank - Usage: \"!rank \"") + bot.say(chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") + + if message.startswith("!pomo+"): + pomoadd() - pos = 1 - for i in r: - bot.say(chanName, str(pos) + " - " + i[0] + ": " + str(i[1])) - pos += 1 + if message.startswith("!updaterank"): + updateRankTopic() + + if message.startswith("!rank"): + showRank() if sender == owner: if message.startswith("!say "): From aa909d204fd6e79d065c9b94589169acf27c9ca5 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 5 Mar 2012 11:41:15 -0300 Subject: [PATCH 20/68] Fix: chanName needs to be passed --- pomobot.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pomobot.py b/pomobot.py index f51156b..0ca4816 100644 --- a/pomobot.py +++ b/pomobot.py @@ -37,7 +37,7 @@ def updateRankTopic(): bot.topic(chanName, topic[:-2]) -def pomoadd(): +def pomoadd(sender): r = bot.query("select count(name) from ranking where name =?", (sender,)) for i in r: @@ -56,14 +56,14 @@ def pomoadd(): updateRankTopic() -def showRank(): +def showRank(chan): r = bot.query('select name, qts from ranking order by qts desc', ()) - bot.say(chanName, "Rank:") + bot.say(chan, "Rank:") pos = 1 for i in r: - bot.say(chanName, str(pos) + " - " + i[0] + ": " + str(i[1])) + bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) pos += 1 @@ -76,13 +76,13 @@ def privmsg(sender, headers, message): bot.say(chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") if message.startswith("!pomo+"): - pomoadd() + pomoadd(sender) if message.startswith("!updaterank"): updateRankTopic() if message.startswith("!rank"): - showRank() + showRank(chanName) if sender == owner: if message.startswith("!say "): From 12344f9706822b761abeeb7456042e807f4bb466 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 5 Mar 2012 11:41:31 -0300 Subject: [PATCH 21/68] Add delrank command --- pomobot.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pomobot.py b/pomobot.py index 0ca4816..64bc4dd 100644 --- a/pomobot.py +++ b/pomobot.py @@ -66,6 +66,11 @@ def showRank(chan): bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) pos += 1 +def delrank(chan): + bot.query("delete from ranking", ()) + bot.commit() + saySuccess(chan, "Rank is clear") + bot.topic(chan, " Rank: ") def privmsg(sender, headers, message): if message.startswith("!help"): @@ -85,6 +90,9 @@ def privmsg(sender, headers, message): showRank(chanName) if sender == owner: + if message.startswith("!delrank"): + bot.identify(sender, delrank, (chanName,), authFailure, (headers[0], sender)) + if message.startswith("!say "): firstSpace = message[5:].find(" ") + 5 bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) From 82dd47df571fcf0af130acdc06424e6cb4ada802 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Tue, 6 Mar 2012 17:42:03 -0300 Subject: [PATCH 22/68] Implement pomo- to decrease pomodoro --- pomobot.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pomobot.py b/pomobot.py index 64bc4dd..0440028 100644 --- a/pomobot.py +++ b/pomobot.py @@ -56,6 +56,24 @@ def pomoadd(sender): updateRankTopic() +def pomominus(sender): + r = bot.query("select count(name) from ranking where name =?", (sender,)) + + for i in r: + count = i[0] + + # if theres someone qts++: else add it with 1 + if count == 1: + r = bot.query("select name, qts from ranking where name =?", (sender,)) + for i in r: + if i[1] > 0: + qt = i[1] - 1 + bot.query("update ranking set qts =? where name = ? ", (qt, sender)) + bot.commit() + updateRankTopic() + + + def showRank(chan): r = bot.query('select name, qts from ranking order by qts desc', ()) @@ -83,6 +101,9 @@ def privmsg(sender, headers, message): if message.startswith("!pomo+"): pomoadd(sender) + if message.startswith("!pomo-"): + pomominus(sender) + if message.startswith("!updaterank"): updateRankTopic() From 37dd5bfa754cbf92a90d0f606253f40520210492 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Wed, 7 Mar 2012 09:04:08 -0300 Subject: [PATCH 23/68] New method to check if user is already in --- pomobot.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pomobot.py b/pomobot.py index 0440028..d463f16 100644 --- a/pomobot.py +++ b/pomobot.py @@ -37,14 +37,18 @@ def updateRankTopic(): bot.topic(chanName, topic[:-2]) -def pomoadd(sender): +def __isOnDb(sender): r = bot.query("select count(name) from ranking where name =?", (sender,)) for i in r: count = i[0] + return count == 1 + +def pomoadd(sender): + # if theres someone qts++: else add it with 1 - if count == 1: + if __isOnDb(sender): r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: qt = i[1] + 1 @@ -57,13 +61,9 @@ def pomoadd(sender): updateRankTopic() def pomominus(sender): - r = bot.query("select count(name) from ranking where name =?", (sender,)) - for i in r: - count = i[0] - - # if theres someone qts++: else add it with 1 - if count == 1: + # if theres someone qts--: else ignore + if __isOnDb(sender): r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: if i[1] > 0: From 1dc9cb416056e2c824208ee2fcab96de910caf97 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Wed, 7 Mar 2012 09:06:40 -0300 Subject: [PATCH 24/68] Change which msg is going to topic after clear rank --- pomobot.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pomobot.py b/pomobot.py index d463f16..e6a48f4 100644 --- a/pomobot.py +++ b/pomobot.py @@ -72,8 +72,6 @@ def pomominus(sender): bot.commit() updateRankTopic() - - def showRank(chan): r = bot.query('select name, qts from ranking order by qts desc', ()) @@ -88,7 +86,7 @@ def delrank(chan): bot.query("delete from ranking", ()) bot.commit() saySuccess(chan, "Rank is clear") - bot.topic(chan, " Rank: ") + bot.topic(chan, " Rank is emptpy! Be the first: !pomo+ ") def privmsg(sender, headers, message): if message.startswith("!help"): From c258c4946651b41be05498723c58fb2a3c557f6b Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Fri, 9 Mar 2012 16:38:43 -0300 Subject: [PATCH 25/68] Stop bodering users --- pomobot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index e6a48f4..5512453 100644 --- a/pomobot.py +++ b/pomobot.py @@ -127,7 +127,6 @@ def privmsg(sender, headers, message): secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) bot.identify(sender, kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) else: - authFailure(sender, '') print "PRIVMSG: \"" + message + "\"" def actionmsg(sender, headers, message): From 0342658ffad3dea13843cb33cbdc71cb1e3bff73 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Thu, 5 Apr 2012 15:45:22 -0300 Subject: [PATCH 26/68] Delete user from the list if they got no pomodoro --- pomobot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pomobot.py b/pomobot.py index 5512453..6c6834a 100644 --- a/pomobot.py +++ b/pomobot.py @@ -71,6 +71,11 @@ def pomominus(sender): bot.query("update ranking set qts =? where name = ? ", (qt, sender)) bot.commit() updateRankTopic() + else: + bot.query("delete from ranking where name = ? ", (sender,)) + bot.commit() + updateRankTopic() + def showRank(chan): r = bot.query('select name, qts from ranking order by qts desc', ()) From 2bb7fd99e56f7e781decc25c9aa7ccc47add23ce Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 14 May 2012 14:43:50 -0300 Subject: [PATCH 27/68] Remove from ranking if has 0 --- pomobot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 6c6834a..db2d1e7 100644 --- a/pomobot.py +++ b/pomobot.py @@ -66,7 +66,7 @@ def pomominus(sender): if __isOnDb(sender): r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: - if i[1] > 0: + if i[1] - 1 > 0: qt = i[1] - 1 bot.query("update ranking set qts =? where name = ? ", (qt, sender)) bot.commit() @@ -139,6 +139,7 @@ def actionmsg(sender, headers, message): def endMOTD(sender, headers, message): bot.join(chanName) + updateRankTopic() # Main program begins here if __name__ == "__main__": From 18551cd827c2a01a42b168a3a8ec1fb8e44c96eb Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 14 May 2012 17:38:09 -0300 Subject: [PATCH 28/68] Better topic layout --- pomobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index db2d1e7..65b4645 100644 --- a/pomobot.py +++ b/pomobot.py @@ -30,9 +30,9 @@ def updateRankTopic(): r = bot.query('select name, qts from ranking order by qts desc', ()) pos = 1 - topic = "" + topic = "Pomodoro done: " for i in r: - topic += str(pos) + " - " + i[0] + ": " + str(i[1]) + " / " + topic += str(pos) + ". " + i[0] + " (" + str(i[1]) + ") | " pos += 1 bot.topic(chanName, topic[:-2]) From 0f5f9290164031916318d2572493c7d4d7b969b7 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 14 May 2012 17:55:55 -0300 Subject: [PATCH 29/68] Feature: !lastrank --- pomobot.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/pomobot.py b/pomobot.py index 65b4645..e8191e1 100644 --- a/pomobot.py +++ b/pomobot.py @@ -27,6 +27,11 @@ def identFail(): def updateRankTopic(): # rank for topic + + topic = createTopicRank() + bot.topic(chanName, topic) + +def createTopicRank(): r = bot.query('select name, qts from ranking order by qts desc', ()) pos = 1 @@ -35,7 +40,14 @@ def updateRankTopic(): topic += str(pos) + ". " + i[0] + " (" + str(i[1]) + ") | " pos += 1 - bot.topic(chanName, topic[:-2]) + return topic[:-2] + +def lastRank(sender, chan): + r = bot.query('select lastrank from lastranking', ()) + for i in r: + lastrank = i[0] + + bot.say(chan, "Last week's rank is: " + i[0]) def __isOnDb(sender): r = bot.query("select count(name) from ranking where name =?", (sender,)) @@ -89,9 +101,14 @@ def showRank(chan): def delrank(chan): bot.query("delete from ranking", ()) + + lastrank = createTopicRank() + + bot.query("update lastranking set lastrank = '" + lastrank + "'", ()) + bot.commit() - saySuccess(chan, "Rank is clear") - bot.topic(chan, " Rank is emptpy! Be the first: !pomo+ ") + bot.say(chan, "Last rank is save. New rank is clear!") + bot.topic(chan, " Be the first: !pomo+ ") def privmsg(sender, headers, message): if message.startswith("!help"): @@ -107,6 +124,9 @@ def privmsg(sender, headers, message): if message.startswith("!pomo-"): pomominus(sender) + if message.startswith("!lastrank"): + lastRank(sender, chanName) + if message.startswith("!updaterank"): updateRankTopic() From 53a6446361a91a0726b5b95bae0afa2ae5732e9a Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 14 May 2012 18:08:08 -0300 Subject: [PATCH 30/68] Update !help --- pomobot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pomobot.py b/pomobot.py index e8191e1..3429a8d 100644 --- a/pomobot.py +++ b/pomobot.py @@ -117,6 +117,8 @@ def privmsg(sender, headers, message): bot.say(chanName, "Help (makes the bot say this message) - Usage: \"!help \"") bot.say(chanName, "!rank tells the rank - Usage: \"!rank \"") bot.say(chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") + bot.say(chanName, "!pomo- Decrease one pomodoro to you - Usage: \"!pomo- \"") + bot.say(chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") if message.startswith("!pomo+"): pomoadd(sender) From b37bd61b054d090d601dd01b281a052eae222634 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 21 May 2012 10:11:25 -0300 Subject: [PATCH 31/68] Fix: should delete the rank after save it --- pomobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index 3429a8d..1e04a9d 100644 --- a/pomobot.py +++ b/pomobot.py @@ -100,13 +100,13 @@ def showRank(chan): pos += 1 def delrank(chan): - bot.query("delete from ranking", ()) lastrank = createTopicRank() bot.query("update lastranking set lastrank = '" + lastrank + "'", ()) - + bot.query("delete from ranking", ()) bot.commit() + bot.say(chan, "Last rank is save. New rank is clear!") bot.topic(chan, " Be the first: !pomo+ ") From 443fcae465a1c82d74ad78ad94ed64566ab35bc3 Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Wed, 23 May 2012 14:22:22 -0400 Subject: [PATCH 32/68] Respond lastrank in private --- pomobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 1e04a9d..ea76f64 100644 --- a/pomobot.py +++ b/pomobot.py @@ -47,7 +47,7 @@ def lastRank(sender, chan): for i in r: lastrank = i[0] - bot.say(chan, "Last week's rank is: " + i[0]) + bot.say(sender, "Last week's rank is: " + i[0]) def __isOnDb(sender): r = bot.query("select count(name) from ranking where name =?", (sender,)) From c2c091ba61095d6a99f23deb164e07a67ae876d0 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Tue, 26 Jun 2012 18:05:09 -0300 Subject: [PATCH 33/68] New feature pomocall --- pomobot.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/pomobot.py b/pomobot.py index ea76f64..5870215 100644 --- a/pomobot.py +++ b/pomobot.py @@ -32,11 +32,19 @@ def updateRankTopic(): bot.topic(chanName, topic) def createTopicRank(): - r = bot.query('select name, qts from ranking order by qts desc', ()) + pomo = bot.query('select name, qts from ranking order by qts desc', ()) + call = bot.query('select name, min from callrank order by min desc', ()) pos = 1 topic = "Pomodoro done: " - for i in r: + for i in pomo: + topic += str(pos) + ". " + i[0] + " (" + str(i[1]) + ") | " + pos += 1 + + topic = topic[:-2] + " - Call: " + + pos = 1 + for i in call: topic += str(pos) + ". " + i[0] + " (" + str(i[1]) + ") | " pos += 1 @@ -49,7 +57,7 @@ def lastRank(sender, chan): bot.say(sender, "Last week's rank is: " + i[0]) -def __isOnDb(sender): +def __isOnDbpomo(sender): r = bot.query("select count(name) from ranking where name =?", (sender,)) for i in r: @@ -57,10 +65,18 @@ def __isOnDb(sender): return count == 1 +def __isOnDbcall(sender): + r = bot.query("select count(name) from callrank where name =?", (sender,)) + + for i in r: + count = i[0] + + return count == 1 + def pomoadd(sender): # if theres someone qts++: else add it with 1 - if __isOnDb(sender): + if __isOnDbpomo(sender): r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: qt = i[1] + 1 @@ -72,10 +88,26 @@ def pomoadd(sender): updateRankTopic() +def pomocalladd(sender, minutes): + + if __isOnDbcall(sender): + r = bot.query("select name, min from callrank where name =?", (sender,)) + for i in r: + minutes = i[1] + int(minutes) + bot.query("update callrank set min =? where name = ? ", (minutes, sender)) + else: + print 'else' + print minutes + bot.query("insert into callrank (name, min) values(?,?)", (sender,minutes)) + + bot.commit() + + updateRankTopic() + def pomominus(sender): # if theres someone qts--: else ignore - if __isOnDb(sender): + if __isOnDbpomo(sender): r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: if i[1] - 1 > 0: @@ -88,6 +120,20 @@ def pomominus(sender): bot.commit() updateRankTopic() +def pomocallminus(sender, minutes): + + if __isOnDbcall(sender): + r = bot.query("select name, min from callrank where name =?", (sender,)) + for i in r: + if (i[1] - int(minutes)) > 0: + minutes = i[1] - int(minutes) + bot.query("update callrank set min =? where name = ? ", (minutes, sender)) + bot.commit() + updateRankTopic() + else: + bot.query("delete from callrank where name = ? ", (sender,)) + bot.commit() + updateRankTopic() def showRank(chan): r = bot.query('select name, qts from ranking order by qts desc', ()) @@ -120,6 +166,12 @@ def privmsg(sender, headers, message): bot.say(chanName, "!pomo- Decrease one pomodoro to you - Usage: \"!pomo- \"") bot.say(chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") + if message.startswith("!pomocall+ "): + pomocalladd(sender, message[10:].strip()) + + if message.startswith("!pomocall- "): + pomocallminus(sender, message[10:].strip()) + if message.startswith("!pomo+"): pomoadd(sender) From f813feb56282e241c4c2da181b25b255ca76ce4f Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Tue, 26 Jun 2012 17:08:23 -0400 Subject: [PATCH 34/68] Commit for each query --- pomobot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index ea76f64..87b4bbf 100644 --- a/pomobot.py +++ b/pomobot.py @@ -103,7 +103,9 @@ def delrank(chan): lastrank = createTopicRank() - bot.query("update lastranking set lastrank = '" + lastrank + "'", ()) + bot.query("update lastranking set lastrank = '"+ lastrank +"'", ()) + bot.commit() + bot.query("delete from ranking", ()) bot.commit() From 3438158f292be9adc97a1aec8c56ab3ab0ec1751 Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Thu, 28 Jun 2012 13:41:18 -0400 Subject: [PATCH 35/68] update help --- pomobot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pomobot.py b/pomobot.py index 69213f6..00aa8be 100644 --- a/pomobot.py +++ b/pomobot.py @@ -166,6 +166,8 @@ def privmsg(sender, headers, message): bot.say(chanName, "!rank tells the rank - Usage: \"!rank \"") bot.say(chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") bot.say(chanName, "!pomo- Decrease one pomodoro to you - Usage: \"!pomo- \"") + bot.say(chanName, "!pomocall+ Add minutes in a call to you - Usage: \"!pomocall+ 20 \" (for 20 min)") + bot.say(chanName, "!pomocall- Decrease minutes in a call to you - Usage: \"!pomo- 30 \" (for 30 min)") bot.say(chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") if message.startswith("!pomocall+ "): From 653e2e988cc004fdb6cb6126eeddc0a123181c2c Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Thu, 28 Jun 2012 13:43:04 -0400 Subject: [PATCH 36/68] Saving some space --- pomobot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pomobot.py b/pomobot.py index 00aa8be..01075d0 100644 --- a/pomobot.py +++ b/pomobot.py @@ -36,16 +36,16 @@ def createTopicRank(): call = bot.query('select name, min from callrank order by min desc', ()) pos = 1 - topic = "Pomodoro done: " + topic = "Pomodoro: " for i in pomo: - topic += str(pos) + ". " + i[0] + " (" + str(i[1]) + ") | " + topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 topic = topic[:-2] + " - Call: " pos = 1 for i in call: - topic += str(pos) + ". " + i[0] + " (" + str(i[1]) + ") | " + topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 return topic[:-2] From 91a0dd1a963b4ef9742dd3d83e233d6a61960b8c Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Thu, 28 Jun 2012 13:46:55 -0400 Subject: [PATCH 37/68] Fix help --- pomobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index 01075d0..8ec1b3f 100644 --- a/pomobot.py +++ b/pomobot.py @@ -166,8 +166,8 @@ def privmsg(sender, headers, message): bot.say(chanName, "!rank tells the rank - Usage: \"!rank \"") bot.say(chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") bot.say(chanName, "!pomo- Decrease one pomodoro to you - Usage: \"!pomo- \"") - bot.say(chanName, "!pomocall+ Add minutes in a call to you - Usage: \"!pomocall+ 20 \" (for 20 min)") - bot.say(chanName, "!pomocall- Decrease minutes in a call to you - Usage: \"!pomo- 30 \" (for 30 min)") + bot.say(chanName, "!pomocall+ Add minutes in a call - Usage: \"!pomocall+ 20 \" (for 20 min)") + bot.say(chanName, "!pomocall- Decrease minutes in a call - Usage: \"!pomocall- 30 \" (for 30 min)") bot.say(chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") if message.startswith("!pomocall+ "): From a900097d396c35d89e6684f7f7398c1089706b0c Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Fri, 29 Jun 2012 08:47:39 -0400 Subject: [PATCH 38/68] Show only the first 6 --- pomobot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pomobot.py b/pomobot.py index 8ec1b3f..c6bca27 100644 --- a/pomobot.py +++ b/pomobot.py @@ -40,6 +40,8 @@ def createTopicRank(): for i in pomo: topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 + if pos > 6: + break topic = topic[:-2] + " - Call: " @@ -47,6 +49,8 @@ def createTopicRank(): for i in call: topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 + if pos > 6: + break return topic[:-2] From f19fb51d183e1f99884126c9becec3971764629b Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Fri, 29 Jun 2012 08:48:18 -0400 Subject: [PATCH 39/68] Show rank with call too --- pomobot.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index c6bca27..1e7a77b 100644 --- a/pomobot.py +++ b/pomobot.py @@ -140,12 +140,20 @@ def pomocallminus(sender, minutes): updateRankTopic() def showRank(chan): - r = bot.query('select name, qts from ranking order by qts desc', ()) + pomo = bot.query('select name, qts from ranking order by qts desc', ()) + call = bot.query('select name, min from callrank order by min desc', ()) bot.say(chan, "Rank:") pos = 1 - for i in r: + for i in pomo: + bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) + pos += 1 + + bot.say(chan, "Call:") + + pos = 1 + for i in call: bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) pos += 1 From 8c52d4e2593a2817ea9880ba6f43f06e42c34f13 Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Fri, 29 Jun 2012 10:14:35 -0400 Subject: [PATCH 40/68] Make rank be sent to the pvt --- pomobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 1e7a77b..a8ddc29 100644 --- a/pomobot.py +++ b/pomobot.py @@ -201,7 +201,7 @@ def privmsg(sender, headers, message): updateRankTopic() if message.startswith("!rank"): - showRank(chanName) + showRank(sender) if sender == owner: if message.startswith("!delrank"): From 1b3a923648a81ba983631ded66f4fe46b9a92c49 Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Mon, 2 Jul 2012 08:47:29 -0400 Subject: [PATCH 41/68] Forget to delete the calls --- pomobot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pomobot.py b/pomobot.py index a8ddc29..088a178 100644 --- a/pomobot.py +++ b/pomobot.py @@ -167,6 +167,9 @@ def delrank(chan): bot.query("delete from ranking", ()) bot.commit() + bot.query("delete from callrank", ()) + bot.commit() + bot.say(chan, "Last rank is save. New rank is clear!") bot.topic(chan, " Be the first: !pomo+ ") From 17e44e2c81faff37adb52cdceb75aef1ee514284 Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Fri, 6 Jul 2012 08:43:36 -0400 Subject: [PATCH 42/68] New help option: !git --- pomobot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pomobot.py b/pomobot.py index 088a178..2bec5ec 100644 --- a/pomobot.py +++ b/pomobot.py @@ -184,6 +184,7 @@ def privmsg(sender, headers, message): bot.say(chanName, "!pomocall+ Add minutes in a call - Usage: \"!pomocall+ 20 \" (for 20 min)") bot.say(chanName, "!pomocall- Decrease minutes in a call - Usage: \"!pomocall- 30 \" (for 30 min)") bot.say(chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") + bot.say(chanName, "!git Shows the link to git repository - Usage: \"!git \"") if message.startswith("!pomocall+ "): pomocalladd(sender, message[10:].strip()) From 348ba555c862ecc04a8aaaa6115ae3c54ef2d06e Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Fri, 6 Jul 2012 09:43:14 -0400 Subject: [PATCH 43/68] Feature say !git --- pomobot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pomobot.py b/pomobot.py index 2bec5ec..cab801d 100644 --- a/pomobot.py +++ b/pomobot.py @@ -207,6 +207,9 @@ def privmsg(sender, headers, message): if message.startswith("!rank"): showRank(sender) + if message.startswith("!git"): + bot.say(chanName, "git clone http://pokgsa.ibm.com/~edusf/public/git/botpomodoro/") + if sender == owner: if message.startswith("!delrank"): bot.identify(sender, delrank, (chanName,), authFailure, (headers[0], sender)) From b4539258712ad6cfa5c2b82950c00353e56442bd Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Thu, 19 Jul 2012 13:53:00 -0300 Subject: [PATCH 44/68] Fix: pass highest position to be shown --- pomobot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pomobot.py b/pomobot.py index cab801d..e2fcee7 100644 --- a/pomobot.py +++ b/pomobot.py @@ -28,10 +28,10 @@ def identFail(): def updateRankTopic(): # rank for topic - topic = createTopicRank() + topic = createTopicRank(6) bot.topic(chanName, topic) -def createTopicRank(): +def createTopicRank(highpos = None): pomo = bot.query('select name, qts from ranking order by qts desc', ()) call = bot.query('select name, min from callrank order by min desc', ()) @@ -40,7 +40,7 @@ def createTopicRank(): for i in pomo: topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 - if pos > 6: + if not highpos and pos > highpos: break topic = topic[:-2] + " - Call: " @@ -49,7 +49,7 @@ def createTopicRank(): for i in call: topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 - if pos > 6: + if not highpos and pos > highpos: break return topic[:-2] From 402f9d531f2806c0e86b40f9fc5896bca68e9a95 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 23 Jul 2012 09:42:28 -0300 Subject: [PATCH 45/68] Last rank keep history --- pomobot.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pomobot.py b/pomobot.py index e2fcee7..e8bb98a 100644 --- a/pomobot.py +++ b/pomobot.py @@ -1,5 +1,6 @@ from ircbotframe import ircBot import sys +import datetime # Bot specific function definitions @@ -55,11 +56,11 @@ def createTopicRank(highpos = None): return topic[:-2] def lastRank(sender, chan): - r = bot.query('select lastrank from lastranking', ()) + r = bot.query('select week, lastrank from lastranking limit 1', ()) for i in r: - lastrank = i[0] + lastrank = i[1] - bot.say(sender, "Last week's rank is: " + i[0]) + bot.say(sender, "Last week's rank is: " + lastrank) def __isOnDbpomo(sender): r = bot.query("select count(name) from ranking where name =?", (sender,)) @@ -161,7 +162,7 @@ def delrank(chan): lastrank = createTopicRank() - bot.query("update lastranking set lastrank = '"+ lastrank +"'", ()) + bot.query("insert into lastranking (week, lastrank) values('" + datetime.date.today() + "', '"+ lastrank +"')", ()) bot.commit() bot.query("delete from ranking", ()) From ab4259a5d5b4e92dce41b6c8d4fcf82ee761b977 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 23 Jul 2012 09:44:32 -0300 Subject: [PATCH 46/68] Fix date to str --- pomobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index e8bb98a..6f17774 100644 --- a/pomobot.py +++ b/pomobot.py @@ -162,7 +162,7 @@ def delrank(chan): lastrank = createTopicRank() - bot.query("insert into lastranking (week, lastrank) values('" + datetime.date.today() + "', '"+ lastrank +"')", ()) + bot.query("insert into lastranking (week, lastrank) values('" + str(datetime.date.today()) + "', '"+ lastrank +"')", ()) bot.commit() bot.query("delete from ranking", ()) From d6d8aaf6fe987e2a72912393e15d1f855d0b3da4 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 23 Jul 2012 10:35:09 -0300 Subject: [PATCH 47/68] New parameter for lastrank --- pomobot.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index 6f17774..361109e 100644 --- a/pomobot.py +++ b/pomobot.py @@ -55,8 +55,14 @@ def createTopicRank(highpos = None): return topic[:-2] -def lastRank(sender, chan): - r = bot.query('select week, lastrank from lastranking limit 1', ()) +def lastRank(sender, chan, limit = 1): + sql = 'select week, lastrank from lastranking ' + + if limit: + sql = sql + 'limit ' + str(limit) + + r = bot.query(sql, ()) + for i in r: lastrank = i[1] From 3cc1891c4b2b0d38d6142a4bb767548d4663ca74 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Mon, 23 Jul 2012 10:44:59 -0300 Subject: [PATCH 48/68] Should show most recent first --- pomobot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 361109e..7052cd8 100644 --- a/pomobot.py +++ b/pomobot.py @@ -58,8 +58,11 @@ def createTopicRank(highpos = None): def lastRank(sender, chan, limit = 1): sql = 'select week, lastrank from lastranking ' + sql = sql + ' order by week desc ' + if limit: - sql = sql + 'limit ' + str(limit) + sql = sql + ' limit ' + str(limit) + r = bot.query(sql, ()) From a8ded9ad7521fc0098903f226002536b1fe54d0b Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Thu, 2 Aug 2012 16:47:18 -0300 Subject: [PATCH 49/68] Fix if --- pomobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index 7052cd8..58f2540 100644 --- a/pomobot.py +++ b/pomobot.py @@ -41,7 +41,7 @@ def createTopicRank(highpos = None): for i in pomo: topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 - if not highpos and pos > highpos: + if highpos is not None and pos > highpos: break topic = topic[:-2] + " - Call: " @@ -50,7 +50,7 @@ def createTopicRank(highpos = None): for i in call: topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" pos += 1 - if not highpos and pos > highpos: + if highpos is not None and pos > highpos: break return topic[:-2] From 859e728e679bf66eefc97159ee34faa4d42914e7 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Thu, 2 Aug 2012 11:07:31 -0300 Subject: [PATCH 50/68] Change bot to use class --- pomobot.py | 436 +++++++++++++++++++++++++++-------------------------- 1 file changed, 221 insertions(+), 215 deletions(-) diff --git a/pomobot.py b/pomobot.py index 58f2540..cc41243 100644 --- a/pomobot.py +++ b/pomobot.py @@ -4,249 +4,258 @@ # Bot specific function definitions -def authFailure(recipient, name): - bot.say(recipient, "You could not be identified to use comand") +class Pomobot(): -def quitSuccess(quitMessage): - bot.disconnect(quitMessage) - bot.stop() + def __init__(self, network, chanName, owner): + self.network = network + self.bot = ircBot(self.network, 6667, "PomodoroBot2", "An example bot written with the new IRC bot framework", "pomodoro.db") + self.bot.bind("PRIVMSG", self.privmsg) + self.bot.bind("ACTION", self.actionmsg) + self.bot.bind("376", self.endMOTD) + self.chanName = chanName + self.owner = owner -def joinSuccess(channel): - bot.join(channel) + def authFailure(self, recipient, name): + self.bot.say(recipient, "You could not be identified to use comand") -def saySuccess(channel, message): - bot.say(channel, message) + def quitSuccess(self, quitMessage): + self.bot.disconnect(quitMessage) + self.bot.stop() -def kickSuccess(nick, channel, reason): - bot.kick(nick, channel, reason) + def joinSuccess(self, channel): + self.bot.join(channel) -def identPass(): - pass + def saySuccess(self, channel, message): + self.bot.say(channel, message) -def identFail(): - pass + def kickSuccess(self, nick, channel, reason): + self.bot.kick(nick, channel, reason) -def updateRankTopic(): - # rank for topic + def identPass(self): + pass - topic = createTopicRank(6) - bot.topic(chanName, topic) + def identFail(self): + pass -def createTopicRank(highpos = None): - pomo = bot.query('select name, qts from ranking order by qts desc', ()) - call = bot.query('select name, min from callrank order by min desc', ()) + def updateRankTopic(self): + # rank for topic + topic = self.createTopicRank(6) + self.bot.topic(self.chanName, topic) - pos = 1 - topic = "Pomodoro: " - for i in pomo: - topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" - pos += 1 - if highpos is not None and pos > highpos: - break + def createTopicRank(self, highpos = None): + pomo = self.bot.query('select name, qts from ranking order by qts desc', ()) + call = self.bot.query('select name, min from callrank order by min desc', ()) - topic = topic[:-2] + " - Call: " + pos = 1 + topic = "Pomodoro: " - pos = 1 - for i in call: - topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" - pos += 1 - if highpos is not None and pos > highpos: - break + for i in pomo: + topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" + pos += 1 + if highpos is not None and pos > highpos: + break - return topic[:-2] + topic = topic[:-2] + " - Call: " -def lastRank(sender, chan, limit = 1): - sql = 'select week, lastrank from lastranking ' + pos = 1 + for i in call: + topic += str(pos) + "." + i[0] + " (" + str(i[1]) + ") |" + pos += 1 + if highpos is not None and pos > highpos: + break - sql = sql + ' order by week desc ' + return topic[:-2] - if limit: - sql = sql + ' limit ' + str(limit) + def lastRank(self, sender, chan, limit = 1): + sql = 'select week, lastrank from lastranking ' + sql = sql + ' order by week desc ' - r = bot.query(sql, ()) + if limit: + sql = sql + ' limit ' + str(limit) - for i in r: - lastrank = i[1] - bot.say(sender, "Last week's rank is: " + lastrank) + r = self.bot.query(sql, ()) -def __isOnDbpomo(sender): - r = bot.query("select count(name) from ranking where name =?", (sender,)) - - for i in r: - count = i[0] - - return count == 1 - -def __isOnDbcall(sender): - r = bot.query("select count(name) from callrank where name =?", (sender,)) - - for i in r: - count = i[0] + for i in r: + lastrank = i[1] - return count == 1 + self.bot.say(sender, "Last week's rank is: " + lastrank) -def pomoadd(sender): + def __isOnDbpomo(self, sender): + r = self.bot.query("select count(name) from ranking where name =?", (sender,)) - # if theres someone qts++: else add it with 1 - if __isOnDbpomo(sender): - r = bot.query("select name, qts from ranking where name =?", (sender,)) for i in r: - qt = i[1] + 1 - bot.query("update ranking set qts =? where name = ? ", (qt, sender)) - else: - bot.query("insert into ranking (name, qts) values(?,1)", (sender,)) + count = i[0] - bot.commit() + return count == 1 - updateRankTopic() + def __isOnDbcall(self, sender): + r = self.bot.query("select count(name) from callrank where name =?", (sender,)) -def pomocalladd(sender, minutes): - - if __isOnDbcall(sender): - r = bot.query("select name, min from callrank where name =?", (sender,)) for i in r: - minutes = i[1] + int(minutes) - bot.query("update callrank set min =? where name = ? ", (minutes, sender)) - else: - print 'else' - print minutes - bot.query("insert into callrank (name, min) values(?,?)", (sender,minutes)) - - bot.commit() + count = i[0] - updateRankTopic() + return count == 1 + + def pomoadd(self, sender): + + # if theres someone qts++: else add it with 1 + if self.__isOnDbpomo(sender): + r = self.bot.query("select name, qts from ranking where name =?", (sender,)) + for i in r: + qt = i[1] + 1 + self.bot.query("update ranking set qts =? where name = ? ", (qt, sender)) + else: + self.bot.query("insert into ranking (name, qts) values(?,1)", (sender,)) + + self.bot.commit() + + self.updateRankTopic() + + def pomocalladd(self, sender, minutes): + + if self.__isOnDbcall(sender): + r = self.bot.query("select name, min from callrank where name =?", (sender,)) + for i in r: + minutes = i[1] + int(minutes) + self.bot.query("update callrank set min =? where name = ? ", (minutes, sender)) + else: + self.bot.query("insert into callrank (name, min) values(?,?)", (sender,minutes)) -def pomominus(sender): + self.bot.commit() - # if theres someone qts--: else ignore - if __isOnDbpomo(sender): - r = bot.query("select name, qts from ranking where name =?", (sender,)) - for i in r: - if i[1] - 1 > 0: - qt = i[1] - 1 - bot.query("update ranking set qts =? where name = ? ", (qt, sender)) - bot.commit() - updateRankTopic() - else: - bot.query("delete from ranking where name = ? ", (sender,)) - bot.commit() - updateRankTopic() - -def pomocallminus(sender, minutes): - - if __isOnDbcall(sender): - r = bot.query("select name, min from callrank where name =?", (sender,)) - for i in r: - if (i[1] - int(minutes)) > 0: - minutes = i[1] - int(minutes) - bot.query("update callrank set min =? where name = ? ", (minutes, sender)) - bot.commit() - updateRankTopic() - else: - bot.query("delete from callrank where name = ? ", (sender,)) - bot.commit() - updateRankTopic() - -def showRank(chan): - pomo = bot.query('select name, qts from ranking order by qts desc', ()) - call = bot.query('select name, min from callrank order by min desc', ()) - - bot.say(chan, "Rank:") - - pos = 1 - for i in pomo: - bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) - pos += 1 - - bot.say(chan, "Call:") - - pos = 1 - for i in call: - bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) - pos += 1 - -def delrank(chan): - - lastrank = createTopicRank() - - bot.query("insert into lastranking (week, lastrank) values('" + str(datetime.date.today()) + "', '"+ lastrank +"')", ()) - bot.commit() - - bot.query("delete from ranking", ()) - bot.commit() - - bot.query("delete from callrank", ()) - bot.commit() - - bot.say(chan, "Last rank is save. New rank is clear!") - bot.topic(chan, " Be the first: !pomo+ ") - -def privmsg(sender, headers, message): - if message.startswith("!help"): - bot.say(chanName, "I am a bot.") - bot.say(chanName, "I have this commands:") - bot.say(chanName, "Help (makes the bot say this message) - Usage: \"!help \"") - bot.say(chanName, "!rank tells the rank - Usage: \"!rank \"") - bot.say(chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") - bot.say(chanName, "!pomo- Decrease one pomodoro to you - Usage: \"!pomo- \"") - bot.say(chanName, "!pomocall+ Add minutes in a call - Usage: \"!pomocall+ 20 \" (for 20 min)") - bot.say(chanName, "!pomocall- Decrease minutes in a call - Usage: \"!pomocall- 30 \" (for 30 min)") - bot.say(chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") - bot.say(chanName, "!git Shows the link to git repository - Usage: \"!git \"") - - if message.startswith("!pomocall+ "): - pomocalladd(sender, message[10:].strip()) - - if message.startswith("!pomocall- "): - pomocallminus(sender, message[10:].strip()) - - if message.startswith("!pomo+"): - pomoadd(sender) - - if message.startswith("!pomo-"): - pomominus(sender) - - if message.startswith("!lastrank"): - lastRank(sender, chanName) - - if message.startswith("!updaterank"): - updateRankTopic() - - if message.startswith("!rank"): - showRank(sender) - - if message.startswith("!git"): - bot.say(chanName, "git clone http://pokgsa.ibm.com/~edusf/public/git/botpomodoro/") - - if sender == owner: - if message.startswith("!delrank"): - bot.identify(sender, delrank, (chanName,), authFailure, (headers[0], sender)) - - if message.startswith("!say "): - firstSpace = message[5:].find(" ") + 5 - bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) - elif message.startswith("!quit"): - if len(message) > 6: - bot.identify(sender, quitSuccess, (message[6:],), authFailure, (headers[0], sender)) - else: - bot.identify(sender, quitSuccess, ("",), authFailure, (headers[0], sender)) - elif message.startswith("!join "): - bot.identify(sender, joinSuccess, (message[6:],), authFailure, (headers[0], sender)) - elif message.startswith("!kick "): - firstSpace = message[6:].find(" ") + 6 - secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) - bot.identify(sender, kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) - else: - print "PRIVMSG: \"" + message + "\"" + self.updateRankTopic() -def actionmsg(sender, headers, message): - print "An ACTION message was sent by " + sender + " with the headers " + headers + ". It says: \"" + sender + " " + message + def pomominus(self, sender): -def endMOTD(sender, headers, message): - bot.join(chanName) - updateRankTopic() + # if theres someone qts--: else ignore + if self.__isOnDbpomo(sender): + r = self.bot.query("select name, qts from ranking where name =?", (sender,)) + for i in r: + if i[1] - 1 > 0: + qt = i[1] - 1 + self.bot.query("update ranking set qts =? where name = ? ", (qt, sender)) + self.bot.commit() + self.updateRankTopic() + else: + self.bot.query("delete from ranking where name = ? ", (sender,)) + self.bot.commit() + self.updateRankTopic() + + def pomocallminus(self, sender, minutes): + + if self.__isOnDbcall(sender): + r = self.bot.query("select name, min from callrank where name =?", (sender,)) + for i in r: + if (i[1] - int(minutes)) > 0: + minutes = i[1] - int(minutes) + self.bot.query("update callrank set min =? where name = ? ", (minutes, sender)) + self.bot.commit() + self.updateRankTopic() + else: + self.bot.query("delete from callrank where name = ? ", (sender,)) + self.bot.commit() + self.updateRankTopic() + + def showRank(self, chan): + pomo = self.bot.query('select name, qts from ranking order by qts desc', ()) + call = self.bot.query('select name, min from callrank order by min desc', ()) + + self.bot.say(chan, "Rank:") + + pos = 1 + for i in pomo: + self.bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) + pos += 1 + + self.bot.say(chan, "Call:") + + pos = 1 + for i in call: + self.bot.say(chan, str(pos) + " - " + i[0] + ": " + str(i[1])) + pos += 1 + + def delrank(self, chan): + + lastrank = self.createTopicRank() + + bot.query("insert into lastranking (week, lastrank) values('" + str(datetime.date.today()) + "', '"+ lastrank +"')", ()) + self.bot.commit() + + self.bot.query("delete from ranking", ()) + self.bot.commit() + + self.bot.query("delete from callrank", ()) + self.bot.commit() + + self.bot.say(chan, "Last rank is save. New rank is clear!") + self.bot.topic(chan, " Be the first: !pomo+ ") + + def privmsg(self, sender, headers, message): + if message.startswith("!help"): + self.bot.say(self.chanName, "I am a bot.") + self.bot.say(self.chanName, "I have this commands:") + self.bot.say(self.chanName, "Help (makes the bot say this message) - Usage: \"!help \"") + self.bot.say(self.chanName, "!rank tells the rank - Usage: \"!rank \"") + self.bot.say(self.chanName, "!pomo+ Add one pomodoro to you - Usage: \"!pomo+ \"") + self.bot.say(self.chanName, "!pomo- Decrease one pomodoro to you - Usage: \"!pomo- \"") + self.bot.say(self.chanName, "!pomocall+ Add minutes in a call - Usage: \"!pomocall+ 20 \" (for 20 min)") + self.bot.say(self.chanName, "!pomocall- Decrease minutes in a call - Usage: \"!pomocall- 30 \" (for 30 min)") + self.bot.say(self.chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") + self.bot.say(self.chanName, "!git Shows the link to git repository - Usage: \"!git \"") + + if message.startswith("!pomocall+ "): + self.pomocalladd(sender, message[10:].strip()) + + if message.startswith("!pomocall- "): + self.pomocallminus(sender, message[10:].strip()) + + if message.startswith("!pomo+"): + self.pomoadd(sender) + + if message.startswith("!pomo-"): + self.pomominus(sender) + + if message.startswith("!lastrank"): + self.lastRank(sender, chanName) + + if message.startswith("!updaterank"): + self.updateRankTopic() + + if message.startswith("!rank"): + self.showRank(sender) + + if message.startswith("!git"): + self.bot.say(self.chanName, "git clone http://pokgsa.ibm.com/~edusf/public/git/botpomodoro/") + + if sender == self.owner: + if message.startswith("!delrank"): + self.bot.identify(sender, delrank, (self.chanName,), authFailure, (headers[0], sender)) + + if message.startswith("!say "): + firstSpace = message[5:].find(" ") + 5 + self.bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) + elif message.startswith("!quit"): + if len(message) > 6: + self.bot.identify(sender, self.quitSuccess, (message[6:],), self.authFailure, (headers[0], sender)) + else: + self.bot.identify(sender, self.quitSuccess, ("",), self.authFailure, (headers[0], sender)) + elif message.startswith("!join "): + self.bot.identify(sender, self.joinSuccess, (message[6:],), self.authFailure, (headers[0], sender)) + elif message.startswith("!kick "): + firstSpace = message[6:].find(" ") + 6 + secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) + self.bot.identify(sender, self.kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) + else: + print "PRIVMSG: \"" + message + "\"" + + def actionmsg(self, sender, headers, message): + print "An ACTION message was sent by " + sender + " with the headers " + headers + ". It says: \"" + sender + " " + message + + def endMOTD(self, sender, headers, message): + self.bot.join(self.chanName) + self.updateRankTopic() # Main program begins here if __name__ == "__main__": @@ -254,12 +263,9 @@ def endMOTD(sender, headers, message): owner = sys.argv[2] chanName = "#" + sys.argv[3] network = sys.argv[1] - bot = ircBot(network, 6667, "PomodoroBot", "An example bot written with the new IRC bot framework", "pomodoro.db") - bot.bind("PRIVMSG", privmsg) - bot.bind("ACTION", actionmsg) - bot.bind("376", endMOTD) - bot.connect() - bot.run() + bot = Pomobot(network, chanName, owner) + bot.bot.connect() + bot.bot.run() else: print "Usage: python examplebot.py " From 05f1fbf7f3ddeb4cc82083b5090df11d5b3a497b Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Fri, 3 Aug 2012 14:30:21 -0300 Subject: [PATCH 51/68] Changes to enable testing --- __init__.py | 0 ircbotframe.py | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 __init__.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ircbotframe.py b/ircbotframe.py index 1fd44ad..162c8da 100644 --- a/ircbotframe.py +++ b/ircbotframe.py @@ -75,8 +75,8 @@ def getLine(self): return str(line) class ircBot: - def __init__(self, network, port, name, description, db): - self.keepGoing = True + def __init__(self, network, port, name, description, db, debug = True, keepGoing = True): + self.keepGoing = keepGoing self.name = name self.desc = description self.network = network @@ -85,7 +85,7 @@ def __init__(self, network, port, name, description, db): self.identifyLock = False self.serverName = "" self.binds = [] - self.debug = True + self.debug = debug self.__db = db # PRIVATE FUNCTIONS From 850bd219981cfa6f32b7e7dbe55355853c1f4a41 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Ferreira Date: Fri, 3 Aug 2012 14:30:45 -0300 Subject: [PATCH 52/68] New test cases (need much more) --- test/test_bot.py | 180 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 test/test_bot.py diff --git a/test/test_bot.py b/test/test_bot.py new file mode 100644 index 0000000..d714a61 --- /dev/null +++ b/test/test_bot.py @@ -0,0 +1,180 @@ +import unittest +import sys + +import threading +import sqlite3 as sql + + +sys.path.insert(0, '../') + +from ircbotframe import ircBot +from pomobot import Pomobot + +class fake_ircOutputBuffer: + # Delays consecutive messages by at least 1 second. + # This prevents the bot spamming the IRC server. + def __init__(self): + self.waiting = False + self.queue = [] + self.error = False + self.returnMSG = None + def __pop(self): + if len(self.queue) == 0: + self.waiting = False + else: + self.sendImmediately(self.queue[0]) + self.queue = self.queue[1:] + #self.__startPopTimer() + def __startPopTimer(self): + self.timer = threading.Timer(1, self.__pop) + self.timer.start() + def sendBuffered(self, string): + # Sends the given string after the rest of the messages in the buffer. + # There is a 1 second gap between each message. + if self.waiting: + self.queue.append(string) + else: + self.waiting = True + self.sendImmediately(string) + #self.__startPopTimer() + def sendImmediately(self, string): + # Sends the given string without buffering. + if not self.error: + try: + #self.irc.send(bytes(string) + b"\r\n") + self.returnMSG = string + except socket.error, msg: + self.error = True + print "Output error", msg + print "Was sending \"" + string + "\"" + def isInError(self): + return self.error + +class DB: + def __init__(self, db): + + self.connection = sql.connect(db) + self.cursor = self.connection.cursor() + + def query(self, querymsg, param = ()): + self.cursor.execute(querymsg, param) + self.connection.commit() + + +class FrameTest(unittest.TestCase): + + def setUp(self): + self.chan = "#Pomodoro" + self.user = "user" + self.bot = ircBot("Bluenet", 6667, "PomodoroBot", "An example bot written with the new IRC bot framework", "pomodoro.db") + self.bot.outBuf = fake_ircOutputBuffer() + + def tearDown(self): + pass + + def testBotsay(self): + msg = 'Msg Test' + self.bot.say(self.chan, msg) + self.assertEqual('PRIVMSG #Pomodoro :' + msg , self.bot.outBuf.returnMSG) + + def testBotTopic(self): + topic = 'Setando um topico de test' + self.bot.topic(self.chan, topic) + self.assertEqual('TOPIC #Pomodoro :' + topic, self.bot.outBuf.returnMSG) + +class PomoboTest(unittest.TestCase): + + def setUp(self): + self.bot = Pomobot("Bluenet", '#Pomodoro', 'camponez') + self.db = DB('pomodoro.db') + self.bot.bot.outBuf = fake_ircOutputBuffer() + self.startDB() + self.populateDB() + + def startDB(self): + self.db.query("CREATE TABLE callrank(name varchar(20), min int);") + self.db.query("CREATE TABLE lastranking(lastrank varchar(300), week 'varchar(11)');") + self.db.query("CREATE TABLE ranking (name text, qts int);") + + def populateDB(self): + ''' Populating the DB''' + + self.db.query("INSERT INTO callrank VALUES('camponez',9);") + + self.db.query("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr \ + (22) |2.tuliom (18) |3.cascardo (12) |4.sene (11) \ + |5.camponez (6) - Call: 1.cascardo (245)|2.maurosr (105) \ + |3.camponez (70)', '2012-05-10');") + + self.db.query("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr \ + (20) |2.tuliom (18) |3.cascardo (12) |4.sene (12) \ + |5.camponez (6) - Call: 1.cascardo (245)|2.maurosr (105) \ + |3.camponez (70)','2012-05-17');") + + self.db.query("INSERT INTO ranking VALUES('T3',3);") + self.db.query("INSERT INTO ranking VALUES('maurosr',6);") + self.db.query("INSERT INTO ranking VALUES('sene',3);") + self.db.query("INSERT INTO ranking VALUES('camponez',5);") + + + def tearDown(self): + self.db.query("drop table callrank;") + self.db.query("drop table lastranking;") + self.db.query("drop table ranking;") + + def testCreateTopic(self): + '''Test if topic is created''' + + self.assertEqual('Pomodoro: 1.maurosr (6) - Call: 1.camponez (9)', + self.bot.createTopicRank(1)) + + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (9)', + self.bot.createTopicRank()) + + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) - Call: 1.camponez (9)', + self.bot.createTopicRank(2)) + + def testAddPomo(self): + '''Test adding pomo''' + + self.bot.pomoadd('maurosr') + self.assertEqual('Pomodoro: 1.maurosr (7) - Call: 1.camponez (9)', + self.bot.createTopicRank(1)) + + self.bot.pomoadd('maurosr') + self.assertEqual('Pomodoro: 1.maurosr (8) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (9)', + self.bot.createTopicRank()) + + def testPomoMinus(self): + '''Test pomo minus''' + + self.bot.pomominus('maurosr') + self.assertEqual('Pomodoro: 1.maurosr (5) - Call: 1.camponez (9)', + self.bot.createTopicRank(1)) + + self.bot.pomominus('T3') + self.assertEqual('Pomodoro: 1.maurosr (5) |2.camponez (5) |3.sene (3) |4.T3 (2) - Call: 1.camponez (9)', + self.bot.createTopicRank()) + + def testAddCall(self): + '''Test add call''' + + self.bot.pomocalladd('camponez', 30) + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (39)', + self.bot.createTopicRank()) + + self.bot.pomocalladd('maurosr', 10) + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (39) |2.maurosr (10)', + self.bot.createTopicRank()) + + def testCallMinus(self): + '''Test call minus''' + + self.bot.pomocallminus('camponez', 4) + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (5)', + self.bot.createTopicRank()) + + self.bot.pomocalladd('maurosr', 10) + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.maurosr (10) |2.camponez (5)', + self.bot.createTopicRank()) + From e3ae8826c04bd3e92bc45d93e2812bede9a694f8 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 6 Aug 2012 09:02:17 -0300 Subject: [PATCH 53/68] Fix: typo --- pomobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index cc41243..693f2a0 100644 --- a/pomobot.py +++ b/pomobot.py @@ -189,7 +189,7 @@ def delrank(self, chan): self.bot.query("delete from callrank", ()) self.bot.commit() - self.bot.say(chan, "Last rank is save. New rank is clear!") + self.bot.say(chan, "Last rank is saved. New rank is clear!") self.bot.topic(chan, " Be the first: !pomo+ ") def privmsg(self, sender, headers, message): From 951aa15c616d6dc554da6556ea51079ee448284f Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 6 Aug 2012 09:38:56 -0300 Subject: [PATCH 54/68] Add new test --- test/test_bot.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_bot.py b/test/test_bot.py index d714a61..f924cd4 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -167,6 +167,10 @@ def testAddCall(self): self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (39) |2.maurosr (10)', self.bot.createTopicRank()) + self.bot.pomocalladd('sene', 20) + self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (39) |2.sene (20) |3.maurosr (10)', + self.bot.createTopicRank()) + def testCallMinus(self): '''Test call minus''' From 59f4fc0b760933d5d5a5fd930d53f4d1a8c19741 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 6 Aug 2012 10:26:14 -0300 Subject: [PATCH 55/68] Better forbiden message feedback --- pomobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 693f2a0..410e8bf 100644 --- a/pomobot.py +++ b/pomobot.py @@ -248,7 +248,7 @@ def privmsg(self, sender, headers, message): secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) self.bot.identify(sender, self.kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) else: - print "PRIVMSG: \"" + message + "\"" + print "PRIVMSG: \"You can not use command: " + message + "\"" def actionmsg(self, sender, headers, message): print "An ACTION message was sent by " + sender + " with the headers " + headers + ". It says: \"" + sender + " " + message From c3e0c9365922ba059a0ac11db412a5c7bb32dc1f Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 6 Aug 2012 10:27:45 -0300 Subject: [PATCH 56/68] Update ctags --- tags | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/tags b/tags index 3bcce0b..2ebae1d 100644 --- a/tags +++ b/tags @@ -3,46 +3,105 @@ !_TAG_PROGRAM_AUTHOR Darren Hiebert /dhiebert@users.sourceforge.net/ !_TAG_PROGRAM_NAME Exuberant Ctags // !_TAG_PROGRAM_URL http://ctags.sourceforge.net /official site/ -!_TAG_PROGRAM_VERSION 5.9~svn20110310 // +!_TAG_PROGRAM_VERSION 5.8 // +DB test/test_bot.py /^class DB:$/;" c +FrameTest test/test_bot.py /^class FrameTest(unittest.TestCase):$/;" c +PomoboTest test/test_bot.py /^class PomoboTest(unittest.TestCase):$/;" c +Pomobot pomobot.py /^class Pomobot():$/;" c +Pomobot test/test_bot.py /^from pomobot import Pomobot$/;" i __callBind ircbotframe.py /^ def __callBind(self, msgtype, sender, headers, message):$/;" m class:ircBot file: +__dbConnect ircbotframe.py /^ def __dbConnect(self):$/;" m class:ircBot file: __debugPrint ircbotframe.py /^ def __debugPrint(self, s):$/;" m class:ircBot file: __identAccept ircbotframe.py /^ def __identAccept(self, nick):$/;" m class:ircBot file: __identReject ircbotframe.py /^ def __identReject(self, nick):$/;" m class:ircBot file: __init__ ircbotframe.py /^ def __init__(self, irc):$/;" m class:ircInputBuffer __init__ ircbotframe.py /^ def __init__(self, irc):$/;" m class:ircOutputBuffer -__init__ ircbotframe.py /^ def __init__(self, network, port, name, description):$/;" m class:ircBot +__init__ ircbotframe.py /^ def __init__(self, network, port, name, description, db, debug = True, keepGoing = True):$/;" m class:ircBot +__init__ pomobot.py /^ def __init__(self, network, chanName, owner):$/;" m class:Pomobot +__init__ test/test_bot.py /^ def __init__(self):$/;" m class:fake_ircOutputBuffer +__init__ test/test_bot.py /^ def __init__(self, db):$/;" m class:DB +__isOnDbcall pomobot.py /^ def __isOnDbcall(self, sender):$/;" m class:Pomobot file: +__isOnDbpomo pomobot.py /^ def __isOnDbpomo(self, sender):$/;" m class:Pomobot file: __pop ircbotframe.py /^ def __pop(self):$/;" m class:ircOutputBuffer file: +__pop test/test_bot.py /^ def __pop(self):$/;" m class:fake_ircOutputBuffer file: __processLine ircbotframe.py /^ def __processLine(self, line):$/;" m class:ircBot file: __recv ircbotframe.py /^ def __recv(self):$/;" m class:ircInputBuffer file: __startPopTimer ircbotframe.py /^ def __startPopTimer(self):$/;" m class:ircOutputBuffer file: -actionmsg examplebot.py /^def actionmsg(sender, headers, message):$/;" f -authFailure examplebot.py /^def authFailure(recipient, name):$/;" f +__startPopTimer test/test_bot.py /^ def __startPopTimer(self):$/;" m class:fake_ircOutputBuffer file: +actionmsg pomobot.py /^ def actionmsg(self, sender, headers, message):$/;" m class:Pomobot +addpomo addpomo.py /^def addpomo(name):$/;" f +authFailure pomobot.py /^ def authFailure(self, recipient, name):$/;" m class:Pomobot ban ircbotframe.py /^ def ban(self, banMask, channel, reason):$/;" m class:ircBot bind ircbotframe.py /^ def bind(self, msgtype, callback):$/;" m class:ircBot +commit ircbotframe.py /^ def commit(self):$/;" m class:ircBot connect ircbotframe.py /^ def connect(self):$/;" m class:ircBot +createTopicRank pomobot.py /^ def createTopicRank(self, highpos = None):$/;" m class:Pomobot +datetime pomobot.py /^import datetime$/;" i debug ircbotframe.py /^ def debug(self, state):$/;" m class:ircBot +delrank pomobot.py /^ def delrank(self, chan):$/;" m class:Pomobot disconnect ircbotframe.py /^ def disconnect(self, qMessage):$/;" m class:ircBot -endMOTD examplebot.py /^def endMOTD(sender, headers, message):$/;" f +endMOTD pomobot.py /^ def endMOTD(self, sender, headers, message):$/;" m class:Pomobot +fake_ircOutputBuffer test/test_bot.py /^class fake_ircOutputBuffer:$/;" c getLine ircbotframe.py /^ def getLine(self):$/;" m class:ircInputBuffer -identFail examplebot.py /^def identFail():$/;" f -identPass examplebot.py /^def identPass():$/;" f +identFail pomobot.py /^ def identFail(self):$/;" m class:Pomobot +identPass pomobot.py /^ def identPass(self):$/;" m class:Pomobot identify ircbotframe.py /^ def identify(self, nick, approvedFunc, approvedParams, deniedFunc, deniedParams):$/;" m class:ircBot ircBot ircbotframe.py /^class ircBot:$/;" c +ircBot pomobot.py /^from ircbotframe import ircBot$/;" i +ircBot test/test_bot.py /^from ircbotframe import ircBot$/;" i ircInputBuffer ircbotframe.py /^class ircInputBuffer:$/;" c ircOutputBuffer ircbotframe.py /^class ircOutputBuffer:$/;" c isInError ircbotframe.py /^ def isInError(self):$/;" m class:ircOutputBuffer +isInError test/test_bot.py /^ def isInError(self):$/;" m class:fake_ircOutputBuffer join ircbotframe.py /^ def join(self, channel):$/;" m class:ircBot -joinSuccess examplebot.py /^def joinSuccess(channel):$/;" f +joinSuccess pomobot.py /^ def joinSuccess(self, channel):$/;" m class:Pomobot kick ircbotframe.py /^ def kick(self, nick, channel, reason):$/;" m class:ircBot -kickSuccess examplebot.py /^def kickSuccess(nick, channel, reason):$/;" f -privmsg examplebot.py /^def privmsg(sender, headers, message):$/;" f -quitSuccess examplebot.py /^def quitSuccess(quitMessage):$/;" f +kickSuccess pomobot.py /^ def kickSuccess(self, nick, channel, reason):$/;" m class:Pomobot +lastRank pomobot.py /^ def lastRank(self, sender, chan, limit = 1):$/;" m class:Pomobot +pomoadd pomobot.py /^ def pomoadd(self, sender):$/;" m class:Pomobot +pomocalladd pomobot.py /^ def pomocalladd(self, sender, minutes):$/;" m class:Pomobot +pomocallminus pomobot.py /^ def pomocallminus(self, sender, minutes):$/;" m class:Pomobot +pomominus pomobot.py /^ def pomominus(self, sender):$/;" m class:Pomobot +populateDB test/test_bot.py /^ def populateDB(self):$/;" m class:PomoboTest +privmsg pomobot.py /^ def privmsg(self, sender, headers, message):$/;" m class:Pomobot +query ircbotframe.py /^ def query(self, querymsg, param):$/;" m class:ircBot +query test/test_bot.py /^ def query(self, querymsg, param = ()):$/;" m class:DB +quitSuccess pomobot.py /^ def quitSuccess(self, quitMessage):$/;" m class:Pomobot +re ircbotframe.py /^import re$/;" i reconnect ircbotframe.py /^ def reconnect(self):$/;" m class:ircBot run ircbotframe.py /^ def run(self):$/;" m class:ircBot say ircbotframe.py /^ def say(self, recipient, message):$/;" m class:ircBot -saySuccess examplebot.py /^def saySuccess(channel, message):$/;" f +saySuccess pomobot.py /^ def saySuccess(self, channel, message):$/;" m class:Pomobot send ircbotframe.py /^ def send(self, string):$/;" m class:ircBot sendBuffered ircbotframe.py /^ def sendBuffered(self, string):$/;" m class:ircOutputBuffer +sendBuffered test/test_bot.py /^ def sendBuffered(self, string):$/;" m class:fake_ircOutputBuffer sendImmediately ircbotframe.py /^ def sendImmediately(self, string):$/;" m class:ircOutputBuffer +sendImmediately test/test_bot.py /^ def sendImmediately(self, string):$/;" m class:fake_ircOutputBuffer +setUp test/test_bot.py /^ def setUp(self):$/;" m class:FrameTest +setUp test/test_bot.py /^ def setUp(self):$/;" m class:PomoboTest +showRank pomobot.py /^ def showRank(self, chan):$/;" m class:Pomobot +socket ircbotframe.py /^import socket$/;" i +sql ircbotframe.py /^import sqlite3 as sql$/;" i +sql test/test_bot.py /^import sqlite3 as sql$/;" i +sqlite3 addpomo.py /^import sqlite3$/;" i +startDB test/test_bot.py /^ def startDB(self):$/;" m class:PomoboTest stop ircbotframe.py /^ def stop(self):$/;" m class:ircBot +sys addpomo.py /^import sys$/;" i +sys pomobot.py /^import sys$/;" i +sys test/test_bot.py /^import sys$/;" i +tearDown test/test_bot.py /^ def tearDown(self):$/;" m class:FrameTest +tearDown test/test_bot.py /^ def tearDown(self):$/;" m class:PomoboTest +testAddCall test/test_bot.py /^ def testAddCall(self):$/;" m class:PomoboTest +testAddPomo test/test_bot.py /^ def testAddPomo(self):$/;" m class:PomoboTest +testBotTopic test/test_bot.py /^ def testBotTopic(self):$/;" m class:FrameTest +testBotsay test/test_bot.py /^ def testBotsay(self):$/;" m class:FrameTest +testCallMinus test/test_bot.py /^ def testCallMinus(self):$/;" m class:PomoboTest +testCreateTopic test/test_bot.py /^ def testCreateTopic(self):$/;" m class:PomoboTest +testPomoMinus test/test_bot.py /^ def testPomoMinus(self):$/;" m class:PomoboTest +threading ircbotframe.py /^import threading$/;" i +threading test/test_bot.py /^import threading$/;" i +time ircbotframe.py /^import time$/;" i +topic ircbotframe.py /^ def topic(self, channel, msg):$/;" m class:ircBot unban ircbotframe.py /^ def unban(self, banMask, channel):$/;" m class:ircBot +unittest test/test_bot.py /^import unittest$/;" i +updateRankTopic pomobot.py /^ def updateRankTopic(self):$/;" m class:Pomobot From 737da753858890810680861fa831514d1b7f4434 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Fri, 10 Aug 2012 17:37:22 -0300 Subject: [PATCH 57/68] Botnick by console --- pomobot.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pomobot.py b/pomobot.py index 410e8bf..fc21e8a 100644 --- a/pomobot.py +++ b/pomobot.py @@ -6,9 +6,9 @@ class Pomobot(): - def __init__(self, network, chanName, owner): + def __init__(self, network, chanName, owner, botnick): self.network = network - self.bot = ircBot(self.network, 6667, "PomodoroBot2", "An example bot written with the new IRC bot framework", "pomodoro.db") + self.bot = ircBot(self.network, 6667, botnick, "An example bot written with the new IRC bot framework", "pomodoro.db") self.bot.bind("PRIVMSG", self.privmsg) self.bot.bind("ACTION", self.actionmsg) self.bot.bind("376", self.endMOTD) @@ -259,13 +259,14 @@ def endMOTD(self, sender, headers, message): # Main program begins here if __name__ == "__main__": - if len(sys.argv) == 4: + if len(sys.argv) == 5: owner = sys.argv[2] - chanName = "#" + sys.argv[3] + chanName = "#" + sys.argv[4] network = sys.argv[1] - bot = Pomobot(network, chanName, owner) + botnick = sys.argv[3] + bot = Pomobot(network, chanName, owner, botnick) bot.bot.connect() bot.bot.run() else: - print "Usage: python examplebot.py " + print "Usage: python examplebot.py " From 4c1f624c60974290bd617e2da1fe0b6758be3f2f Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Tue, 14 Aug 2012 23:55:18 -0400 Subject: [PATCH 58/68] Fix: wrong call method --- pomobot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pomobot.py b/pomobot.py index fc21e8a..360d6a3 100644 --- a/pomobot.py +++ b/pomobot.py @@ -231,11 +231,11 @@ def privmsg(self, sender, headers, message): if sender == self.owner: if message.startswith("!delrank"): - self.bot.identify(sender, delrank, (self.chanName,), authFailure, (headers[0], sender)) + self.bot.identify(sender, self.delrank, (self.chanName,), authFailure, (headers[0], sender)) if message.startswith("!say "): firstSpace = message[5:].find(" ") + 5 - self.bot.identify(sender, saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) + self.bot.identify(sender, self.saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) elif message.startswith("!quit"): if len(message) > 6: self.bot.identify(sender, self.quitSuccess, (message[6:],), self.authFailure, (headers[0], sender)) From 8150cce9786a5561da03c068a108749f33d4681e Mon Sep 17 00:00:00 2001 From: "edusf@br.ibm.com" Date: Mon, 3 Sep 2012 08:08:00 -0400 Subject: [PATCH 59/68] Fix: more wrong call method --- pomobot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pomobot.py b/pomobot.py index 360d6a3..90a0ed7 100644 --- a/pomobot.py +++ b/pomobot.py @@ -180,7 +180,7 @@ def delrank(self, chan): lastrank = self.createTopicRank() - bot.query("insert into lastranking (week, lastrank) values('" + str(datetime.date.today()) + "', '"+ lastrank +"')", ()) + self.bot.query("insert into lastranking (week, lastrank) values('" + str(datetime.date.today()) + "', '"+ lastrank +"')", ()) self.bot.commit() self.bot.query("delete from ranking", ()) @@ -231,11 +231,11 @@ def privmsg(self, sender, headers, message): if sender == self.owner: if message.startswith("!delrank"): - self.bot.identify(sender, self.delrank, (self.chanName,), authFailure, (headers[0], sender)) + self.bot.identify(sender, self.delrank, (self.chanName,), self.authFailure, (headers[0], sender)) if message.startswith("!say "): firstSpace = message[5:].find(" ") + 5 - self.bot.identify(sender, self.saySuccess, (message[5:firstSpace], message[firstSpace+1:]), authFailure, (headers[0], sender)) + self.bot.identify(sender, self.saySuccess, (message[5:firstSpace], message[firstSpace+1:]), self.authFailure, (headers[0], sender)) elif message.startswith("!quit"): if len(message) > 6: self.bot.identify(sender, self.quitSuccess, (message[6:],), self.authFailure, (headers[0], sender)) From da11a99c92dc6fb6b8ae3fe6110740a280c65091 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Tue, 9 Oct 2012 15:56:46 -0300 Subject: [PATCH 60/68] Now bot nick is a paramater --- test/test_bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_bot.py b/test/test_bot.py index f924cd4..3f7cf40 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -85,7 +85,7 @@ def testBotTopic(self): class PomoboTest(unittest.TestCase): def setUp(self): - self.bot = Pomobot("Bluenet", '#Pomodoro', 'camponez') + self.bot = Pomobot("Bluenet", '#Pomodoro', 'camponez', "Pomobot2") self.db = DB('pomodoro.db') self.bot.bot.outBuf = fake_ircOutputBuffer() self.startDB() From 11032b46486851110ee0c0ba57713b029abd7ee6 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Tue, 9 Oct 2012 16:06:00 -0300 Subject: [PATCH 61/68] No need to use pytest Just type python test_file.py --- test/test_bot.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/test_bot.py b/test/test_bot.py index 3f7cf40..a3f047a 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -60,7 +60,6 @@ def query(self, querymsg, param = ()): self.cursor.execute(querymsg, param) self.connection.commit() - class FrameTest(unittest.TestCase): def setUp(self): @@ -116,7 +115,6 @@ def populateDB(self): self.db.query("INSERT INTO ranking VALUES('sene',3);") self.db.query("INSERT INTO ranking VALUES('camponez',5);") - def tearDown(self): self.db.query("drop table callrank;") self.db.query("drop table lastranking;") @@ -182,3 +180,9 @@ def testCallMinus(self): self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.maurosr (10) |2.camponez (5)', self.bot.createTopicRank()) + +def main(): + unittest.main() + +if __name__ == '__main__': + main() From 1e460db4a754f6282e226dd37d44e615450ab84e Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Fri, 19 Oct 2012 17:28:43 -0300 Subject: [PATCH 62/68] Fix: wrong method call --- pomobot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index 90a0ed7..9b3077f 100644 --- a/pomobot.py +++ b/pomobot.py @@ -246,7 +246,7 @@ def privmsg(self, sender, headers, message): elif message.startswith("!kick "): firstSpace = message[6:].find(" ") + 6 secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) - self.bot.identify(sender, self.kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), authFailure, (headers[0], sender)) + self.bot.identify(sender, self.kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), self.authFailure, (headers[0], sender)) else: print "PRIVMSG: \"You can not use command: " + message + "\"" From cc8aee4227b3574f27a8174e87e9ac2495322e97 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 22 Oct 2012 14:48:33 -0200 Subject: [PATCH 63/68] Only needs to check if owner for restricted commands --- pomobot.py | 52 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/pomobot.py b/pomobot.py index 9b3077f..3d234c8 100644 --- a/pomobot.py +++ b/pomobot.py @@ -14,6 +14,12 @@ def __init__(self, network, chanName, owner, botnick): self.bot.bind("376", self.endMOTD) self.chanName = chanName self.owner = owner + self.restricted_commands = ["!delrank", + "!say", + "!quit", + "!kick", + "!join", + ] def authFailure(self, recipient, name): self.bot.say(recipient, "You could not be identified to use comand") @@ -193,6 +199,31 @@ def delrank(self, chan): self.bot.topic(chan, " Be the first: !pomo+ ") def privmsg(self, sender, headers, message): + + # Restricted commands + if message.split()[0] in self.restricted_commands: + if sender == self.owner : + if message.startswith("!delrank"): + self.bot.identify(sender, self.delrank, (self.chanName,), self.authFailure, (headers[0], sender)) + + if message.startswith("!say "): + firstSpace = message[5:].find(" ") + 5 + self.bot.identify(sender, self.saySuccess, (message[5:firstSpace], message[firstSpace+1:]), self.authFailure, (headers[0], sender)) + elif message.startswith("!quit"): + if len(message) > 6: + self.bot.identify(sender, self.quitSuccess, (message[6:],), self.authFailure, (headers[0], sender)) + else: + self.bot.identify(sender, self.quitSuccess, ("",), self.authFailure, (headers[0], sender)) + elif message.startswith("!join "): + self.bot.identify(sender, self.joinSuccess, (message[6:],), self.authFailure, (headers[0], sender)) + elif message.startswith("!kick "): + firstSpace = message[6:].find(" ") + 6 + secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) + self.bot.identify(sender, self.kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), self.authFailure, (headers[0], sender)) + else: + self.bot.say(sender,"You can not use command: " + message) + + # Other commands if message.startswith("!help"): self.bot.say(self.chanName, "I am a bot.") self.bot.say(self.chanName, "I have this commands:") @@ -229,27 +260,6 @@ def privmsg(self, sender, headers, message): if message.startswith("!git"): self.bot.say(self.chanName, "git clone http://pokgsa.ibm.com/~edusf/public/git/botpomodoro/") - if sender == self.owner: - if message.startswith("!delrank"): - self.bot.identify(sender, self.delrank, (self.chanName,), self.authFailure, (headers[0], sender)) - - if message.startswith("!say "): - firstSpace = message[5:].find(" ") + 5 - self.bot.identify(sender, self.saySuccess, (message[5:firstSpace], message[firstSpace+1:]), self.authFailure, (headers[0], sender)) - elif message.startswith("!quit"): - if len(message) > 6: - self.bot.identify(sender, self.quitSuccess, (message[6:],), self.authFailure, (headers[0], sender)) - else: - self.bot.identify(sender, self.quitSuccess, ("",), self.authFailure, (headers[0], sender)) - elif message.startswith("!join "): - self.bot.identify(sender, self.joinSuccess, (message[6:],), self.authFailure, (headers[0], sender)) - elif message.startswith("!kick "): - firstSpace = message[6:].find(" ") + 6 - secondSpace = message[firstSpace+1:].find(" ") + (firstSpace + 1) - self.bot.identify(sender, self.kickSuccess, (message[6:firstSpace], message[firstSpace+1:secondSpace], message[secondSpace+1:]), self.authFailure, (headers[0], sender)) - else: - print "PRIVMSG: \"You can not use command: " + message + "\"" - def actionmsg(self, sender, headers, message): print "An ACTION message was sent by " + sender + " with the headers " + headers + ". It says: \"" + sender + " " + message From bc8382aa15ab3d3e2904d0bca3ce77ca7f0659ec Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 22 Oct 2012 16:01:49 -0200 Subject: [PATCH 64/68] Dont need to see pomodoro.db --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3bb2efd..446bd8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .*.swp *.pyc +test/pomodoro.db From 22f132688b22ae7a8d5a81e1497e820f9e97089f Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Fri, 26 Oct 2012 12:19:52 -0200 Subject: [PATCH 65/68] Add feature to generate charts --- graphy/__init__.py | 1 + graphy/all_tests.py | 51 ++ graphy/backends/__init__.py | 0 .../google_chart_api/.svn/all-wcprops | 53 ++ .../google_chart_api/.svn/dir-prop-base | 6 + graphy/backends/google_chart_api/.svn/entries | 129 ++++ graphy/backends/google_chart_api/.svn/format | 1 + .../.svn/prop-base/bar_chart_test.py.svn-base | 5 + .../prop-base/base_encoder_test.py.svn-base | 5 + .../prop-base/line_chart_test.py.svn-base | 5 + .../.svn/prop-base/pie_chart_test.py.svn-base | 5 + .../.svn/prop-base/util_test.py.svn-base | 5 + .../.svn/text-base/__init__.py.svn-base | 50 ++ .../.svn/text-base/bar_chart_test.py.svn-base | 190 ++++++ .../text-base/base_encoder_test.py.svn-base | 578 ++++++++++++++++++ .../.svn/text-base/encoders.py.svn-base | 430 +++++++++++++ .../text-base/line_chart_test.py.svn-base | 124 ++++ .../.svn/text-base/pie_chart_test.py.svn-base | 149 +++++ .../.svn/text-base/util.py.svn-base | 231 +++++++ .../.svn/text-base/util_test.py.svn-base | 149 +++++ graphy/backends/google_chart_api/__init__.py | 50 ++ .../google_chart_api/bar_chart_test.py | 190 ++++++ .../google_chart_api/base_encoder_test.py | 578 ++++++++++++++++++ graphy/backends/google_chart_api/encoders.py | 430 +++++++++++++ .../google_chart_api/line_chart_test.py | 124 ++++ .../google_chart_api/pie_chart_test.py | 149 +++++ graphy/backends/google_chart_api/util.py | 231 +++++++ graphy/backends/google_chart_api/util_test.py | 149 +++++ graphy/bar_chart.py | 171 ++++++ graphy/bar_chart_test.py | 83 +++ graphy/common.py | 412 +++++++++++++ graphy/common_test.py | 108 ++++ graphy/formatters.py | 192 ++++++ graphy/formatters_test.py | 106 ++++ graphy/graphy_test.py | 46 ++ graphy/line_chart.py | 122 ++++ graphy/line_chart_test.py | 77 +++ graphy/pie_chart.py | 178 ++++++ graphy/pie_chart_test.py | 110 ++++ graphy/util.py | 13 + pomobot.py | 129 ++++ test/test_bot.py | 44 +- 42 files changed, 5841 insertions(+), 18 deletions(-) create mode 100644 graphy/__init__.py create mode 100755 graphy/all_tests.py create mode 100644 graphy/backends/__init__.py create mode 100644 graphy/backends/google_chart_api/.svn/all-wcprops create mode 100644 graphy/backends/google_chart_api/.svn/dir-prop-base create mode 100644 graphy/backends/google_chart_api/.svn/entries create mode 100644 graphy/backends/google_chart_api/.svn/format create mode 100644 graphy/backends/google_chart_api/.svn/prop-base/bar_chart_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/prop-base/base_encoder_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/prop-base/line_chart_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/prop-base/pie_chart_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/prop-base/util_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/__init__.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/bar_chart_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/base_encoder_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/encoders.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/line_chart_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/pie_chart_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/util.py.svn-base create mode 100644 graphy/backends/google_chart_api/.svn/text-base/util_test.py.svn-base create mode 100644 graphy/backends/google_chart_api/__init__.py create mode 100755 graphy/backends/google_chart_api/bar_chart_test.py create mode 100755 graphy/backends/google_chart_api/base_encoder_test.py create mode 100644 graphy/backends/google_chart_api/encoders.py create mode 100755 graphy/backends/google_chart_api/line_chart_test.py create mode 100755 graphy/backends/google_chart_api/pie_chart_test.py create mode 100644 graphy/backends/google_chart_api/util.py create mode 100755 graphy/backends/google_chart_api/util_test.py create mode 100644 graphy/bar_chart.py create mode 100755 graphy/bar_chart_test.py create mode 100644 graphy/common.py create mode 100755 graphy/common_test.py create mode 100644 graphy/formatters.py create mode 100755 graphy/formatters_test.py create mode 100755 graphy/graphy_test.py create mode 100644 graphy/line_chart.py create mode 100644 graphy/line_chart_test.py create mode 100644 graphy/pie_chart.py create mode 100755 graphy/pie_chart_test.py create mode 100644 graphy/util.py diff --git a/graphy/__init__.py b/graphy/__init__.py new file mode 100644 index 0000000..6cd828b --- /dev/null +++ b/graphy/__init__.py @@ -0,0 +1 @@ +__version__='1.0' diff --git a/graphy/all_tests.py b/graphy/all_tests.py new file mode 100755 index 0000000..834e22c --- /dev/null +++ b/graphy/all_tests.py @@ -0,0 +1,51 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Run all tests from *_test.py files.""" + +import os +import unittest + + +def ModuleName(filename, base_dir): + """Given a filename, convert to the python module name.""" + filename = filename.replace(base_dir, '') + filename = filename.lstrip(os.path.sep) + filename = filename.replace(os.path.sep, '.') + if filename.endswith('.py'): + filename = filename[:-3] + return filename + + +def FindTestModules(): + """Return names of any test modules (*_test.py).""" + tests = [] + start_dir = os.path.dirname(os.path.abspath(__file__)) + for dir, subdirs, files in os.walk(start_dir): + if dir.endswith('/.svn') or '/.svn/' in dir: + continue + tests.extend(ModuleName(os.path.join(dir, f), start_dir) for f + in files if f.endswith('_test.py')) + return tests + + +def AllTests(): + suites = unittest.defaultTestLoader.loadTestsFromNames(FindTestModules()) + return unittest.TestSuite(suites) + + +if __name__ == '__main__': + unittest.main(module=None, defaultTest='__main__.AllTests') diff --git a/graphy/backends/__init__.py b/graphy/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphy/backends/google_chart_api/.svn/all-wcprops b/graphy/backends/google_chart_api/.svn/all-wcprops new file mode 100644 index 0000000..9c34ec4 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/all-wcprops @@ -0,0 +1,53 @@ +K 25 +svn:wc:ra_dav:version-url +V 65 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api +END +base_encoder_test.py +K 25 +svn:wc:ra_dav:version-url +V 86 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/base_encoder_test.py +END +util_test.py +K 25 +svn:wc:ra_dav:version-url +V 78 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/util_test.py +END +util.py +K 25 +svn:wc:ra_dav:version-url +V 73 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/util.py +END +pie_chart_test.py +K 25 +svn:wc:ra_dav:version-url +V 83 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/pie_chart_test.py +END +__init__.py +K 25 +svn:wc:ra_dav:version-url +V 77 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/__init__.py +END +encoders.py +K 25 +svn:wc:ra_dav:version-url +V 77 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/encoders.py +END +bar_chart_test.py +K 25 +svn:wc:ra_dav:version-url +V 83 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/bar_chart_test.py +END +line_chart_test.py +K 25 +svn:wc:ra_dav:version-url +V 84 +/svn/!svn/ver/75/tags/graphy_1.0/graphy/backends/google_chart_api/line_chart_test.py +END diff --git a/graphy/backends/google_chart_api/.svn/dir-prop-base b/graphy/backends/google_chart_api/.svn/dir-prop-base new file mode 100644 index 0000000..4cc643b --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/dir-prop-base @@ -0,0 +1,6 @@ +K 10 +svn:ignore +V 6 +*.pyc + +END diff --git a/graphy/backends/google_chart_api/.svn/entries b/graphy/backends/google_chart_api/.svn/entries new file mode 100644 index 0000000..5a2f3d5 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/entries @@ -0,0 +1,129 @@ +8 + +dir +75 +https://graphy.googlecode.com/svn/tags/graphy_1.0/graphy/backends/google_chart_api +https://graphy.googlecode.com/svn + + + +2009-01-13T03:40:07.182080Z +73 +bugmaster +has-props + +svn:special svn:externals svn:needs-lock + + + + + + + + + + + +30582518-8026-11dd-8d1c-71c7e1663bfb + +base_encoder_test.py +file + + + + +2009-05-14T23:29:14.000000Z +2257291b958ebb18f2f8879037e1ce95 +2008-12-30T15:54:15.324132Z +68 +zovirl@zovirl.com +has-props + +util_test.py +file + + + + +2009-05-14T23:29:14.000000Z +d09705be8624c77a15180c3e15de1390 +2008-11-19T02:16:36.610210Z +41 +zovirl@zovirl.com +has-props + +util.py +file + + + + +2009-05-14T23:29:14.000000Z +173e949a508d9c740d845c753f5ec6d8 +2008-12-30T15:57:12.784546Z +69 +zovirl@zovirl.com + +pie_chart_test.py +file + + + + +2009-05-14T23:29:14.000000Z +438fc383e33c5d3420301cd7074fe21d +2009-01-09T00:58:34.094011Z +72 +bugmaster +has-props + +__init__.py +file + + + + +2009-05-14T23:29:14.000000Z +2e74e5afa52c2d367611c87935b5ed0d +2008-12-06T01:15:57.099252Z +45 +zovirl@zovirl.com + +encoders.py +file + + + + +2009-05-14T23:29:14.000000Z +010763cf83bdb874d9b88299ee550d3d +2009-01-13T03:40:07.182080Z +73 +bugmaster + +bar_chart_test.py +file + + + + +2009-05-14T23:29:14.000000Z +ffbbd2cca8df1273e08ce598cdddf103 +2008-12-30T15:54:15.324132Z +68 +zovirl@zovirl.com +has-props + +line_chart_test.py +file + + + + +2009-05-14T23:29:14.000000Z +f5a276348bde69ebe52d00d8da35b606 +2008-12-09T06:22:36.205817Z +48 +zovirl@zovirl.com +has-props + diff --git a/graphy/backends/google_chart_api/.svn/format b/graphy/backends/google_chart_api/.svn/format new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/format @@ -0,0 +1 @@ +8 diff --git a/graphy/backends/google_chart_api/.svn/prop-base/bar_chart_test.py.svn-base b/graphy/backends/google_chart_api/.svn/prop-base/bar_chart_test.py.svn-base new file mode 100644 index 0000000..869ac71 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/prop-base/bar_chart_test.py.svn-base @@ -0,0 +1,5 @@ +K 14 +svn:executable +V 1 +* +END diff --git a/graphy/backends/google_chart_api/.svn/prop-base/base_encoder_test.py.svn-base b/graphy/backends/google_chart_api/.svn/prop-base/base_encoder_test.py.svn-base new file mode 100644 index 0000000..869ac71 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/prop-base/base_encoder_test.py.svn-base @@ -0,0 +1,5 @@ +K 14 +svn:executable +V 1 +* +END diff --git a/graphy/backends/google_chart_api/.svn/prop-base/line_chart_test.py.svn-base b/graphy/backends/google_chart_api/.svn/prop-base/line_chart_test.py.svn-base new file mode 100644 index 0000000..869ac71 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/prop-base/line_chart_test.py.svn-base @@ -0,0 +1,5 @@ +K 14 +svn:executable +V 1 +* +END diff --git a/graphy/backends/google_chart_api/.svn/prop-base/pie_chart_test.py.svn-base b/graphy/backends/google_chart_api/.svn/prop-base/pie_chart_test.py.svn-base new file mode 100644 index 0000000..869ac71 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/prop-base/pie_chart_test.py.svn-base @@ -0,0 +1,5 @@ +K 14 +svn:executable +V 1 +* +END diff --git a/graphy/backends/google_chart_api/.svn/prop-base/util_test.py.svn-base b/graphy/backends/google_chart_api/.svn/prop-base/util_test.py.svn-base new file mode 100644 index 0000000..869ac71 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/prop-base/util_test.py.svn-base @@ -0,0 +1,5 @@ +K 14 +svn:executable +V 1 +* +END diff --git a/graphy/backends/google_chart_api/.svn/text-base/__init__.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/__init__.py.svn-base new file mode 100644 index 0000000..59f1a33 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/__init__.py.svn-base @@ -0,0 +1,50 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Backend which can generate charts using the Google Chart API.""" + +from graphy import line_chart +from graphy import bar_chart +from graphy import pie_chart +from graphy.backends.google_chart_api import encoders + +def _GetChartFactory(chart_class, display_class): + """Create a factory method for instantiating charts with displays. + + Returns a method which, when called, will create & return a chart with + chart.display already populated. + """ + def Inner(*args, **kwargs): + chart = chart_class(*args, **kwargs) + chart.display = display_class(chart) + return chart + return Inner + +# These helper methods make it easy to get chart objects with display +# objects already setup. For example, this: +# chart = google_chart_api.LineChart() +# is equivalent to: +# chart = line_chart.LineChart() +# chart.display = google_chart_api.LineChartEncoder() +# +# (If there's some chart type for which a helper method isn't available, you +# can always just instantiate the correct encoder manually, like in the 2nd +# example above). +# TODO: fix these so they have nice docs in ipython (give them __doc__) +LineChart = _GetChartFactory(line_chart.LineChart, encoders.LineChartEncoder) +Sparkline = _GetChartFactory(line_chart.Sparkline, encoders.SparklineEncoder) +BarChart = _GetChartFactory(bar_chart.BarChart, encoders.BarChartEncoder) +PieChart = _GetChartFactory(pie_chart.PieChart, encoders.PieChartEncoder) diff --git a/graphy/backends/google_chart_api/.svn/text-base/bar_chart_test.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/bar_chart_test.py.svn-base new file mode 100644 index 0000000..bb4ed33 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/bar_chart_test.py.svn-base @@ -0,0 +1,190 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +import math + +from graphy import graphy_test +from graphy import bar_chart +from graphy.backends import google_chart_api +from graphy.backends.google_chart_api import base_encoder_test + + +# Extend XYChartTest so that we pick up & repeat all the basic tests which +# BarCharts should continue to satisfy +class BarChartTest(base_encoder_test.XYChartTest): + + def GetChart(self, *args, **kwargs): + return google_chart_api.BarChart(*args, **kwargs) + + def AddToChart(self, chart, points, color=None, label=None): + return chart.AddBars(points, color=color, label=label) + + def testChartType(self): + def Check(vertical, stacked, expected_type): + self.chart.vertical = vertical + self.chart.stacked = stacked + self.assertEqual(self.Param('cht'), expected_type) + Check(vertical=True, stacked=True, expected_type='bvs') + Check(vertical=True, stacked=False, expected_type='bvg') + Check(vertical=False, stacked=True, expected_type='bhs') + Check(vertical=False, stacked=False, expected_type='bhg') + + def testSingleBarCase(self): + """Test that we can handle a bar chart with only a single bar.""" + self.AddToChart(self.chart, [1]) + self.assertEqual(self.Param('chd'), 's:A') + + def testHorizontalScaling(self): + """Test the scaling works correctly on horizontal bar charts (which have + min/max on a different axis than other charts). + """ + self.AddToChart(self.chart, [3]) + self.chart.vertical = False + self.chart.bottom.min = 0 + self.chart.bottom.max = 3 + self.assertEqual(self.Param('chd'), 's:9') # 9 is far right edge. + self.chart.bottom.max = 6 + self.assertEqual(self.Param('chd'), 's:f') # f is right in the middle. + + def testZeroPoint(self): + self.AddToChart(self.chart, [-5, 0, 5]) + self.assertEqual(self.Param('chp'), str(.5)) # Auto scaling. + self.chart.left.min = 0 + self.chart.left.max = 5 + self.assertRaises(KeyError, self.Param, 'chp') # No negative values. + self.chart.left.min = -5 + self.assertEqual(self.Param('chp'), str(.5)) # Explicit scaling. + self.chart.left.max = 15 + self.assertEqual(self.Param('chp'), str(.25)) # Different zero point. + self.chart.left.max = -1 + self.assertEqual(self.Param('chp'), str(1)) # Both negative values. + + def testLabelsInCorrectOrder(self): + """Test that we reverse labels for horizontal bar charts + (Otherwise they are backwards from what you would expect) + """ + self.chart.left.labels = [1, 2, 3] + self.chart.vertical = True + self.assertEqual(self.Param('chxl'), '0:|1|2|3') + self.chart.vertical = False + self.assertEqual(self.Param('chxl'), '0:|3|2|1') + + def testLabelRangeDefaultsToDataScale(self): + """Test that if you don't set axis ranges, they default to the data + scale. + """ + self.chart.auto_scale.buffer = 0 # Buffer causes trouble for testing. + self.AddToChart(self.chart, [1, 5]) + self.chart.left.labels = (1, 5) + self.chart.left.labels_positions = (1, 5) + self.assertEqual(self.Param('chxr'), '0,1,5') + + def testCanOverrideChbh(self): + self.chart.style = bar_chart.BarChartStyle(10, 3, 6) + self.AddToChart(self.chart, [1, 2, 3]) + self.assertEqual(self.Param('chbh'), '10,3,6') + self.chart.display.extra_params['chbh'] = '5,5,2' + self.assertEqual(self.Param('chbh'), '5,5,2') + + def testDefaultBarChartStyle(self): + self.assertNotIn('chbh', self.chart.display._Params(self.chart)) + self.chart.style = bar_chart.BarChartStyle(None, None, None) + self.assertNotIn('chbh', self.chart.display._Params(self.chart)) + self.chart.style = bar_chart.BarChartStyle(10, 3, 6) + self.assertNotIn('chbh', self.chart.display._Params(self.chart)) + self.AddToChart(self.chart, [1, 2, 3]) + self.assertEqual(self.Param('chbh'), '10,3,6') + self.chart.style = bar_chart.BarChartStyle(10) + self.assertEqual(self.Param('chbh'), '10,4,8') + + def testAutoBarSizing(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.chart.style = bar_chart.BarChartStyle(None, 3, 6) + self.chart.display._width = 100 + self.chart.display._height = 1000 + self.chart.stacked = False + self.assertEqual(self.Param('chbh'), 'a,3,6') + self.chart.stacked = True + self.assertEqual(self.Param('chbh'), 'a,3') + self.chart.vertical = False + self.chart.stacked = False + self.assertEqual(self.Param('chbh'), 'a,3,6') + self.chart.stacked = True + self.assertEqual(self.Param('chbh'), 'a,3') + self.chart.display._height = 1 + self.assertEqual(self.Param('chbh'), 'a,3') + + def testAutoBarSpacing(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.chart.style = bar_chart.BarChartStyle(10, 1, None) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 2) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 1) + self.assertEqual(self.Param('chbh'), '10,0,1') + + def testFractionalAutoBarSpacing(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.chart.style = bar_chart.BarChartStyle(10, 0.1, None, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 0.2, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 0.1, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), '10,0,1') + self.chart.style = bar_chart.BarChartStyle(None, 0.1, 0.2, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), 'r,0.1,0.2') + self.chart.style = bar_chart.BarChartStyle(None, 0.1, None, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), 'r,0.1,0.2') + + def testStackedDataScaling(self): + self.AddToChart(self.chart, [10, 20, 30]) + self.AddToChart(self.chart, [-5, -10, -15]) + self.chart.stacked = True + self.assertEqual(self.Param('chd'), 's:iu6,PJD') + self.chart.stacked = False + self.assertEqual(self.Param('chd'), 's:iu6,PJD') + + self.chart = self.GetChart() + self.chart.stacked = True + self.AddToChart(self.chart, [10, 20, 30]) + self.AddToChart(self.chart, [5, -10, 15]) + self.assertEqual(self.Param('chd'), 's:Xhr,SDc') + self.AddToChart(self.chart, [-15, -10, -45]) + self.assertEqual(self.Param('chd'), 's:lrx,iYo,VYD') + # TODO: Figure out how to deal with missing data points, test them + + def testNegativeBars(self): + self.chart.stacked = True + self.AddToChart(self.chart, [-10,-20,-30]) + self.assertEqual(self.Param('chd'), 's:oVD') + self.AddToChart(self.chart, [-1,-2,-3]) + self.assertEqual(self.Param('chd'), 's:pZI,531') + self.chart.stacked = False + self.assertEqual(self.Param('chd'), 's:pWD,642') + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/.svn/text-base/base_encoder_test.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/base_encoder_test.py.svn-base new file mode 100644 index 0000000..335b588 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/base_encoder_test.py.svn-base @@ -0,0 +1,578 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Test for the base encoder. Also serves as a base class for the +chart-type-specific tests.""" + +from graphy import common +from graphy import graphy_test +from graphy import formatters +from graphy.backends.google_chart_api import encoders +from graphy.backends.google_chart_api import util + + +class TestEncoder(encoders.BaseChartEncoder): + """Simple implementation of BaseChartEncoder for testing common behavior.""" + def _GetType(self, chart): + return {'chart_type': 'TEST_TYPE'} + + def _GetDependentAxis(self, chart): + return chart.left + + +class TestChart(common.BaseChart): + """Simple implementation of BaseChart for testing common behavior.""" + + def __init__(self, points=None): + super(TestChart, self).__init__() + if points is not None: + self.AddData(points) + + def AddData(self, points, color=None, label=None): + style = common._BasicStyle(color) + series = common.DataSeries(points, style=style, label=label) + self.data.append(series) + return series + + +class BaseChartTest(graphy_test.GraphyTest): + """Base class for all chart-specific tests""" + + def ExpectAxes(self, labels, positions): + """Helper to test that the chart axis spec matches the expected values.""" + self.assertEqual(self.Param('chxl'), labels) + self.assertEqual(self.Param('chxp'), positions) + + def GetChart(self, *args, **kwargs): + """Get a chart object. Other classes can override to change the + type of chart being tested. + """ + chart = TestChart(*args, **kwargs) + chart.display = TestEncoder(chart) + return chart + + def AddToChart(self, chart, points, color=None, label=None): + """Add data to the chart. + + Chart is assumed to be of the same type as returned by self.GetChart(). + """ + return chart.AddData(points, color=color, label=label) + + def setUp(self): + self.chart = self.GetChart() + + def testImgAndUrlUseSameUrl(self): + """Check that Img() and Url() return the same URL.""" + self.assertIn(self.chart.display.Url(500, 100, use_html_entities=True), + self.chart.display.Img(500, 100)) + + def testImgUsesHtmlEntitiesInUrl(self): + img_tag = self.chart.display.Img(500, 100) + self.assertNotIn('&ch', img_tag) + self.assertIn('&ch', img_tag) + + def testParamsAreStrings(self): + """Test that params are all converted to strings.""" + self.chart.display.extra_params['test'] = 32 + self.assertEqual(self.Param('test'), '32') + + def testExtraParamsOverideDefaults(self): + self.assertNotEqual(self.Param('cht'), 'test') # Sanity check. + self.chart.display.extra_params['cht'] = 'test' + self.assertEqual(self.Param('cht'), 'test') + + def testExtraParamsCanUseLongNames(self): + self.chart.display.extra_params['color'] = 'XYZ' + self.assertEqual(self.Param('chco'), 'XYZ') + + def testExtraParamsCanUseNewNames(self): + """Make sure future Google Chart API features can be accessed immediately + through extra_params. (Double-checks that the long-to-short name + conversion doesn't mess up the ability to use new features). + """ + self.chart.display.extra_params['fancy_new_feature'] = 'shiny' + self.assertEqual(self.Param('fancy_new_feature'), 'shiny') + + def testEmptyParamsDropped(self): + """Check that empty parameters don't end up in the URL.""" + self.assertEqual(self.Param('chxt'), '') + self.assertNotIn('chxt', self.chart.display.Url(0, 0)) + + def testSizes(self): + self.assertIn('89x102', self.chart.display.Url(89, 102)) + + img = self.chart.display.Img(89, 102) + self.assertIn('chs=89x102', img) + self.assertIn('width="89"', img) + self.assertIn('height="102"', img) + + def testChartType(self): + self.assertEqual(self.Param('cht'), 'TEST_TYPE') + + def testChartSizeConvertedToInt(self): + url = self.chart.display.Url(100.1, 200.2) + self.assertIn('100x200', url) + + def testUrlBase(self): + def assertStartsWith(actual_text, expected_start): + message = "[%s] didn't start with [%s]" % (actual_text, expected_start) + self.assert_(actual_text.startswith(expected_start), message) + + assertStartsWith(self.chart.display.Url(0, 0), + 'http://chart.apis.google.com/chart') + + url_base = 'http://example.com/charts' + self.chart.display.url_base = url_base + assertStartsWith(self.chart.display.Url(0, 0), url_base) + + def testEnhancedEncoder(self): + self.chart.display.enhanced_encoding = True + self.assertEqual(self.Param('chd'), 'e:') + + def testUrlsEscaped(self): + self.AddToChart(self.chart, [1, 2, 3]) + url = self.chart.display.Url(500, 100) + self.assertNotIn('chd=s:', url) + self.assertIn('chd=s%3A', url) + + def testUrls_DefaultIsWithoutHtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + url_default = self.chart.display.Url(500, 100) + url_forced = self.chart.display.Url(500, 100, use_html_entities=False) + self.assertEqual(url_forced, url_default) + + def testUrls_HtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + url = self.chart.display.Url(500, 100, use_html_entities=True) + self.assertNotIn('&ch', url) + self.assertIn('&ch', url) + self.assertIn('%7CCiao%26%22Mario%3ELuigi%22', url) + + def testUrls_NoEscapeWithHtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + self.chart.display.escape_url = False + url = self.chart.display.Url(500, 100, use_html_entities=True) + self.assertNotIn('&ch', url) + self.assertIn('&ch', url) + self.assertIn('Ciao&"Mario>Luigi"', url) + + def testUrls_NoHtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + url = self.chart.display.Url(500, 100, use_html_entities=False) + self.assertIn('&ch', url) + self.assertNotIn('&ch', url) + self.assertIn('%7CCiao%26%22Mario%3ELuigi%22', url) + + def testCanRemoveDefaultFormatters(self): + self.assertEqual(3, len(self.chart.formatters)) + # I don't know why you'd want to remove the default formatters like this. + # It is just a proof that we can manipulate the default formatters + # through their aliases. + self.chart.formatters.remove(self.chart.auto_color) + self.chart.formatters.remove(self.chart.auto_legend) + self.chart.formatters.remove(self.chart.auto_scale) + self.assertEqual(0, len(self.chart.formatters)) + + def testFormattersWorkOnCopy(self): + """Make sure formatters can't modify the user's chart.""" + self.AddToChart(self.chart, [1]) + # By making sure our point is at the upper boundry, we make sure that both + # line, pie, & bar charts encode it as a '9' in the simple encoding. + self.chart.left.max = 1 + self.chart.left.min = 0 + # Sanity checks before adding a formatter. + self.assertEqual(self.Param('chd'), 's:9') + self.assertEqual(len(self.chart.data), 1) + + def MaliciousFormatter(chart): + chart.data.pop() # Modify a mutable chart attribute + self.chart.AddFormatter(MaliciousFormatter) + + self.assertEqual(self.Param('chd'), 's:', "Formatter wasn't used.") + self.assertEqual(len(self.chart.data), 1, + "Formatter was able to modify original chart.") + + self.chart.formatters.remove(MaliciousFormatter) + self.assertEqual(self.Param('chd'), 's:9', + "Chart changed even after removing the formatter") + + +class XYChartTest(BaseChartTest): + """Base class for charts that display lines or points in 2d. + + Pretty much anything but the pie chart. + """ + + def testImgAndUrlUseSameUrl(self): + """Check that Img() and Url() return the same URL.""" + super(XYChartTest, self).testImgAndUrlUseSameUrl() + self.AddToChart(self.chart, range(0, 100)) + self.assertIn(self.chart.display.Url(500, 100, use_html_entities=True), + self.chart.display.Img(500, 100)) + self.chart = self.GetChart([-1, 0, 1]) + self.assertIn(self.chart.display.Url(500, 100, use_html_entities=True), + self.chart.display.Img(500, 100)) + + # TODO: Once the deprecated AddSeries is removed, revisit + # whether we need this test. + def testAddSeries(self): + self.chart.auto_scale.buffer = 0 # Buffer causes trouble for testing. + self.assertEqual(self.Param('chd'), 's:') + self.AddToChart(self.chart, (1, 2, 3)) + self.assertEqual(self.Param('chd'), 's:Af9') + self.AddToChart(self.chart, (4, 5, 6)) + self.assertEqual(self.Param('chd'), 's:AMY,lx9') + + # TODO: Once the deprecated AddSeries is removed, revisit + # whether we need this test. + def testAddSeriesReturnsValue(self): + points = (1, 2, 3) + series = self.AddToChart(self.chart, points, '#000000') + self.assertTrue(series is not None) + self.assertEqual(series.data, points) + self.assertEqual(series.style.color, '#000000') + + def testFlatSeries(self): + """Make sure we handle scaling of a flat data series correctly (there are + div by zero issues). + """ + self.AddToChart(self.chart, [5, 5, 5]) + self.assertEqual(self.Param('chd'), 's:AAA') + self.chart.left.min = 0 + self.chart.left.max = 5 + self.assertEqual(self.Param('chd'), 's:999') + self.chart.left.min = 5 + self.chart.left.max = 15 + self.assertEqual(self.Param('chd'), 's:AAA') + + def testEmptyPointsStillCreatesSeries(self): + """If we pass an empty list for points, we expect to get an empty data + series, not nothing. This way we can add data points later.""" + chart = self.GetChart() + self.assertEqual(0, len(chart.data)) + data = [] + chart = self.GetChart(data) + self.assertEqual(1, len(chart.data)) + self.assertEqual(0, len(chart.data[0].data)) + # This is the use case we are trying to serve: adding points later. + data.append(0) + self.assertEqual(1, len(chart.data[0].data)) + + def testEmptySeriesDroppedFromParams(self): + """By the time we make parameters, we don't want empty series to be + included because it will mess up the indexes of other things like colors + and makers. They should be dropped instead.""" + self.chart.auto_scale.buffer = 0 + # Check just an empty series. + self.AddToChart(self.chart, [], color='eeeeee') + self.assertEqual(self.Param('chd'), 's:') + # Now check when there are some real series in there too. + self.AddToChart(self.chart, [1], color='111111') + self.AddToChart(self.chart, [], color='FFFFFF') + self.AddToChart(self.chart, [2], color='222222') + self.assertEqual(self.Param('chd'), 's:A,9') + self.assertEqual(self.Param('chco'), '111111,222222') + + def testDataSeriesCorrectlyConverted(self): + # To avoid problems caused by floating-point errors, the input in this test + # is carefully chosen to avoid 0.5 boundries (1.5, 2.5, 3.5, ...). + chart = self.GetChart() + chart.auto_scale.buffer = 0 # The buffer makes testing difficult. + self.assertEqual(self.Param('chd', chart), 's:') + chart = self.GetChart(range(0, 10)) + chart.auto_scale.buffer = 0 + self.assertEqual(self.Param('chd', chart), 's:AHOUbipv29') + chart = self.GetChart(range(-10, 0)) + chart.auto_scale.buffer = 0 + self.assertEqual(self.Param('chd', chart), 's:AHOUbipv29') + chart = self.GetChart((-1.1, 0.0, 1.1, 2.2)) + chart.auto_scale.buffer = 0 + self.assertEqual(self.Param('chd', chart), 's:AUp9') + + def testSeriesColors(self): + self.AddToChart(self.chart, [1, 2, 3], '000000') + self.AddToChart(self.chart, [4, 5, 6], 'FFFFFF') + self.assertEqual(self.Param('chco'), '000000,FFFFFF') + + def testSeriesCaption_NoCaptions(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.assertRaises(KeyError, self.Param, 'chdl') + + def testSeriesCaption_SomeCaptions(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6], label='Label') + self.AddToChart(self.chart, [7, 8, 9]) + self.assertEqual(self.Param('chdl'), '|Label|') + + def testThatZeroIsPreservedInCaptions(self): + """Test that a 0 caption becomes '0' and not ''. + (This makes sure that the logic to rewrite a label of None to '' doesn't + also accidentally rewrite 0 to ''). + """ + self.AddToChart(self.chart, [], label=0) + self.AddToChart(self.chart, [], label=1) + self.assertEqual(self.Param('chdl'), '0|1') + + def testSeriesCaption_AllCaptions(self): + self.AddToChart(self.chart, [1, 2, 3], label='Its') + self.AddToChart(self.chart, [4, 5, 6], label='Me') + self.AddToChart(self.chart, [7, 8, 9], label='Mario') + self.assertEqual(self.Param('chdl'), 'Its|Me|Mario') + + def testDefaultColorsApplied(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.assertEqual(self.Param('chco'), '0000ff,ff0000') + + def testShowingAxes(self): + self.assertEqual(self.Param('chxt'), '') + self.chart.left.min = 3 + self.chart.left.max = 5 + self.assertEqual(self.Param('chxt'), '') + self.chart.left.labels = ['a'] + self.assertEqual(self.Param('chxt'), 'y') + self.chart.right.labels = ['a'] + self.assertEqual(self.Param('chxt'), 'y,r') + self.chart.left.labels = [] # Set back to the original state. + self.assertEqual(self.Param('chxt'), 'r') + + def testAxisRanges(self): + self.chart.left.labels = ['a'] + self.chart.bottom.labels = ['a'] + self.assertEqual(self.Param('chxr'), '') + self.chart.left.min = -5 + self.chart.left.max = 10 + self.assertEqual(self.Param('chxr'), '0,-5,10') + self.chart.bottom.min = 0.5 + self.chart.bottom.max = 0.75 + self.assertEqual(self.Param('chxr'), '0,-5,10|1,0.5,0.75') + + def testAxisLabels(self): + self.ExpectAxes('', '') + self.chart.left.labels = [10, 20, 30] + self.ExpectAxes('0:|10|20|30', '') + self.chart.left.label_positions = [0, 50, 100] + self.ExpectAxes('0:|10|20|30', '0,0,50,100') + self.chart.right.labels = ['cow', 'horse', 'monkey'] + self.chart.right.label_positions = [3.7, 10, -22.9] + self.ExpectAxes('0:|10|20|30|1:|cow|horse|monkey', + '0,0,50,100|1,3.7,10,-22.9') + + def testGridBottomAxis(self): + self.chart.bottom.min = 0 + self.chart.bottom.max = 20 + self.chart.bottom.grid_spacing = 10 + self.assertEqual(self.Param('chg'), '50,0,1,0') + self.chart.bottom.grid_spacing = 2 + self.assertEqual(self.Param('chg'), '10,0,1,0') + + def testGridFloatingPoint(self): + """Test that you can get decimal grid values in chg.""" + self.chart.bottom.min = 0 + self.chart.bottom.max = 8 + self.chart.bottom.grid_spacing = 1 + self.assertEqual(self.Param('chg'), '12.5,0,1,0') + self.chart.bottom.max = 3 + self.assertEqual(self.Param('chg'), '33.3,0,1,0') + + def testGridLeftAxis(self): + self.chart.auto_scale.buffer = 0 + self.AddToChart(self.chart, (0, 20)) + self.chart.left.grid_spacing = 5 + self.assertEqual(self.Param('chg'), '0,25,1,0') + + def testLabelGridBottomAxis(self): + self.AddToChart(self.chart, [0, 20, 40]) + self.chart.bottom.label_gridlines = True + self.chart.bottom.labels = ['Apple', 'Banana', 'Coconut'] + self.chart.bottom.label_positions = [1.5, 5, 8.5] + self.chart.display._width = 320 + self.chart.display._height = 240 + self.assertEqual(self.Param('chxtc'), '0,-320') + + def testLabelGridLeftAxis(self): + self.AddToChart(self.chart, [0, 20, 40]) + self.chart.left.label_gridlines = True + self.chart.left.labels = ['Few', 'Some', 'Lots'] + self.chart.left.label_positions = [5, 20, 35] + self.chart.display._width = 320 + self.chart.display._height = 240 + self.assertEqual(self.Param('chxtc'), '0,-320') + + def testLabelGridBothAxes(self): + self.AddToChart(self.chart, [0, 20, 40]) + self.chart.left.label_gridlines = True + self.chart.left.labels = ['Few', 'Some', 'Lots'] + self.chart.left.label_positions = [5, 20, 35] + self.chart.bottom.label_gridlines = True + self.chart.bottom.labels = ['Apple', 'Banana', 'Coconut'] + self.chart.bottom.label_positions = [1.5, 5, 8.5] + self.chart.display._width = 320 + self.chart.display._height = 240 + self.assertEqual(self.Param('chxtc'), '0,-320|1,-320') + + def testDefaultDataScalingNotPersistant(self): + """The auto-scaling shouldn't permanantly set the scale.""" + self.chart.auto_scale.buffer = 0 # Buffer just makes the math tricky here. + # This data should scale to the simple encoding's min/middle/max values + # (A, f, 9). + self.AddToChart(self.chart, [1, 2, 3]) + self.assertEqual(self.Param('chd'), 's:Af9') + # Different data that maintains the same relative spacing *should* scale + # to the same min/middle/max. + self.chart.data[0].data = [10, 20, 30] + self.assertEqual(self.Param('chd'), 's:Af9') + + def FakeScale(self, data, old_min, old_max, new_min, new_max): + self.min = old_min + self.max = old_max + return data + + def testDefaultDataScaling(self): + """If you don't set min/max, it should use the data's min/max.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [2, 3, 5, 7, 11]) + self.chart.auto_scale.buffer = 0 + # This causes scaling to happen & calls FakeScale. + self.chart.display.Url(0, 0) + self.assertEqual(2, self.min) + self.assertEqual(11, self.max) + finally: + util.ScaleData = orig_scale + + def testDefaultDataScalingAvoidsCropping(self): + """The default scaling should give a little buffer to avoid cropping.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [1, 6]) + # This causes scaling to happen & calls FakeScale. + self.chart.display.Url(0, 0) + buffer = 5 * self.chart.auto_scale.buffer + self.assertEqual(1 - buffer, self.min) + self.assertEqual(6 + buffer, self.max) + finally: + util.ScaleData = orig_scale + + def testExplicitDataScaling(self): + """If you set min/max, data should be scaled to this.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [2, 3, 5, 7, 11]) + self.chart.left.min = -7 + self.chart.left.max = 49 + # This causes scaling to happen & calls FakeScale. + self.chart.display.Url(0, 0) + self.assertEqual(-7, self.min) + self.assertEqual(49, self.max) + finally: + util.ScaleData = orig_scale + + def testImplicitMinValue(self): + """min values should be filled in if they are not set explicitly.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [0, 10]) + self.chart.auto_scale.buffer = 0 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(0, self.min) + self.chart.left.min = -5 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(-5, self.min) + finally: + util.ScaleData = orig_scale + + def testImplicitMaxValue(self): + """max values should be filled in if they are not set explicitly.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [0, 10]) + self.chart.auto_scale.buffer = 0 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(10, self.max) + self.chart.left.max = 15 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(15, self.max) + finally: + util.ScaleData = orig_scale + + def testNoneCanAppearInData(self): + """None should be a valid value in a data series. (It means "no data at + this point") + """ + # Buffer makes comparison difficult because min/max aren't A & 9 + self.chart.auto_scale.buffer = 0 + self.AddToChart(self.chart, [1, None, 3]) + self.assertEqual(self.Param('chd'), 's:A_9') + + def testResolveLabelCollision(self): + self.chart.auto_scale.buffer = 0 + self.AddToChart(self.chart, [500, 1000]) + self.AddToChart(self.chart, [100, 999]) + self.AddToChart(self.chart, [200, 900]) + self.AddToChart(self.chart, [200, -99]) + self.AddToChart(self.chart, [100, -100]) + self.chart.right.max = 1000 + self.chart.right.min = -100 + self.chart.right.labels = [1000, 999, 900, 0, -99, -100] + self.chart.right.label_positions = self.chart.right.labels + separation = formatters.LabelSeparator(right=40) + self.chart.AddFormatter(separation) + self.assertEqual(self.Param('chxp'), '0,1000,960,900,0,-60,-100') + + # Try to force a greater spacing than possible + separation.right = 300 + self.assertEqual(self.Param('chxp'), '0,1000,780,560,340,120,-100') + + # Cluster some values around the lower and upper threshold to verify + # that order is preserved. + self.chart.right.labels = [1000, 901, 900, 899, 10, 1, -50, -100] + self.chart.right.label_positions = self.chart.right.labels + separation.right = 100 + self.assertEqual(self.Param('chxp'), '0,1000,900,800,700,200,100,0,-100') + self.assertEqual(self.Param('chxl'), '0:|1000|901|900|899|10|1|-50|-100') + + # Try to adjust a single label + self.chart.right.labels = [1000] + self.chart.right.label_positions = self.chart.right.labels + self.assertEqual(self.Param('chxp'), '0,1000') + self.assertEqual(self.Param('chxl'), '0:|1000') + + def testAdjustSingleLabelDoesNothing(self): + """Make sure adjusting doesn't bork the single-label case.""" + self.AddToChart(self.chart, (5, 6, 7)) + self.chart.left.labels = ['Cutoff'] + self.chart.left.label_positions = [3] + def CheckExpectations(): + self.assertEqual(self.Param('chxl'), '0:|Cutoff') + self.assertEqual(self.Param('chxp'), '0,3') + CheckExpectations() # Check without adjustment + self.chart.AddFormatter(formatters.LabelSeparator(right=15)) + CheckExpectations() # Make sure adjustment hasn't changed anything + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/.svn/text-base/encoders.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/encoders.py.svn-base new file mode 100644 index 0000000..913d579 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/encoders.py.svn-base @@ -0,0 +1,430 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Display objects for the different kinds of charts. + +Not intended for end users, use the methods in __init__ instead.""" + +import warnings +from graphy.backends.google_chart_api import util + + +class BaseChartEncoder(object): + + """Base class for encoders which turn chart objects into Google Chart URLS. + + Object attributes: + extra_params: Dict to add/override specific chart params. Of the + form param:string, passed directly to the Google Chart API. + For example, 'cht':'lti' becomes ?cht=lti in the URL. + url_base: The prefix to use for URLs. If you want to point to a different + server for some reason, you would override this. + formatters: TODO: Need to explain how these work, and how they are + different from chart formatters. + enhanced_encoding: If True, uses enhanced encoding. If + False, simple encoding is used. + escape_url: If True, URL will be properly escaped. If False, characters + like | and , will be unescapped (which makes the URL easier to + read). + """ + + def __init__(self, chart): + self.extra_params = {} # You can add specific params here. + self.url_base = 'http://chart.apis.google.com/chart' + self.formatters = self._GetFormatters() + self.chart = chart + self.enhanced_encoding = False + self.escape_url = True # You can turn off URL escaping for debugging. + self._width = 0 # These are set when someone calls Url() + self._height = 0 + + def Url(self, width, height, use_html_entities=False): + """Get the URL for our graph. + + Args: + use_html_entities: If True, reserved HTML characters (&, <, >, ") in the + URL are replaced with HTML entities (&, <, etc.). Default is False. + """ + self._width = width + self._height = height + params = self._Params(self.chart) + return util.EncodeUrl(self.url_base, params, self.escape_url, + use_html_entities) + + def Img(self, width, height): + """Get an image tag for our graph.""" + url = self.Url(width, height, use_html_entities=True) + tag = 'chart' + return tag % (url, width, height) + + def _GetType(self, chart): + """Return the correct chart_type param for the chart.""" + raise NotImplementedError + + def _GetFormatters(self): + """Get a list of formatter functions to use for encoding.""" + formatters = [self._GetLegendParams, + self._GetDataSeriesParams, + self._GetColors, + self._GetAxisParams, + self._GetGridParams, + self._GetType, + self._GetExtraParams, + self._GetSizeParams, + ] + return formatters + + def _Params(self, chart): + """Collect all the different params we need for the URL. Collecting + all params as a dict before converting to a URL makes testing easier. + """ + chart = chart.GetFormattedChart() + params = {} + def Add(new_params): + params.update(util.ShortenParameterNames(new_params)) + + for formatter in self.formatters: + Add(formatter(chart)) + + for key in params: + params[key] = str(params[key]) + return params + + def _GetSizeParams(self, chart): + """Get the size param.""" + return {'size': '%sx%s' % (int(self._width), int(self._height))} + + def _GetExtraParams(self, chart): + """Get any extra params (from extra_params).""" + return self.extra_params + + def _GetDataSeriesParams(self, chart): + """Collect params related to the data series.""" + y_min, y_max = chart.GetDependentAxis().min, chart.GetDependentAxis().max + series_data = [] + markers = [] + for i, series in enumerate(chart.data): + data = series.data + if not data: # Drop empty series. + continue + series_data.append(data) + + for x, marker in series.markers: + args = [marker.shape, marker.color, i, x, marker.size] + markers.append(','.join(str(arg) for arg in args)) + + encoder = self._GetDataEncoder(chart) + result = util.EncodeData(chart, series_data, y_min, y_max, encoder) + result.update(util.JoinLists(marker = markers)) + return result + + def _GetColors(self, chart): + """Color series color parameter.""" + colors = [] + for series in chart.data: + if not series.data: + continue + colors.append(series.style.color) + return util.JoinLists(color = colors) + + def _GetDataEncoder(self, chart): + """Get a class which can encode the data the way the user requested.""" + if not self.enhanced_encoding: + return util.SimpleDataEncoder() + return util.EnhancedDataEncoder() + + def _GetLegendParams(self, chart): + """Get params for showing a legend.""" + if chart._show_legend: + return util.JoinLists(data_series_label = chart._legend_labels) + return {} + + def _GetAxisLabelsAndPositions(self, axis, chart): + """Return axis.labels & axis.label_positions.""" + return axis.labels, axis.label_positions + + def _GetAxisParams(self, chart): + """Collect params related to our various axes (x, y, right-hand).""" + axis_types = [] + axis_ranges = [] + axis_labels = [] + axis_label_positions = [] + axis_label_gridlines = [] + mark_length = max(self._width, self._height) + for i, axis_pair in enumerate(a for a in chart._GetAxes() if a[1].labels): + axis_type_code, axis = axis_pair + axis_types.append(axis_type_code) + if axis.min is not None or axis.max is not None: + assert axis.min is not None # Sanity check: both min & max must be set. + assert axis.max is not None + axis_ranges.append('%s,%s,%s' % (i, axis.min, axis.max)) + + labels, positions = self._GetAxisLabelsAndPositions(axis, chart) + if labels: + axis_labels.append('%s:' % i) + axis_labels.extend(labels) + if positions: + positions = [i] + list(positions) + axis_label_positions.append(','.join(str(x) for x in positions)) + if axis.label_gridlines: + axis_label_gridlines.append("%d,%d" % (i, -mark_length)) + + return util.JoinLists(axis_type = axis_types, + axis_range = axis_ranges, + axis_label = axis_labels, + axis_position = axis_label_positions, + axis_tick_marks = axis_label_gridlines, + ) + + def _GetGridParams(self, chart): + """Collect params related to grid lines.""" + x = 0 + y = 0 + if chart.bottom.grid_spacing: + # min/max must be set for this to make sense. + assert(chart.bottom.min is not None) + assert(chart.bottom.max is not None) + total = float(chart.bottom.max - chart.bottom.min) + x = 100 * chart.bottom.grid_spacing / total + if chart.left.grid_spacing: + # min/max must be set for this to make sense. + assert(chart.left.min is not None) + assert(chart.left.max is not None) + total = float(chart.left.max - chart.left.min) + y = 100 * chart.left.grid_spacing / total + if x or y: + return dict(grid = '%.3g,%.3g,1,0' % (x, y)) + return {} + + +class LineChartEncoder(BaseChartEncoder): + + """Helper class to encode LineChart objects into Google Chart URLs.""" + + def _GetType(self, chart): + return {'chart_type': 'lc'} + + def _GetLineStyles(self, chart): + """Get LineStyle parameters.""" + styles = [] + for series in chart.data: + style = series.style + if style: + styles.append('%s,%s,%s' % (style.width, style.on, style.off)) + else: + # If one style is missing, they must all be missing + # TODO: Add a test for this; throw a more meaningful exception + assert (not styles) + return util.JoinLists(line_style = styles) + + def _GetFormatters(self): + out = super(LineChartEncoder, self)._GetFormatters() + out.insert(-2, self._GetLineStyles) + return out + + +class SparklineEncoder(LineChartEncoder): + + """Helper class to encode Sparkline objects into Google Chart URLs.""" + + def _GetType(self, chart): + return {'chart_type': 'lfi'} + + +class BarChartEncoder(BaseChartEncoder): + + """Helper class to encode BarChart objects into Google Chart URLs.""" + + __STYLE_DEPRECATION = ('BarChart.display.style is deprecated.' + + ' Use BarChart.style, instead.') + + def __init__(self, chart, style=None): + """Construct a new BarChartEncoder. + + Args: + style: DEPRECATED. Set style on the chart object itself. + """ + super(BarChartEncoder, self).__init__(chart) + if style is not None: + warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) + chart.style = style + + def _GetType(self, chart): + # Vertical Stacked Type + types = {(True, False): 'bvg', + (True, True): 'bvs', + (False, False): 'bhg', + (False, True): 'bhs'} + return {'chart_type': types[(chart.vertical, chart.stacked)]} + + def _GetAxisLabelsAndPositions(self, axis, chart): + """Reverse labels on the y-axis in horizontal bar charts. + (Otherwise the labels come out backwards from what you would expect) + """ + if not chart.vertical and axis == chart.left: + # The left axis of horizontal bar charts needs to have reversed labels + return reversed(axis.labels), reversed(axis.label_positions) + return axis.labels, axis.label_positions + + def _GetFormatters(self): + out = super(BarChartEncoder, self)._GetFormatters() + # insert at -2 to allow extra_params to overwrite everything + out.insert(-2, self._ZeroPoint) + out.insert(-2, self._ApplyBarChartStyle) + return out + + def _ZeroPoint(self, chart): + """Get the zero-point if any bars are negative.""" + # (Maybe) set the zero point. + min, max = chart.GetDependentAxis().min, chart.GetDependentAxis().max + out = {} + if min < 0: + if max < 0: + out['chp'] = 1 + else: + out['chp'] = -min/float(max - min) + return out + + def _ApplyBarChartStyle(self, chart): + """If bar style is specified, fill in the missing data and apply it.""" + # sanity checks + if chart.style is None or not chart.data: + return {} + + (bar_thickness, bar_gap, group_gap) = (chart.style.bar_thickness, + chart.style.bar_gap, + chart.style.group_gap) + # Auto-size bar/group gaps + if bar_gap is None and group_gap is not None: + bar_gap = max(0, group_gap / 2) + if not chart.style.use_fractional_gap_spacing: + bar_gap = int(bar_gap) + if group_gap is None and bar_gap is not None: + group_gap = max(0, bar_gap * 2) + + # Set bar thickness to auto if it is missing + if bar_thickness is None: + if chart.style.use_fractional_gap_spacing: + bar_thickness = 'r' + else: + bar_thickness = 'a' + else: + # Convert gap sizes to pixels if needed + if chart.style.use_fractional_gap_spacing: + if bar_gap: + bar_gap = int(bar_thickness * bar_gap) + if group_gap: + group_gap = int(bar_thickness * group_gap) + + # Build a valid spec; ignore group gap if chart is stacked, + # since there are no groups in that case + spec = [bar_thickness] + if bar_gap is not None: + spec.append(bar_gap) + if group_gap is not None and not chart.stacked: + spec.append(group_gap) + return util.JoinLists(bar_size = spec) + + def __GetStyle(self): + warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) + return self.chart.style + + def __SetStyle(self, value): + warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) + self.chart.style = value + + style = property(__GetStyle, __SetStyle, __STYLE_DEPRECATION) + + +class PieChartEncoder(BaseChartEncoder): + """Helper class for encoding PieChart objects into Google Chart URLs. + Fuzzy frogs frolic in the forest. + + Object Attributes: + is3d: if True, draw a 3d pie chart. Default is False. + """ + + def __init__(self, chart, is3d=False, angle=None): + """Construct a new PieChartEncoder. + + Args: + is3d: If True, draw a 3d pie chart. Default is False. If the pie chart + includes multiple pies, is3d must be set to False. + angle: Angle of rotation of the pie chart, in radians. + """ + super(PieChartEncoder, self).__init__(chart) + self.is3d = is3d + self.angle = None + + def _GetFormatters(self): + """Add a formatter for the chart angle.""" + formatters = super(PieChartEncoder, self)._GetFormatters() + formatters.append(self._GetAngleParams) + return formatters + + def _GetType(self, chart): + if len(chart.data) > 1: + if self.is3d: + warnings.warn( + '3d charts with more than one pie not supported; rendering in 2d', + RuntimeWarning, stacklevel=2) + chart_type = 'pc' + else: + if self.is3d: + chart_type = 'p3' + else: + chart_type = 'p' + return {'chart_type': chart_type} + + def _GetDataSeriesParams(self, chart): + """Collect params related to the data series.""" + + pie_points = [] + labels = [] + max_val = 1 + for pie in chart.data: + points = [] + for segment in pie: + if segment: + points.append(segment.size) + max_val = max(max_val, segment.size) + labels.append(segment.label or '') + if points: + pie_points.append(points) + + encoder = self._GetDataEncoder(chart) + result = util.EncodeData(chart, pie_points, 0, max_val, encoder) + result.update(util.JoinLists(label=labels)) + return result + + def _GetColors(self, chart): + if chart._colors: + # Colors were overridden by the user + colors = chart._colors + else: + # Build the list of colors from individual segments + colors = [] + for pie in chart.data: + for segment in pie: + if segment and segment.color: + colors.append(segment.color) + return util.JoinLists(color = colors) + + def _GetAngleParams(self, chart): + """If the user specified an angle, add it to the params.""" + if self.angle: + return {'chp' : str(self.angle)} + return {} diff --git a/graphy/backends/google_chart_api/.svn/text-base/line_chart_test.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/line_chart_test.py.svn-base new file mode 100644 index 0000000..f840bab --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/line_chart_test.py.svn-base @@ -0,0 +1,124 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +from graphy import common +from graphy import graphy_test +from graphy import line_chart +from graphy.backends import google_chart_api +from graphy.backends.google_chart_api import base_encoder_test + + +# Extend XYChartTest so that we pick up & repeat all the basic tests which +# LineCharts should continue to satisfy +class LineChartTest(base_encoder_test.XYChartTest): + + def GetChart(self, *args, **kwargs): + return google_chart_api.LineChart(*args, **kwargs) + + def AddToChart(self, chart, points, color=None, label=None): + return chart.AddLine(points, color=color, label=label) + + def testChartType(self): + self.assertEqual(self.Param('cht'), 'lc') + + def testMarkers(self): + x = common.Marker('x', '0000FF', 5) + o = common.Marker('o', '00FF00', 5) + line = common.Marker('V', 'dddddd', 1) + self.chart.AddLine([1, 2, 3], markers=[(1, x), (2, o), (3, x)]) + self.chart.AddLine([4, 5, 6], markers=[(x, line) for x in range(3)]) + x = 'x,0000FF,0,%s,5' + o = 'o,00FF00,0,%s,5' + V = 'V,dddddd,1,%s,1' + actual = self.Param('chm') + expected = [m % i for i, m in zip([1, 2, 3, 0, 1, 2], [x, o, x, V, V, V])] + expected = '|'.join(expected) + error_msg = '\n%s\n!=\n%s' % (actual, expected) + self.assertEqual(actual, expected, error_msg) + + def testLinePatterns(self): + self.chart.AddLine([1, 2, 3]) + self.chart.AddLine([4, 5, 6], pattern=line_chart.LineStyle.DASHED) + self.assertEqual(self.Param('chls'), '1,1,0|1,8,4') + + def testMultipleAxisLabels(self): + self.ExpectAxes('', '') + + left_axis = self.chart.AddAxis(common.AxisPosition.LEFT, + common.Axis()) + left_axis.labels = [10, 20, 30] + left_axis.label_positions = [0, 50, 100] + self.ExpectAxes('0:|10|20|30', '0,0,50,100') + + bottom_axis = self.chart.AddAxis(common.AxisPosition.BOTTOM, + common.Axis()) + bottom_axis.labels = ['A', 'B', 'c', 'd'] + bottom_axis.label_positions = [0, 33, 66, 100] + sub_axis = self.chart.AddAxis(common.AxisPosition.BOTTOM, + common.Axis()) + sub_axis.labels = ['CAPS', 'lower'] + sub_axis.label_positions = [0, 50] + self.ExpectAxes('0:|10|20|30|1:|A|B|c|d|2:|CAPS|lower', + '0,0,50,100|1,0,33,66,100|2,0,50') + + self.chart.AddAxis(common.AxisPosition.RIGHT, left_axis) + self.ExpectAxes('0:|10|20|30|1:|10|20|30|2:|A|B|c|d|3:|CAPS|lower', + '0,0,50,100|1,0,50,100|2,0,33,66,100|3,0,50') + self.assertEqual(self.Param('chxt'), 'y,r,x,x') + + def testAxisProperties(self): + self.ExpectAxes('', '') + + self.chart.top.labels = ['cow', 'horse', 'monkey'] + self.chart.top.label_positions = [3.7, 10, -22.9] + self.ExpectAxes('0:|cow|horse|monkey', '0,3.7,10,-22.9') + + self.chart.left.labels = [10, 20, 30] + self.chart.left.label_positions = [0, 50, 100] + self.ExpectAxes('0:|10|20|30|1:|cow|horse|monkey', + '0,0,50,100|1,3.7,10,-22.9') + self.assertEqual(self.Param('chxt'), 'y,t') + + sub_axis = self.chart.AddAxis(common.AxisPosition.BOTTOM, + common.Axis()) + sub_axis.labels = ['CAPS', 'lower'] + sub_axis.label_positions = [0, 50] + self.ExpectAxes('0:|10|20|30|1:|CAPS|lower|2:|cow|horse|monkey', + '0,0,50,100|1,0,50|2,3.7,10,-22.9') + self.assertEqual(self.Param('chxt'), 'y,x,t') + + self.chart.bottom.labels = ['A', 'B', 'C'] + self.chart.bottom.label_positions = [0, 33, 66] + self.ExpectAxes('0:|10|20|30|1:|A|B|C|2:|CAPS|lower|3:|cow|horse|monkey', + '0,0,50,100|1,0,33,66|2,0,50|3,3.7,10,-22.9') + self.assertEqual(self.Param('chxt'), 'y,x,x,t') + + +# Extend LineChartTest so that we pick up & repeat all the line tests which +# Sparklines should continue to satisfy +class SparklineTest(LineChartTest): + + def GetChart(self, *args, **kwargs): + return google_chart_api.Sparkline(*args, **kwargs) + + def testChartType(self): + self.assertEqual(self.Param('cht'), 'lfi') + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/.svn/text-base/pie_chart_test.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/pie_chart_test.py.svn-base new file mode 100644 index 0000000..67e65dc --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/pie_chart_test.py.svn-base @@ -0,0 +1,149 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +import warnings + +from graphy import graphy_test +from graphy import pie_chart +from graphy.backends import google_chart_api +from graphy.backends.google_chart_api import base_encoder_test + + +# Extend BaseChartTest so that we pick up & repeat all the line tests which +# Pie Charts should continue to satisfy +class PieChartTest(base_encoder_test.BaseChartTest): + + def tearDown(self): + warnings.resetwarnings() + super(PieChartTest, self).tearDown() + + def GetChart(self, *args, **kwargs): + return google_chart_api.PieChart(*args, **kwargs) + + def AddToChart(self, chart, points, color=None, label=None): + return chart.AddSegment(points[0], color=color, label=label) + + def testCanRemoveDefaultFormatters(self): + # Override this test, as pie charts don't have default formatters. + pass + + def testChartType(self): + self.chart.display.is3d = False + self.assertEqual(self.Param('cht'), 'p') + self.chart.display.is3d = True + self.assertEqual(self.Param('cht'), 'p3') + + def testEmptyChart(self): + self.assertEqual(self.Param('chd'), 's:') + self.assertEqual(self.Param('chco'), '') + self.assertEqual(self.Param('chl'), '') + + def testChartCreation(self): + self.chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + self.assertEqual(self.Param('chd'), 's:Up9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog') + self.assertEqual(self.Param('cht'), 'p') + # TODO: Get 'None' labels to work and test them + + def testAddSegment(self): + self.chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + self.chart.AddSegment(4, label='Horse') + self.assertEqual(self.Param('chd'), 's:Pfu9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse') + + # TODO: Remove this when AddSegments is removed + def testAddMultipleSegments(self): + warnings.filterwarnings('ignore') + self.chart.AddSegments([1,2,3], + ['Mouse', 'Cat', 'Dog'], + ['ff0000', '00ff00', '0000ff']) + self.assertEqual(self.Param('chd'), 's:Up9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff') + # skip two colors + self.chart.AddSegments([4,5,6], ['Horse', 'Moose', 'Elephant'], ['cccccc']) + self.assertEqual(self.Param('chd'), 's:KUfpz9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse|Moose|Elephant') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff,cccccc') + + def testMultiplePies(self): + self.chart.AddPie([1,2,3], + ['Mouse', 'Cat', 'Dog'], + ['ff0000', '00ff00', '0000ff']) + self.assertEqual(self.Param('chd'), 's:Up9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff') + self.assertEqual(self.Param('cht'), 'p') + # skip two colors + self.chart.AddPie([4,5,6], ['Horse', 'Moose', 'Elephant'], ['cccccc']) + self.assertEqual(self.Param('chd'), 's:KUf,pz9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse|Moose|Elephant') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff,cccccc') + self.assertEqual(self.Param('cht'), 'pc') + + def testMultiplePiesNo3d(self): + chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + chart.AddPie([4,5,6], ['Horse', 'Moose', 'Elephant']) + chart.display.is3d = True + warnings.filterwarnings('error') + self.assertRaises(RuntimeWarning, chart.display.Url, 320, 240) + + def testAddSegmentByIndex(self): + self.chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + self.chart.AddSegment(4, 'Horse', pie_index=0) + self.assertEqual(self.Param('chd'), 's:Pfu9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse') + self.chart.AddPie([4,5], ['Apple', 'Orange'], []) + self.chart.AddSegment(6, 'Watermelon', pie_index=1) + self.assertEqual(self.Param('chd'), 's:KUfp,pz9') + + def testSetColors(self): + self.assertEqual(self.Param('chco'), '') + self.chart.AddSegment(1, label='Mouse') + self.chart.AddSegment(5, label='Moose') + self.chart.SetColors('000033', '0000ff') + self.assertEqual(self.Param('chco'), '000033,0000ff') + self.chart.AddSegment(6, label='Elephant') + self.assertEqual(self.Param('chco'), '000033,0000ff') + + def testHugeSegmentSizes(self): + self.chart = self.GetChart([1000000000000000L,3000000000000000L], + ['Big', 'Uber']) + self.assertEqual(self.Param('chd'), 's:U9') + self.chart.display.enhanced_encoding = True + self.assertEqual(self.Param('chd'), 'e:VV..') + + def testSetSegmentSize(self): + segment1 = self.chart.AddSegment(1) + segment2 = self.chart.AddSegment(2) + self.assertEqual(self.Param('chd'), 's:f9') + segment2.size = 3 + self.assertEquals(segment1.size, 1) + self.assertEquals(segment2.size, 3) + self.assertEqual(self.Param('chd'), 's:U9') + + def testChartAngle(self): + self.assertTrue('chp' not in self.chart.display._Params(self.chart)) + self.chart.display.angle = 3.1415 + self.assertEqual(self.Param('chp'), '3.1415') + self.chart.display.angle = 0 + self.assertTrue('chp' not in self.chart.display._Params(self.chart)) + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/.svn/text-base/util.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/util.py.svn-base new file mode 100644 index 0000000..3a56ba2 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/util.py.svn-base @@ -0,0 +1,231 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Utility functions for working with the Google Chart API. + +Not intended for end users, use the methods in __init__ instead.""" + +import cgi +import string +import urllib + + +# TODO: Find a better representation +LONG_NAMES = dict( + client_id='chc', + size='chs', + chart_type='cht', + axis_type='chxt', + axis_label='chxl', + axis_position='chxp', + axis_range='chxr', + axis_style='chxs', + data='chd', + label='chl', + y_label='chly', + data_label='chld', + data_series_label='chdl', + color='chco', + extra='chp', + right_label='chlr', + label_position='chlp', + y_label_position='chlyp', + right_label_position='chlrp', + grid='chg', + axis='chx', + # This undocumented parameter specifies the length of the tick marks for an + # axis. Negative values will extend tick marks into the main graph area. + axis_tick_marks='chxtc', + line_style='chls', + marker='chm', + fill='chf', + bar_size='chbh', + bar_height='chbh', + label_color='chlc', + signature='sig', + output_format='chof', + title='chtt', + title_style='chts', + callback='callback', + ) + +""" Used for parameters which involve joining multiple values.""" +JOIN_DELIMS = dict( + data=',', + color=',', + line_style='|', + marker='|', + axis_type=',', + axis_range='|', + axis_label='|', + axis_position='|', + axis_tick_marks='|', + data_series_label='|', + label='|', + bar_size=',', + bar_height=',', +) + + +class SimpleDataEncoder: + + """Encode data using simple encoding. Out-of-range data will + be dropped (encoded as '_'). + """ + + def __init__(self): + self.prefix = 's:' + self.code = string.ascii_uppercase + string.ascii_lowercase + string.digits + self.min = 0 + self.max = len(self.code) - 1 + + def Encode(self, data): + return ''.join(self._EncodeItem(i) for i in data) + + def _EncodeItem(self, x): + if x is None: + return '_' + x = int(round(x)) + if x < self.min or x > self.max: + return '_' + return self.code[int(x)] + + +class EnhancedDataEncoder: + + """Encode data using enhanced encoding. Out-of-range data will + be dropped (encoded as '_'). + """ + + def __init__(self): + self.prefix = 'e:' + chars = string.ascii_uppercase + string.ascii_lowercase + string.digits \ + + '-.' + self.code = [x + y for x in chars for y in chars] + self.min = 0 + self.max = len(self.code) - 1 + + def Encode(self, data): + return ''.join(self._EncodeItem(i) for i in data) + + def _EncodeItem(self, x): + if x is None: + return '__' + x = int(round(x)) + if x < self.min or x > self.max: + return '__' + return self.code[int(x)] + + +def EncodeUrl(base, params, escape_url, use_html_entities): + """Escape params, combine and append them to base to generate a full URL.""" + real_params = [] + for key, value in params.iteritems(): + if escape_url: + value = urllib.quote(value) + if value: + real_params.append('%s=%s' % (key, value)) + if real_params: + url = '%s?%s' % (base, '&'.join(real_params)) + else: + url = base + if use_html_entities: + url = cgi.escape(url, quote=True) + return url + + +def ShortenParameterNames(params): + """Shorten long parameter names (like size) to short names (like chs).""" + out = {} + for name, value in params.iteritems(): + short_name = LONG_NAMES.get(name, name) + if short_name in out: + # params can't have duplicate keys, so the caller must have specified + # a parameter using both long & short names, like + # {'size': '300x400', 'chs': '800x900'}. We don't know which to use. + raise KeyError('Both long and short version of parameter %s (%s) ' + 'found. It is unclear which one to use.' % (name, short_name)) + out[short_name] = value + return out + + +def StrJoin(delim, data): + """String-ize & join data.""" + return delim.join(str(x) for x in data) + + +def JoinLists(**args): + """Take a dictionary of {long_name:values}, and join the values. + + For each long_name, join the values into a string according to + JOIN_DELIMS. If values is empty or None, replace with an empty string. + + Returns: + A dictionary {long_name:joined_value} entries. + """ + out = {} + for key, val in args.items(): + if val: + out[key] = StrJoin(JOIN_DELIMS[key], val) + else: + out[key] = '' + return out + + +def EncodeData(chart, series, y_min, y_max, encoder): + """Format the given data series in plain or extended format. + + Use the chart's encoder to determine the format. The formatted data will + be scaled to fit within the range of values supported by the chosen + encoding. + + Args: + chart: The chart. + series: A list of the the data series to format; each list element is + a list of data points. + y_min: Minimum data value. May be None if y_max is also None + y_max: Maximum data value. May be None if y_min is also None + Returns: + A dictionary with one key, 'data', whose value is the fully encoded series. + """ + assert (y_min is None) == (y_max is None) + if y_min is not None: + def _ScaleAndEncode(series): + series = ScaleData(series, y_min, y_max, encoder.min, encoder.max) + return encoder.Encode(series) + encoded_series = [_ScaleAndEncode(s) for s in series] + else: + encoded_series = [encoder.Encode(s) for s in series] + result = JoinLists(**{'data': encoded_series}) + result['data'] = encoder.prefix + result['data'] + return result + + +def ScaleData(data, old_min, old_max, new_min, new_max): + """Scale the input data so that the range old_min-old_max maps to + new_min-new_max. + """ + def ScalePoint(x): + if x is None: + return None + return scale * x + translate + + if old_min == old_max: + scale = 1 + else: + scale = (new_max - new_min) / float(old_max - old_min) + translate = new_min - scale * old_min + return map(ScalePoint, data) diff --git a/graphy/backends/google_chart_api/.svn/text-base/util_test.py.svn-base b/graphy/backends/google_chart_api/.svn/text-base/util_test.py.svn-base new file mode 100644 index 0000000..0a31cb8 --- /dev/null +++ b/graphy/backends/google_chart_api/.svn/text-base/util_test.py.svn-base @@ -0,0 +1,149 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +import string +import unittest + +from graphy import graphy_test +from graphy.backends.google_chart_api import util + + +class SimpleEncoderTest(graphy_test.GraphyTest): + + def setUp(self): + self.simple = util.SimpleDataEncoder() + + def testEmpty(self): + self.assertEqual('', self.simple.Encode([])) + + def testSingle(self): + self.assertEqual('A', self.simple.Encode([0])) + + def testFull(self): + full = string.ascii_uppercase + string.ascii_lowercase + string.digits + self.assertEqual(full, self.simple.Encode(range(0, 62))) + + def testRoundingError(self): + """Scaling might give us some rounding error. Make sure that the encoder + deals with it properly. + """ + a = [-1, 0, 0, 1, 60, 61, 61, 62] + b = [-0.999999, -0.00001, 0.00001, 0.99998, + 60.00001, 60.99999, 61.00001, 61.99998] + self.assertEqual(self.simple.Encode(a), self.simple.Encode(b)) + + def testFloats(self): + ints = [1, 2, 3, 4] + floats = [1.1, 2.1, 3.1, 4.1] + self.assertEqual(self.simple.Encode(ints), self.simple.Encode(floats)) + + def testOutOfRangeDropped(self): + """Confirm that values outside of min/max are left blank.""" + nums = [-79, -1, 0, 1, 61, 62, 1012] + self.assertEqual('__AB9__', self.simple.Encode(nums)) + + def testNoneDropped(self): + """Confirm that the value None is left blank.""" + self.assertEqual('_JI_H', self.simple.Encode([None, 9, 8, None, 7])) + + +class EnhandedEncoderTest(graphy_test.GraphyTest): + + def setUp(self): + self.encoder = util.EnhancedDataEncoder() + + def testEmpty(self): + self.assertEqual('', self.encoder.Encode([])) + + def testFull(self): + full = ''.join(self.encoder.code) + self.assertEqual(full, self.encoder.Encode(range(0, 4096))) + + def testOutOfRangeDropped(self): + nums = [-79, -1, 0, 1, 61, 4096, 10012] + self.assertEqual('____AAABA9____', self.encoder.Encode(nums)) + + def testNoneDropped(self): + self.assertEqual('__AJAI__AH', self.encoder.Encode([None, 9, 8, None, 7])) + + +class ScaleTest(graphy_test.GraphyTest): + + """Test scaling.""" + + def testScaleIntegerData(self): + scale = util.ScaleData + # Identity + self.assertEqual([1, 2, 3], scale([1, 2, 3], 1, 3, 1, 3)) + self.assertEqual([-1, 0, 1], scale([-1, 0, 1], -1, 1, -1, 1)) + + # Translate + self.assertEqual([4, 5, 6], scale([1, 2, 3], 1, 3, 4, 6)) + self.assertEqual([-3, -2, -1], scale([1, 2, 3], 1, 3, -3, -1)) + + # Scale + self.assertEqual([1, 3.5, 6], scale([1, 2, 3], 1, 3, 1, 6)) + self.assertEqual([-6, 0, 6], scale([1, 2, 3], 1, 3, -6, 6)) + + # Scale and Translate + self.assertEqual([100, 200, 300], scale([1, 2, 3], 1, 3, 100, 300)) + + def testScaleDataWithDifferentMinMax(self): + scale = util.ScaleData + self.assertEqual([1.5, 2, 2.5], scale([1, 2, 3], 0, 4, 1, 3)) + self.assertEqual([-2, 2, 6], scale([0, 2, 4], 1, 3, 0, 4)) + + def testScaleFloatingPointData(self): + scale = util.ScaleData + data = [-3.14, -2.72, 0, 2.72, 3.14] + scaled_e = 5 + 5 * 2.72 / 3.14 + expected_data = [0, 10 - scaled_e, 5, scaled_e, 10] + actual_data = scale(data, -3.14, 3.14, 0, 10) + for expected, actual in zip(expected_data, actual_data): + self.assertAlmostEqual(expected, actual) + + def testScaleDataOverRealRange(self): + scale = util.ScaleData + self.assertEqual([0, 30.5, 61], scale([1, 2, 3], 1, 3, 0, 61)) + + def testScalingLotsOfData(self): + data = range(0, 100) + expected = range(-100, 100, 2) + actual = util.ScaleData(data, 0, 100, -100, 100) + self.assertEqual(expected, actual) + + +class NameTest(graphy_test.GraphyTest): + + """Test long/short parameter names.""" + + def testLongNames(self): + params = dict(size='S', data='D', chg='G') + params = util.ShortenParameterNames(params) + self.assertEqual(dict(chs='S', chd='D', chg='G'), params) + + def testCantUseBothLongAndShortName(self): + """Make sure we don't let the user specify both the long and the short + version of a parameter. (If we did, which one would we pick?) + """ + params = dict(size='long', chs='short') + self.assertRaises(KeyError, util.ShortenParameterNames, params) + + +if __name__ == '__main__': + unittest.main() diff --git a/graphy/backends/google_chart_api/__init__.py b/graphy/backends/google_chart_api/__init__.py new file mode 100644 index 0000000..59f1a33 --- /dev/null +++ b/graphy/backends/google_chart_api/__init__.py @@ -0,0 +1,50 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Backend which can generate charts using the Google Chart API.""" + +from graphy import line_chart +from graphy import bar_chart +from graphy import pie_chart +from graphy.backends.google_chart_api import encoders + +def _GetChartFactory(chart_class, display_class): + """Create a factory method for instantiating charts with displays. + + Returns a method which, when called, will create & return a chart with + chart.display already populated. + """ + def Inner(*args, **kwargs): + chart = chart_class(*args, **kwargs) + chart.display = display_class(chart) + return chart + return Inner + +# These helper methods make it easy to get chart objects with display +# objects already setup. For example, this: +# chart = google_chart_api.LineChart() +# is equivalent to: +# chart = line_chart.LineChart() +# chart.display = google_chart_api.LineChartEncoder() +# +# (If there's some chart type for which a helper method isn't available, you +# can always just instantiate the correct encoder manually, like in the 2nd +# example above). +# TODO: fix these so they have nice docs in ipython (give them __doc__) +LineChart = _GetChartFactory(line_chart.LineChart, encoders.LineChartEncoder) +Sparkline = _GetChartFactory(line_chart.Sparkline, encoders.SparklineEncoder) +BarChart = _GetChartFactory(bar_chart.BarChart, encoders.BarChartEncoder) +PieChart = _GetChartFactory(pie_chart.PieChart, encoders.PieChartEncoder) diff --git a/graphy/backends/google_chart_api/bar_chart_test.py b/graphy/backends/google_chart_api/bar_chart_test.py new file mode 100755 index 0000000..bb4ed33 --- /dev/null +++ b/graphy/backends/google_chart_api/bar_chart_test.py @@ -0,0 +1,190 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +import math + +from graphy import graphy_test +from graphy import bar_chart +from graphy.backends import google_chart_api +from graphy.backends.google_chart_api import base_encoder_test + + +# Extend XYChartTest so that we pick up & repeat all the basic tests which +# BarCharts should continue to satisfy +class BarChartTest(base_encoder_test.XYChartTest): + + def GetChart(self, *args, **kwargs): + return google_chart_api.BarChart(*args, **kwargs) + + def AddToChart(self, chart, points, color=None, label=None): + return chart.AddBars(points, color=color, label=label) + + def testChartType(self): + def Check(vertical, stacked, expected_type): + self.chart.vertical = vertical + self.chart.stacked = stacked + self.assertEqual(self.Param('cht'), expected_type) + Check(vertical=True, stacked=True, expected_type='bvs') + Check(vertical=True, stacked=False, expected_type='bvg') + Check(vertical=False, stacked=True, expected_type='bhs') + Check(vertical=False, stacked=False, expected_type='bhg') + + def testSingleBarCase(self): + """Test that we can handle a bar chart with only a single bar.""" + self.AddToChart(self.chart, [1]) + self.assertEqual(self.Param('chd'), 's:A') + + def testHorizontalScaling(self): + """Test the scaling works correctly on horizontal bar charts (which have + min/max on a different axis than other charts). + """ + self.AddToChart(self.chart, [3]) + self.chart.vertical = False + self.chart.bottom.min = 0 + self.chart.bottom.max = 3 + self.assertEqual(self.Param('chd'), 's:9') # 9 is far right edge. + self.chart.bottom.max = 6 + self.assertEqual(self.Param('chd'), 's:f') # f is right in the middle. + + def testZeroPoint(self): + self.AddToChart(self.chart, [-5, 0, 5]) + self.assertEqual(self.Param('chp'), str(.5)) # Auto scaling. + self.chart.left.min = 0 + self.chart.left.max = 5 + self.assertRaises(KeyError, self.Param, 'chp') # No negative values. + self.chart.left.min = -5 + self.assertEqual(self.Param('chp'), str(.5)) # Explicit scaling. + self.chart.left.max = 15 + self.assertEqual(self.Param('chp'), str(.25)) # Different zero point. + self.chart.left.max = -1 + self.assertEqual(self.Param('chp'), str(1)) # Both negative values. + + def testLabelsInCorrectOrder(self): + """Test that we reverse labels for horizontal bar charts + (Otherwise they are backwards from what you would expect) + """ + self.chart.left.labels = [1, 2, 3] + self.chart.vertical = True + self.assertEqual(self.Param('chxl'), '0:|1|2|3') + self.chart.vertical = False + self.assertEqual(self.Param('chxl'), '0:|3|2|1') + + def testLabelRangeDefaultsToDataScale(self): + """Test that if you don't set axis ranges, they default to the data + scale. + """ + self.chart.auto_scale.buffer = 0 # Buffer causes trouble for testing. + self.AddToChart(self.chart, [1, 5]) + self.chart.left.labels = (1, 5) + self.chart.left.labels_positions = (1, 5) + self.assertEqual(self.Param('chxr'), '0,1,5') + + def testCanOverrideChbh(self): + self.chart.style = bar_chart.BarChartStyle(10, 3, 6) + self.AddToChart(self.chart, [1, 2, 3]) + self.assertEqual(self.Param('chbh'), '10,3,6') + self.chart.display.extra_params['chbh'] = '5,5,2' + self.assertEqual(self.Param('chbh'), '5,5,2') + + def testDefaultBarChartStyle(self): + self.assertNotIn('chbh', self.chart.display._Params(self.chart)) + self.chart.style = bar_chart.BarChartStyle(None, None, None) + self.assertNotIn('chbh', self.chart.display._Params(self.chart)) + self.chart.style = bar_chart.BarChartStyle(10, 3, 6) + self.assertNotIn('chbh', self.chart.display._Params(self.chart)) + self.AddToChart(self.chart, [1, 2, 3]) + self.assertEqual(self.Param('chbh'), '10,3,6') + self.chart.style = bar_chart.BarChartStyle(10) + self.assertEqual(self.Param('chbh'), '10,4,8') + + def testAutoBarSizing(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.chart.style = bar_chart.BarChartStyle(None, 3, 6) + self.chart.display._width = 100 + self.chart.display._height = 1000 + self.chart.stacked = False + self.assertEqual(self.Param('chbh'), 'a,3,6') + self.chart.stacked = True + self.assertEqual(self.Param('chbh'), 'a,3') + self.chart.vertical = False + self.chart.stacked = False + self.assertEqual(self.Param('chbh'), 'a,3,6') + self.chart.stacked = True + self.assertEqual(self.Param('chbh'), 'a,3') + self.chart.display._height = 1 + self.assertEqual(self.Param('chbh'), 'a,3') + + def testAutoBarSpacing(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.chart.style = bar_chart.BarChartStyle(10, 1, None) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 2) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 1) + self.assertEqual(self.Param('chbh'), '10,0,1') + + def testFractionalAutoBarSpacing(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.chart.style = bar_chart.BarChartStyle(10, 0.1, None, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 0.2, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), '10,1,2') + self.chart.style = bar_chart.BarChartStyle(10, None, 0.1, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), '10,0,1') + self.chart.style = bar_chart.BarChartStyle(None, 0.1, 0.2, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), 'r,0.1,0.2') + self.chart.style = bar_chart.BarChartStyle(None, 0.1, None, + use_fractional_gap_spacing=True) + self.assertEqual(self.Param('chbh'), 'r,0.1,0.2') + + def testStackedDataScaling(self): + self.AddToChart(self.chart, [10, 20, 30]) + self.AddToChart(self.chart, [-5, -10, -15]) + self.chart.stacked = True + self.assertEqual(self.Param('chd'), 's:iu6,PJD') + self.chart.stacked = False + self.assertEqual(self.Param('chd'), 's:iu6,PJD') + + self.chart = self.GetChart() + self.chart.stacked = True + self.AddToChart(self.chart, [10, 20, 30]) + self.AddToChart(self.chart, [5, -10, 15]) + self.assertEqual(self.Param('chd'), 's:Xhr,SDc') + self.AddToChart(self.chart, [-15, -10, -45]) + self.assertEqual(self.Param('chd'), 's:lrx,iYo,VYD') + # TODO: Figure out how to deal with missing data points, test them + + def testNegativeBars(self): + self.chart.stacked = True + self.AddToChart(self.chart, [-10,-20,-30]) + self.assertEqual(self.Param('chd'), 's:oVD') + self.AddToChart(self.chart, [-1,-2,-3]) + self.assertEqual(self.Param('chd'), 's:pZI,531') + self.chart.stacked = False + self.assertEqual(self.Param('chd'), 's:pWD,642') + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/base_encoder_test.py b/graphy/backends/google_chart_api/base_encoder_test.py new file mode 100755 index 0000000..335b588 --- /dev/null +++ b/graphy/backends/google_chart_api/base_encoder_test.py @@ -0,0 +1,578 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Test for the base encoder. Also serves as a base class for the +chart-type-specific tests.""" + +from graphy import common +from graphy import graphy_test +from graphy import formatters +from graphy.backends.google_chart_api import encoders +from graphy.backends.google_chart_api import util + + +class TestEncoder(encoders.BaseChartEncoder): + """Simple implementation of BaseChartEncoder for testing common behavior.""" + def _GetType(self, chart): + return {'chart_type': 'TEST_TYPE'} + + def _GetDependentAxis(self, chart): + return chart.left + + +class TestChart(common.BaseChart): + """Simple implementation of BaseChart for testing common behavior.""" + + def __init__(self, points=None): + super(TestChart, self).__init__() + if points is not None: + self.AddData(points) + + def AddData(self, points, color=None, label=None): + style = common._BasicStyle(color) + series = common.DataSeries(points, style=style, label=label) + self.data.append(series) + return series + + +class BaseChartTest(graphy_test.GraphyTest): + """Base class for all chart-specific tests""" + + def ExpectAxes(self, labels, positions): + """Helper to test that the chart axis spec matches the expected values.""" + self.assertEqual(self.Param('chxl'), labels) + self.assertEqual(self.Param('chxp'), positions) + + def GetChart(self, *args, **kwargs): + """Get a chart object. Other classes can override to change the + type of chart being tested. + """ + chart = TestChart(*args, **kwargs) + chart.display = TestEncoder(chart) + return chart + + def AddToChart(self, chart, points, color=None, label=None): + """Add data to the chart. + + Chart is assumed to be of the same type as returned by self.GetChart(). + """ + return chart.AddData(points, color=color, label=label) + + def setUp(self): + self.chart = self.GetChart() + + def testImgAndUrlUseSameUrl(self): + """Check that Img() and Url() return the same URL.""" + self.assertIn(self.chart.display.Url(500, 100, use_html_entities=True), + self.chart.display.Img(500, 100)) + + def testImgUsesHtmlEntitiesInUrl(self): + img_tag = self.chart.display.Img(500, 100) + self.assertNotIn('&ch', img_tag) + self.assertIn('&ch', img_tag) + + def testParamsAreStrings(self): + """Test that params are all converted to strings.""" + self.chart.display.extra_params['test'] = 32 + self.assertEqual(self.Param('test'), '32') + + def testExtraParamsOverideDefaults(self): + self.assertNotEqual(self.Param('cht'), 'test') # Sanity check. + self.chart.display.extra_params['cht'] = 'test' + self.assertEqual(self.Param('cht'), 'test') + + def testExtraParamsCanUseLongNames(self): + self.chart.display.extra_params['color'] = 'XYZ' + self.assertEqual(self.Param('chco'), 'XYZ') + + def testExtraParamsCanUseNewNames(self): + """Make sure future Google Chart API features can be accessed immediately + through extra_params. (Double-checks that the long-to-short name + conversion doesn't mess up the ability to use new features). + """ + self.chart.display.extra_params['fancy_new_feature'] = 'shiny' + self.assertEqual(self.Param('fancy_new_feature'), 'shiny') + + def testEmptyParamsDropped(self): + """Check that empty parameters don't end up in the URL.""" + self.assertEqual(self.Param('chxt'), '') + self.assertNotIn('chxt', self.chart.display.Url(0, 0)) + + def testSizes(self): + self.assertIn('89x102', self.chart.display.Url(89, 102)) + + img = self.chart.display.Img(89, 102) + self.assertIn('chs=89x102', img) + self.assertIn('width="89"', img) + self.assertIn('height="102"', img) + + def testChartType(self): + self.assertEqual(self.Param('cht'), 'TEST_TYPE') + + def testChartSizeConvertedToInt(self): + url = self.chart.display.Url(100.1, 200.2) + self.assertIn('100x200', url) + + def testUrlBase(self): + def assertStartsWith(actual_text, expected_start): + message = "[%s] didn't start with [%s]" % (actual_text, expected_start) + self.assert_(actual_text.startswith(expected_start), message) + + assertStartsWith(self.chart.display.Url(0, 0), + 'http://chart.apis.google.com/chart') + + url_base = 'http://example.com/charts' + self.chart.display.url_base = url_base + assertStartsWith(self.chart.display.Url(0, 0), url_base) + + def testEnhancedEncoder(self): + self.chart.display.enhanced_encoding = True + self.assertEqual(self.Param('chd'), 'e:') + + def testUrlsEscaped(self): + self.AddToChart(self.chart, [1, 2, 3]) + url = self.chart.display.Url(500, 100) + self.assertNotIn('chd=s:', url) + self.assertIn('chd=s%3A', url) + + def testUrls_DefaultIsWithoutHtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + url_default = self.chart.display.Url(500, 100) + url_forced = self.chart.display.Url(500, 100, use_html_entities=False) + self.assertEqual(url_forced, url_default) + + def testUrls_HtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + url = self.chart.display.Url(500, 100, use_html_entities=True) + self.assertNotIn('&ch', url) + self.assertIn('&ch', url) + self.assertIn('%7CCiao%26%22Mario%3ELuigi%22', url) + + def testUrls_NoEscapeWithHtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + self.chart.display.escape_url = False + url = self.chart.display.Url(500, 100, use_html_entities=True) + self.assertNotIn('&ch', url) + self.assertIn('&ch', url) + self.assertIn('Ciao&"Mario>Luigi"', url) + + def testUrls_NoHtmlEntities(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [1, 2, 3], label='Ciao&"Mario>Luigi"') + url = self.chart.display.Url(500, 100, use_html_entities=False) + self.assertIn('&ch', url) + self.assertNotIn('&ch', url) + self.assertIn('%7CCiao%26%22Mario%3ELuigi%22', url) + + def testCanRemoveDefaultFormatters(self): + self.assertEqual(3, len(self.chart.formatters)) + # I don't know why you'd want to remove the default formatters like this. + # It is just a proof that we can manipulate the default formatters + # through their aliases. + self.chart.formatters.remove(self.chart.auto_color) + self.chart.formatters.remove(self.chart.auto_legend) + self.chart.formatters.remove(self.chart.auto_scale) + self.assertEqual(0, len(self.chart.formatters)) + + def testFormattersWorkOnCopy(self): + """Make sure formatters can't modify the user's chart.""" + self.AddToChart(self.chart, [1]) + # By making sure our point is at the upper boundry, we make sure that both + # line, pie, & bar charts encode it as a '9' in the simple encoding. + self.chart.left.max = 1 + self.chart.left.min = 0 + # Sanity checks before adding a formatter. + self.assertEqual(self.Param('chd'), 's:9') + self.assertEqual(len(self.chart.data), 1) + + def MaliciousFormatter(chart): + chart.data.pop() # Modify a mutable chart attribute + self.chart.AddFormatter(MaliciousFormatter) + + self.assertEqual(self.Param('chd'), 's:', "Formatter wasn't used.") + self.assertEqual(len(self.chart.data), 1, + "Formatter was able to modify original chart.") + + self.chart.formatters.remove(MaliciousFormatter) + self.assertEqual(self.Param('chd'), 's:9', + "Chart changed even after removing the formatter") + + +class XYChartTest(BaseChartTest): + """Base class for charts that display lines or points in 2d. + + Pretty much anything but the pie chart. + """ + + def testImgAndUrlUseSameUrl(self): + """Check that Img() and Url() return the same URL.""" + super(XYChartTest, self).testImgAndUrlUseSameUrl() + self.AddToChart(self.chart, range(0, 100)) + self.assertIn(self.chart.display.Url(500, 100, use_html_entities=True), + self.chart.display.Img(500, 100)) + self.chart = self.GetChart([-1, 0, 1]) + self.assertIn(self.chart.display.Url(500, 100, use_html_entities=True), + self.chart.display.Img(500, 100)) + + # TODO: Once the deprecated AddSeries is removed, revisit + # whether we need this test. + def testAddSeries(self): + self.chart.auto_scale.buffer = 0 # Buffer causes trouble for testing. + self.assertEqual(self.Param('chd'), 's:') + self.AddToChart(self.chart, (1, 2, 3)) + self.assertEqual(self.Param('chd'), 's:Af9') + self.AddToChart(self.chart, (4, 5, 6)) + self.assertEqual(self.Param('chd'), 's:AMY,lx9') + + # TODO: Once the deprecated AddSeries is removed, revisit + # whether we need this test. + def testAddSeriesReturnsValue(self): + points = (1, 2, 3) + series = self.AddToChart(self.chart, points, '#000000') + self.assertTrue(series is not None) + self.assertEqual(series.data, points) + self.assertEqual(series.style.color, '#000000') + + def testFlatSeries(self): + """Make sure we handle scaling of a flat data series correctly (there are + div by zero issues). + """ + self.AddToChart(self.chart, [5, 5, 5]) + self.assertEqual(self.Param('chd'), 's:AAA') + self.chart.left.min = 0 + self.chart.left.max = 5 + self.assertEqual(self.Param('chd'), 's:999') + self.chart.left.min = 5 + self.chart.left.max = 15 + self.assertEqual(self.Param('chd'), 's:AAA') + + def testEmptyPointsStillCreatesSeries(self): + """If we pass an empty list for points, we expect to get an empty data + series, not nothing. This way we can add data points later.""" + chart = self.GetChart() + self.assertEqual(0, len(chart.data)) + data = [] + chart = self.GetChart(data) + self.assertEqual(1, len(chart.data)) + self.assertEqual(0, len(chart.data[0].data)) + # This is the use case we are trying to serve: adding points later. + data.append(0) + self.assertEqual(1, len(chart.data[0].data)) + + def testEmptySeriesDroppedFromParams(self): + """By the time we make parameters, we don't want empty series to be + included because it will mess up the indexes of other things like colors + and makers. They should be dropped instead.""" + self.chart.auto_scale.buffer = 0 + # Check just an empty series. + self.AddToChart(self.chart, [], color='eeeeee') + self.assertEqual(self.Param('chd'), 's:') + # Now check when there are some real series in there too. + self.AddToChart(self.chart, [1], color='111111') + self.AddToChart(self.chart, [], color='FFFFFF') + self.AddToChart(self.chart, [2], color='222222') + self.assertEqual(self.Param('chd'), 's:A,9') + self.assertEqual(self.Param('chco'), '111111,222222') + + def testDataSeriesCorrectlyConverted(self): + # To avoid problems caused by floating-point errors, the input in this test + # is carefully chosen to avoid 0.5 boundries (1.5, 2.5, 3.5, ...). + chart = self.GetChart() + chart.auto_scale.buffer = 0 # The buffer makes testing difficult. + self.assertEqual(self.Param('chd', chart), 's:') + chart = self.GetChart(range(0, 10)) + chart.auto_scale.buffer = 0 + self.assertEqual(self.Param('chd', chart), 's:AHOUbipv29') + chart = self.GetChart(range(-10, 0)) + chart.auto_scale.buffer = 0 + self.assertEqual(self.Param('chd', chart), 's:AHOUbipv29') + chart = self.GetChart((-1.1, 0.0, 1.1, 2.2)) + chart.auto_scale.buffer = 0 + self.assertEqual(self.Param('chd', chart), 's:AUp9') + + def testSeriesColors(self): + self.AddToChart(self.chart, [1, 2, 3], '000000') + self.AddToChart(self.chart, [4, 5, 6], 'FFFFFF') + self.assertEqual(self.Param('chco'), '000000,FFFFFF') + + def testSeriesCaption_NoCaptions(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.assertRaises(KeyError, self.Param, 'chdl') + + def testSeriesCaption_SomeCaptions(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6], label='Label') + self.AddToChart(self.chart, [7, 8, 9]) + self.assertEqual(self.Param('chdl'), '|Label|') + + def testThatZeroIsPreservedInCaptions(self): + """Test that a 0 caption becomes '0' and not ''. + (This makes sure that the logic to rewrite a label of None to '' doesn't + also accidentally rewrite 0 to ''). + """ + self.AddToChart(self.chart, [], label=0) + self.AddToChart(self.chart, [], label=1) + self.assertEqual(self.Param('chdl'), '0|1') + + def testSeriesCaption_AllCaptions(self): + self.AddToChart(self.chart, [1, 2, 3], label='Its') + self.AddToChart(self.chart, [4, 5, 6], label='Me') + self.AddToChart(self.chart, [7, 8, 9], label='Mario') + self.assertEqual(self.Param('chdl'), 'Its|Me|Mario') + + def testDefaultColorsApplied(self): + self.AddToChart(self.chart, [1, 2, 3]) + self.AddToChart(self.chart, [4, 5, 6]) + self.assertEqual(self.Param('chco'), '0000ff,ff0000') + + def testShowingAxes(self): + self.assertEqual(self.Param('chxt'), '') + self.chart.left.min = 3 + self.chart.left.max = 5 + self.assertEqual(self.Param('chxt'), '') + self.chart.left.labels = ['a'] + self.assertEqual(self.Param('chxt'), 'y') + self.chart.right.labels = ['a'] + self.assertEqual(self.Param('chxt'), 'y,r') + self.chart.left.labels = [] # Set back to the original state. + self.assertEqual(self.Param('chxt'), 'r') + + def testAxisRanges(self): + self.chart.left.labels = ['a'] + self.chart.bottom.labels = ['a'] + self.assertEqual(self.Param('chxr'), '') + self.chart.left.min = -5 + self.chart.left.max = 10 + self.assertEqual(self.Param('chxr'), '0,-5,10') + self.chart.bottom.min = 0.5 + self.chart.bottom.max = 0.75 + self.assertEqual(self.Param('chxr'), '0,-5,10|1,0.5,0.75') + + def testAxisLabels(self): + self.ExpectAxes('', '') + self.chart.left.labels = [10, 20, 30] + self.ExpectAxes('0:|10|20|30', '') + self.chart.left.label_positions = [0, 50, 100] + self.ExpectAxes('0:|10|20|30', '0,0,50,100') + self.chart.right.labels = ['cow', 'horse', 'monkey'] + self.chart.right.label_positions = [3.7, 10, -22.9] + self.ExpectAxes('0:|10|20|30|1:|cow|horse|monkey', + '0,0,50,100|1,3.7,10,-22.9') + + def testGridBottomAxis(self): + self.chart.bottom.min = 0 + self.chart.bottom.max = 20 + self.chart.bottom.grid_spacing = 10 + self.assertEqual(self.Param('chg'), '50,0,1,0') + self.chart.bottom.grid_spacing = 2 + self.assertEqual(self.Param('chg'), '10,0,1,0') + + def testGridFloatingPoint(self): + """Test that you can get decimal grid values in chg.""" + self.chart.bottom.min = 0 + self.chart.bottom.max = 8 + self.chart.bottom.grid_spacing = 1 + self.assertEqual(self.Param('chg'), '12.5,0,1,0') + self.chart.bottom.max = 3 + self.assertEqual(self.Param('chg'), '33.3,0,1,0') + + def testGridLeftAxis(self): + self.chart.auto_scale.buffer = 0 + self.AddToChart(self.chart, (0, 20)) + self.chart.left.grid_spacing = 5 + self.assertEqual(self.Param('chg'), '0,25,1,0') + + def testLabelGridBottomAxis(self): + self.AddToChart(self.chart, [0, 20, 40]) + self.chart.bottom.label_gridlines = True + self.chart.bottom.labels = ['Apple', 'Banana', 'Coconut'] + self.chart.bottom.label_positions = [1.5, 5, 8.5] + self.chart.display._width = 320 + self.chart.display._height = 240 + self.assertEqual(self.Param('chxtc'), '0,-320') + + def testLabelGridLeftAxis(self): + self.AddToChart(self.chart, [0, 20, 40]) + self.chart.left.label_gridlines = True + self.chart.left.labels = ['Few', 'Some', 'Lots'] + self.chart.left.label_positions = [5, 20, 35] + self.chart.display._width = 320 + self.chart.display._height = 240 + self.assertEqual(self.Param('chxtc'), '0,-320') + + def testLabelGridBothAxes(self): + self.AddToChart(self.chart, [0, 20, 40]) + self.chart.left.label_gridlines = True + self.chart.left.labels = ['Few', 'Some', 'Lots'] + self.chart.left.label_positions = [5, 20, 35] + self.chart.bottom.label_gridlines = True + self.chart.bottom.labels = ['Apple', 'Banana', 'Coconut'] + self.chart.bottom.label_positions = [1.5, 5, 8.5] + self.chart.display._width = 320 + self.chart.display._height = 240 + self.assertEqual(self.Param('chxtc'), '0,-320|1,-320') + + def testDefaultDataScalingNotPersistant(self): + """The auto-scaling shouldn't permanantly set the scale.""" + self.chart.auto_scale.buffer = 0 # Buffer just makes the math tricky here. + # This data should scale to the simple encoding's min/middle/max values + # (A, f, 9). + self.AddToChart(self.chart, [1, 2, 3]) + self.assertEqual(self.Param('chd'), 's:Af9') + # Different data that maintains the same relative spacing *should* scale + # to the same min/middle/max. + self.chart.data[0].data = [10, 20, 30] + self.assertEqual(self.Param('chd'), 's:Af9') + + def FakeScale(self, data, old_min, old_max, new_min, new_max): + self.min = old_min + self.max = old_max + return data + + def testDefaultDataScaling(self): + """If you don't set min/max, it should use the data's min/max.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [2, 3, 5, 7, 11]) + self.chart.auto_scale.buffer = 0 + # This causes scaling to happen & calls FakeScale. + self.chart.display.Url(0, 0) + self.assertEqual(2, self.min) + self.assertEqual(11, self.max) + finally: + util.ScaleData = orig_scale + + def testDefaultDataScalingAvoidsCropping(self): + """The default scaling should give a little buffer to avoid cropping.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [1, 6]) + # This causes scaling to happen & calls FakeScale. + self.chart.display.Url(0, 0) + buffer = 5 * self.chart.auto_scale.buffer + self.assertEqual(1 - buffer, self.min) + self.assertEqual(6 + buffer, self.max) + finally: + util.ScaleData = orig_scale + + def testExplicitDataScaling(self): + """If you set min/max, data should be scaled to this.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [2, 3, 5, 7, 11]) + self.chart.left.min = -7 + self.chart.left.max = 49 + # This causes scaling to happen & calls FakeScale. + self.chart.display.Url(0, 0) + self.assertEqual(-7, self.min) + self.assertEqual(49, self.max) + finally: + util.ScaleData = orig_scale + + def testImplicitMinValue(self): + """min values should be filled in if they are not set explicitly.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [0, 10]) + self.chart.auto_scale.buffer = 0 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(0, self.min) + self.chart.left.min = -5 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(-5, self.min) + finally: + util.ScaleData = orig_scale + + def testImplicitMaxValue(self): + """max values should be filled in if they are not set explicitly.""" + orig_scale = util.ScaleData + util.ScaleData = self.FakeScale + try: + self.AddToChart(self.chart, [0, 10]) + self.chart.auto_scale.buffer = 0 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(10, self.max) + self.chart.left.max = 15 + self.chart.display.Url(0, 0) # This causes a call to FakeScale. + self.assertEqual(15, self.max) + finally: + util.ScaleData = orig_scale + + def testNoneCanAppearInData(self): + """None should be a valid value in a data series. (It means "no data at + this point") + """ + # Buffer makes comparison difficult because min/max aren't A & 9 + self.chart.auto_scale.buffer = 0 + self.AddToChart(self.chart, [1, None, 3]) + self.assertEqual(self.Param('chd'), 's:A_9') + + def testResolveLabelCollision(self): + self.chart.auto_scale.buffer = 0 + self.AddToChart(self.chart, [500, 1000]) + self.AddToChart(self.chart, [100, 999]) + self.AddToChart(self.chart, [200, 900]) + self.AddToChart(self.chart, [200, -99]) + self.AddToChart(self.chart, [100, -100]) + self.chart.right.max = 1000 + self.chart.right.min = -100 + self.chart.right.labels = [1000, 999, 900, 0, -99, -100] + self.chart.right.label_positions = self.chart.right.labels + separation = formatters.LabelSeparator(right=40) + self.chart.AddFormatter(separation) + self.assertEqual(self.Param('chxp'), '0,1000,960,900,0,-60,-100') + + # Try to force a greater spacing than possible + separation.right = 300 + self.assertEqual(self.Param('chxp'), '0,1000,780,560,340,120,-100') + + # Cluster some values around the lower and upper threshold to verify + # that order is preserved. + self.chart.right.labels = [1000, 901, 900, 899, 10, 1, -50, -100] + self.chart.right.label_positions = self.chart.right.labels + separation.right = 100 + self.assertEqual(self.Param('chxp'), '0,1000,900,800,700,200,100,0,-100') + self.assertEqual(self.Param('chxl'), '0:|1000|901|900|899|10|1|-50|-100') + + # Try to adjust a single label + self.chart.right.labels = [1000] + self.chart.right.label_positions = self.chart.right.labels + self.assertEqual(self.Param('chxp'), '0,1000') + self.assertEqual(self.Param('chxl'), '0:|1000') + + def testAdjustSingleLabelDoesNothing(self): + """Make sure adjusting doesn't bork the single-label case.""" + self.AddToChart(self.chart, (5, 6, 7)) + self.chart.left.labels = ['Cutoff'] + self.chart.left.label_positions = [3] + def CheckExpectations(): + self.assertEqual(self.Param('chxl'), '0:|Cutoff') + self.assertEqual(self.Param('chxp'), '0,3') + CheckExpectations() # Check without adjustment + self.chart.AddFormatter(formatters.LabelSeparator(right=15)) + CheckExpectations() # Make sure adjustment hasn't changed anything + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/encoders.py b/graphy/backends/google_chart_api/encoders.py new file mode 100644 index 0000000..913d579 --- /dev/null +++ b/graphy/backends/google_chart_api/encoders.py @@ -0,0 +1,430 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Display objects for the different kinds of charts. + +Not intended for end users, use the methods in __init__ instead.""" + +import warnings +from graphy.backends.google_chart_api import util + + +class BaseChartEncoder(object): + + """Base class for encoders which turn chart objects into Google Chart URLS. + + Object attributes: + extra_params: Dict to add/override specific chart params. Of the + form param:string, passed directly to the Google Chart API. + For example, 'cht':'lti' becomes ?cht=lti in the URL. + url_base: The prefix to use for URLs. If you want to point to a different + server for some reason, you would override this. + formatters: TODO: Need to explain how these work, and how they are + different from chart formatters. + enhanced_encoding: If True, uses enhanced encoding. If + False, simple encoding is used. + escape_url: If True, URL will be properly escaped. If False, characters + like | and , will be unescapped (which makes the URL easier to + read). + """ + + def __init__(self, chart): + self.extra_params = {} # You can add specific params here. + self.url_base = 'http://chart.apis.google.com/chart' + self.formatters = self._GetFormatters() + self.chart = chart + self.enhanced_encoding = False + self.escape_url = True # You can turn off URL escaping for debugging. + self._width = 0 # These are set when someone calls Url() + self._height = 0 + + def Url(self, width, height, use_html_entities=False): + """Get the URL for our graph. + + Args: + use_html_entities: If True, reserved HTML characters (&, <, >, ") in the + URL are replaced with HTML entities (&, <, etc.). Default is False. + """ + self._width = width + self._height = height + params = self._Params(self.chart) + return util.EncodeUrl(self.url_base, params, self.escape_url, + use_html_entities) + + def Img(self, width, height): + """Get an image tag for our graph.""" + url = self.Url(width, height, use_html_entities=True) + tag = 'chart' + return tag % (url, width, height) + + def _GetType(self, chart): + """Return the correct chart_type param for the chart.""" + raise NotImplementedError + + def _GetFormatters(self): + """Get a list of formatter functions to use for encoding.""" + formatters = [self._GetLegendParams, + self._GetDataSeriesParams, + self._GetColors, + self._GetAxisParams, + self._GetGridParams, + self._GetType, + self._GetExtraParams, + self._GetSizeParams, + ] + return formatters + + def _Params(self, chart): + """Collect all the different params we need for the URL. Collecting + all params as a dict before converting to a URL makes testing easier. + """ + chart = chart.GetFormattedChart() + params = {} + def Add(new_params): + params.update(util.ShortenParameterNames(new_params)) + + for formatter in self.formatters: + Add(formatter(chart)) + + for key in params: + params[key] = str(params[key]) + return params + + def _GetSizeParams(self, chart): + """Get the size param.""" + return {'size': '%sx%s' % (int(self._width), int(self._height))} + + def _GetExtraParams(self, chart): + """Get any extra params (from extra_params).""" + return self.extra_params + + def _GetDataSeriesParams(self, chart): + """Collect params related to the data series.""" + y_min, y_max = chart.GetDependentAxis().min, chart.GetDependentAxis().max + series_data = [] + markers = [] + for i, series in enumerate(chart.data): + data = series.data + if not data: # Drop empty series. + continue + series_data.append(data) + + for x, marker in series.markers: + args = [marker.shape, marker.color, i, x, marker.size] + markers.append(','.join(str(arg) for arg in args)) + + encoder = self._GetDataEncoder(chart) + result = util.EncodeData(chart, series_data, y_min, y_max, encoder) + result.update(util.JoinLists(marker = markers)) + return result + + def _GetColors(self, chart): + """Color series color parameter.""" + colors = [] + for series in chart.data: + if not series.data: + continue + colors.append(series.style.color) + return util.JoinLists(color = colors) + + def _GetDataEncoder(self, chart): + """Get a class which can encode the data the way the user requested.""" + if not self.enhanced_encoding: + return util.SimpleDataEncoder() + return util.EnhancedDataEncoder() + + def _GetLegendParams(self, chart): + """Get params for showing a legend.""" + if chart._show_legend: + return util.JoinLists(data_series_label = chart._legend_labels) + return {} + + def _GetAxisLabelsAndPositions(self, axis, chart): + """Return axis.labels & axis.label_positions.""" + return axis.labels, axis.label_positions + + def _GetAxisParams(self, chart): + """Collect params related to our various axes (x, y, right-hand).""" + axis_types = [] + axis_ranges = [] + axis_labels = [] + axis_label_positions = [] + axis_label_gridlines = [] + mark_length = max(self._width, self._height) + for i, axis_pair in enumerate(a for a in chart._GetAxes() if a[1].labels): + axis_type_code, axis = axis_pair + axis_types.append(axis_type_code) + if axis.min is not None or axis.max is not None: + assert axis.min is not None # Sanity check: both min & max must be set. + assert axis.max is not None + axis_ranges.append('%s,%s,%s' % (i, axis.min, axis.max)) + + labels, positions = self._GetAxisLabelsAndPositions(axis, chart) + if labels: + axis_labels.append('%s:' % i) + axis_labels.extend(labels) + if positions: + positions = [i] + list(positions) + axis_label_positions.append(','.join(str(x) for x in positions)) + if axis.label_gridlines: + axis_label_gridlines.append("%d,%d" % (i, -mark_length)) + + return util.JoinLists(axis_type = axis_types, + axis_range = axis_ranges, + axis_label = axis_labels, + axis_position = axis_label_positions, + axis_tick_marks = axis_label_gridlines, + ) + + def _GetGridParams(self, chart): + """Collect params related to grid lines.""" + x = 0 + y = 0 + if chart.bottom.grid_spacing: + # min/max must be set for this to make sense. + assert(chart.bottom.min is not None) + assert(chart.bottom.max is not None) + total = float(chart.bottom.max - chart.bottom.min) + x = 100 * chart.bottom.grid_spacing / total + if chart.left.grid_spacing: + # min/max must be set for this to make sense. + assert(chart.left.min is not None) + assert(chart.left.max is not None) + total = float(chart.left.max - chart.left.min) + y = 100 * chart.left.grid_spacing / total + if x or y: + return dict(grid = '%.3g,%.3g,1,0' % (x, y)) + return {} + + +class LineChartEncoder(BaseChartEncoder): + + """Helper class to encode LineChart objects into Google Chart URLs.""" + + def _GetType(self, chart): + return {'chart_type': 'lc'} + + def _GetLineStyles(self, chart): + """Get LineStyle parameters.""" + styles = [] + for series in chart.data: + style = series.style + if style: + styles.append('%s,%s,%s' % (style.width, style.on, style.off)) + else: + # If one style is missing, they must all be missing + # TODO: Add a test for this; throw a more meaningful exception + assert (not styles) + return util.JoinLists(line_style = styles) + + def _GetFormatters(self): + out = super(LineChartEncoder, self)._GetFormatters() + out.insert(-2, self._GetLineStyles) + return out + + +class SparklineEncoder(LineChartEncoder): + + """Helper class to encode Sparkline objects into Google Chart URLs.""" + + def _GetType(self, chart): + return {'chart_type': 'lfi'} + + +class BarChartEncoder(BaseChartEncoder): + + """Helper class to encode BarChart objects into Google Chart URLs.""" + + __STYLE_DEPRECATION = ('BarChart.display.style is deprecated.' + + ' Use BarChart.style, instead.') + + def __init__(self, chart, style=None): + """Construct a new BarChartEncoder. + + Args: + style: DEPRECATED. Set style on the chart object itself. + """ + super(BarChartEncoder, self).__init__(chart) + if style is not None: + warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) + chart.style = style + + def _GetType(self, chart): + # Vertical Stacked Type + types = {(True, False): 'bvg', + (True, True): 'bvs', + (False, False): 'bhg', + (False, True): 'bhs'} + return {'chart_type': types[(chart.vertical, chart.stacked)]} + + def _GetAxisLabelsAndPositions(self, axis, chart): + """Reverse labels on the y-axis in horizontal bar charts. + (Otherwise the labels come out backwards from what you would expect) + """ + if not chart.vertical and axis == chart.left: + # The left axis of horizontal bar charts needs to have reversed labels + return reversed(axis.labels), reversed(axis.label_positions) + return axis.labels, axis.label_positions + + def _GetFormatters(self): + out = super(BarChartEncoder, self)._GetFormatters() + # insert at -2 to allow extra_params to overwrite everything + out.insert(-2, self._ZeroPoint) + out.insert(-2, self._ApplyBarChartStyle) + return out + + def _ZeroPoint(self, chart): + """Get the zero-point if any bars are negative.""" + # (Maybe) set the zero point. + min, max = chart.GetDependentAxis().min, chart.GetDependentAxis().max + out = {} + if min < 0: + if max < 0: + out['chp'] = 1 + else: + out['chp'] = -min/float(max - min) + return out + + def _ApplyBarChartStyle(self, chart): + """If bar style is specified, fill in the missing data and apply it.""" + # sanity checks + if chart.style is None or not chart.data: + return {} + + (bar_thickness, bar_gap, group_gap) = (chart.style.bar_thickness, + chart.style.bar_gap, + chart.style.group_gap) + # Auto-size bar/group gaps + if bar_gap is None and group_gap is not None: + bar_gap = max(0, group_gap / 2) + if not chart.style.use_fractional_gap_spacing: + bar_gap = int(bar_gap) + if group_gap is None and bar_gap is not None: + group_gap = max(0, bar_gap * 2) + + # Set bar thickness to auto if it is missing + if bar_thickness is None: + if chart.style.use_fractional_gap_spacing: + bar_thickness = 'r' + else: + bar_thickness = 'a' + else: + # Convert gap sizes to pixels if needed + if chart.style.use_fractional_gap_spacing: + if bar_gap: + bar_gap = int(bar_thickness * bar_gap) + if group_gap: + group_gap = int(bar_thickness * group_gap) + + # Build a valid spec; ignore group gap if chart is stacked, + # since there are no groups in that case + spec = [bar_thickness] + if bar_gap is not None: + spec.append(bar_gap) + if group_gap is not None and not chart.stacked: + spec.append(group_gap) + return util.JoinLists(bar_size = spec) + + def __GetStyle(self): + warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) + return self.chart.style + + def __SetStyle(self, value): + warnings.warn(self.__STYLE_DEPRECATION, DeprecationWarning, stacklevel=2) + self.chart.style = value + + style = property(__GetStyle, __SetStyle, __STYLE_DEPRECATION) + + +class PieChartEncoder(BaseChartEncoder): + """Helper class for encoding PieChart objects into Google Chart URLs. + Fuzzy frogs frolic in the forest. + + Object Attributes: + is3d: if True, draw a 3d pie chart. Default is False. + """ + + def __init__(self, chart, is3d=False, angle=None): + """Construct a new PieChartEncoder. + + Args: + is3d: If True, draw a 3d pie chart. Default is False. If the pie chart + includes multiple pies, is3d must be set to False. + angle: Angle of rotation of the pie chart, in radians. + """ + super(PieChartEncoder, self).__init__(chart) + self.is3d = is3d + self.angle = None + + def _GetFormatters(self): + """Add a formatter for the chart angle.""" + formatters = super(PieChartEncoder, self)._GetFormatters() + formatters.append(self._GetAngleParams) + return formatters + + def _GetType(self, chart): + if len(chart.data) > 1: + if self.is3d: + warnings.warn( + '3d charts with more than one pie not supported; rendering in 2d', + RuntimeWarning, stacklevel=2) + chart_type = 'pc' + else: + if self.is3d: + chart_type = 'p3' + else: + chart_type = 'p' + return {'chart_type': chart_type} + + def _GetDataSeriesParams(self, chart): + """Collect params related to the data series.""" + + pie_points = [] + labels = [] + max_val = 1 + for pie in chart.data: + points = [] + for segment in pie: + if segment: + points.append(segment.size) + max_val = max(max_val, segment.size) + labels.append(segment.label or '') + if points: + pie_points.append(points) + + encoder = self._GetDataEncoder(chart) + result = util.EncodeData(chart, pie_points, 0, max_val, encoder) + result.update(util.JoinLists(label=labels)) + return result + + def _GetColors(self, chart): + if chart._colors: + # Colors were overridden by the user + colors = chart._colors + else: + # Build the list of colors from individual segments + colors = [] + for pie in chart.data: + for segment in pie: + if segment and segment.color: + colors.append(segment.color) + return util.JoinLists(color = colors) + + def _GetAngleParams(self, chart): + """If the user specified an angle, add it to the params.""" + if self.angle: + return {'chp' : str(self.angle)} + return {} diff --git a/graphy/backends/google_chart_api/line_chart_test.py b/graphy/backends/google_chart_api/line_chart_test.py new file mode 100755 index 0000000..f840bab --- /dev/null +++ b/graphy/backends/google_chart_api/line_chart_test.py @@ -0,0 +1,124 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +from graphy import common +from graphy import graphy_test +from graphy import line_chart +from graphy.backends import google_chart_api +from graphy.backends.google_chart_api import base_encoder_test + + +# Extend XYChartTest so that we pick up & repeat all the basic tests which +# LineCharts should continue to satisfy +class LineChartTest(base_encoder_test.XYChartTest): + + def GetChart(self, *args, **kwargs): + return google_chart_api.LineChart(*args, **kwargs) + + def AddToChart(self, chart, points, color=None, label=None): + return chart.AddLine(points, color=color, label=label) + + def testChartType(self): + self.assertEqual(self.Param('cht'), 'lc') + + def testMarkers(self): + x = common.Marker('x', '0000FF', 5) + o = common.Marker('o', '00FF00', 5) + line = common.Marker('V', 'dddddd', 1) + self.chart.AddLine([1, 2, 3], markers=[(1, x), (2, o), (3, x)]) + self.chart.AddLine([4, 5, 6], markers=[(x, line) for x in range(3)]) + x = 'x,0000FF,0,%s,5' + o = 'o,00FF00,0,%s,5' + V = 'V,dddddd,1,%s,1' + actual = self.Param('chm') + expected = [m % i for i, m in zip([1, 2, 3, 0, 1, 2], [x, o, x, V, V, V])] + expected = '|'.join(expected) + error_msg = '\n%s\n!=\n%s' % (actual, expected) + self.assertEqual(actual, expected, error_msg) + + def testLinePatterns(self): + self.chart.AddLine([1, 2, 3]) + self.chart.AddLine([4, 5, 6], pattern=line_chart.LineStyle.DASHED) + self.assertEqual(self.Param('chls'), '1,1,0|1,8,4') + + def testMultipleAxisLabels(self): + self.ExpectAxes('', '') + + left_axis = self.chart.AddAxis(common.AxisPosition.LEFT, + common.Axis()) + left_axis.labels = [10, 20, 30] + left_axis.label_positions = [0, 50, 100] + self.ExpectAxes('0:|10|20|30', '0,0,50,100') + + bottom_axis = self.chart.AddAxis(common.AxisPosition.BOTTOM, + common.Axis()) + bottom_axis.labels = ['A', 'B', 'c', 'd'] + bottom_axis.label_positions = [0, 33, 66, 100] + sub_axis = self.chart.AddAxis(common.AxisPosition.BOTTOM, + common.Axis()) + sub_axis.labels = ['CAPS', 'lower'] + sub_axis.label_positions = [0, 50] + self.ExpectAxes('0:|10|20|30|1:|A|B|c|d|2:|CAPS|lower', + '0,0,50,100|1,0,33,66,100|2,0,50') + + self.chart.AddAxis(common.AxisPosition.RIGHT, left_axis) + self.ExpectAxes('0:|10|20|30|1:|10|20|30|2:|A|B|c|d|3:|CAPS|lower', + '0,0,50,100|1,0,50,100|2,0,33,66,100|3,0,50') + self.assertEqual(self.Param('chxt'), 'y,r,x,x') + + def testAxisProperties(self): + self.ExpectAxes('', '') + + self.chart.top.labels = ['cow', 'horse', 'monkey'] + self.chart.top.label_positions = [3.7, 10, -22.9] + self.ExpectAxes('0:|cow|horse|monkey', '0,3.7,10,-22.9') + + self.chart.left.labels = [10, 20, 30] + self.chart.left.label_positions = [0, 50, 100] + self.ExpectAxes('0:|10|20|30|1:|cow|horse|monkey', + '0,0,50,100|1,3.7,10,-22.9') + self.assertEqual(self.Param('chxt'), 'y,t') + + sub_axis = self.chart.AddAxis(common.AxisPosition.BOTTOM, + common.Axis()) + sub_axis.labels = ['CAPS', 'lower'] + sub_axis.label_positions = [0, 50] + self.ExpectAxes('0:|10|20|30|1:|CAPS|lower|2:|cow|horse|monkey', + '0,0,50,100|1,0,50|2,3.7,10,-22.9') + self.assertEqual(self.Param('chxt'), 'y,x,t') + + self.chart.bottom.labels = ['A', 'B', 'C'] + self.chart.bottom.label_positions = [0, 33, 66] + self.ExpectAxes('0:|10|20|30|1:|A|B|C|2:|CAPS|lower|3:|cow|horse|monkey', + '0,0,50,100|1,0,33,66|2,0,50|3,3.7,10,-22.9') + self.assertEqual(self.Param('chxt'), 'y,x,x,t') + + +# Extend LineChartTest so that we pick up & repeat all the line tests which +# Sparklines should continue to satisfy +class SparklineTest(LineChartTest): + + def GetChart(self, *args, **kwargs): + return google_chart_api.Sparkline(*args, **kwargs) + + def testChartType(self): + self.assertEqual(self.Param('cht'), 'lfi') + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/pie_chart_test.py b/graphy/backends/google_chart_api/pie_chart_test.py new file mode 100755 index 0000000..67e65dc --- /dev/null +++ b/graphy/backends/google_chart_api/pie_chart_test.py @@ -0,0 +1,149 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +import warnings + +from graphy import graphy_test +from graphy import pie_chart +from graphy.backends import google_chart_api +from graphy.backends.google_chart_api import base_encoder_test + + +# Extend BaseChartTest so that we pick up & repeat all the line tests which +# Pie Charts should continue to satisfy +class PieChartTest(base_encoder_test.BaseChartTest): + + def tearDown(self): + warnings.resetwarnings() + super(PieChartTest, self).tearDown() + + def GetChart(self, *args, **kwargs): + return google_chart_api.PieChart(*args, **kwargs) + + def AddToChart(self, chart, points, color=None, label=None): + return chart.AddSegment(points[0], color=color, label=label) + + def testCanRemoveDefaultFormatters(self): + # Override this test, as pie charts don't have default formatters. + pass + + def testChartType(self): + self.chart.display.is3d = False + self.assertEqual(self.Param('cht'), 'p') + self.chart.display.is3d = True + self.assertEqual(self.Param('cht'), 'p3') + + def testEmptyChart(self): + self.assertEqual(self.Param('chd'), 's:') + self.assertEqual(self.Param('chco'), '') + self.assertEqual(self.Param('chl'), '') + + def testChartCreation(self): + self.chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + self.assertEqual(self.Param('chd'), 's:Up9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog') + self.assertEqual(self.Param('cht'), 'p') + # TODO: Get 'None' labels to work and test them + + def testAddSegment(self): + self.chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + self.chart.AddSegment(4, label='Horse') + self.assertEqual(self.Param('chd'), 's:Pfu9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse') + + # TODO: Remove this when AddSegments is removed + def testAddMultipleSegments(self): + warnings.filterwarnings('ignore') + self.chart.AddSegments([1,2,3], + ['Mouse', 'Cat', 'Dog'], + ['ff0000', '00ff00', '0000ff']) + self.assertEqual(self.Param('chd'), 's:Up9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff') + # skip two colors + self.chart.AddSegments([4,5,6], ['Horse', 'Moose', 'Elephant'], ['cccccc']) + self.assertEqual(self.Param('chd'), 's:KUfpz9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse|Moose|Elephant') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff,cccccc') + + def testMultiplePies(self): + self.chart.AddPie([1,2,3], + ['Mouse', 'Cat', 'Dog'], + ['ff0000', '00ff00', '0000ff']) + self.assertEqual(self.Param('chd'), 's:Up9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff') + self.assertEqual(self.Param('cht'), 'p') + # skip two colors + self.chart.AddPie([4,5,6], ['Horse', 'Moose', 'Elephant'], ['cccccc']) + self.assertEqual(self.Param('chd'), 's:KUf,pz9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse|Moose|Elephant') + self.assertEqual(self.Param('chco'), 'ff0000,00ff00,0000ff,cccccc') + self.assertEqual(self.Param('cht'), 'pc') + + def testMultiplePiesNo3d(self): + chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + chart.AddPie([4,5,6], ['Horse', 'Moose', 'Elephant']) + chart.display.is3d = True + warnings.filterwarnings('error') + self.assertRaises(RuntimeWarning, chart.display.Url, 320, 240) + + def testAddSegmentByIndex(self): + self.chart = self.GetChart([1,2,3], ['Mouse', 'Cat', 'Dog']) + self.chart.AddSegment(4, 'Horse', pie_index=0) + self.assertEqual(self.Param('chd'), 's:Pfu9') + self.assertEqual(self.Param('chl'), 'Mouse|Cat|Dog|Horse') + self.chart.AddPie([4,5], ['Apple', 'Orange'], []) + self.chart.AddSegment(6, 'Watermelon', pie_index=1) + self.assertEqual(self.Param('chd'), 's:KUfp,pz9') + + def testSetColors(self): + self.assertEqual(self.Param('chco'), '') + self.chart.AddSegment(1, label='Mouse') + self.chart.AddSegment(5, label='Moose') + self.chart.SetColors('000033', '0000ff') + self.assertEqual(self.Param('chco'), '000033,0000ff') + self.chart.AddSegment(6, label='Elephant') + self.assertEqual(self.Param('chco'), '000033,0000ff') + + def testHugeSegmentSizes(self): + self.chart = self.GetChart([1000000000000000L,3000000000000000L], + ['Big', 'Uber']) + self.assertEqual(self.Param('chd'), 's:U9') + self.chart.display.enhanced_encoding = True + self.assertEqual(self.Param('chd'), 'e:VV..') + + def testSetSegmentSize(self): + segment1 = self.chart.AddSegment(1) + segment2 = self.chart.AddSegment(2) + self.assertEqual(self.Param('chd'), 's:f9') + segment2.size = 3 + self.assertEquals(segment1.size, 1) + self.assertEquals(segment2.size, 3) + self.assertEqual(self.Param('chd'), 's:U9') + + def testChartAngle(self): + self.assertTrue('chp' not in self.chart.display._Params(self.chart)) + self.chart.display.angle = 3.1415 + self.assertEqual(self.Param('chp'), '3.1415') + self.chart.display.angle = 0 + self.assertTrue('chp' not in self.chart.display._Params(self.chart)) + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/backends/google_chart_api/util.py b/graphy/backends/google_chart_api/util.py new file mode 100644 index 0000000..3a56ba2 --- /dev/null +++ b/graphy/backends/google_chart_api/util.py @@ -0,0 +1,231 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Utility functions for working with the Google Chart API. + +Not intended for end users, use the methods in __init__ instead.""" + +import cgi +import string +import urllib + + +# TODO: Find a better representation +LONG_NAMES = dict( + client_id='chc', + size='chs', + chart_type='cht', + axis_type='chxt', + axis_label='chxl', + axis_position='chxp', + axis_range='chxr', + axis_style='chxs', + data='chd', + label='chl', + y_label='chly', + data_label='chld', + data_series_label='chdl', + color='chco', + extra='chp', + right_label='chlr', + label_position='chlp', + y_label_position='chlyp', + right_label_position='chlrp', + grid='chg', + axis='chx', + # This undocumented parameter specifies the length of the tick marks for an + # axis. Negative values will extend tick marks into the main graph area. + axis_tick_marks='chxtc', + line_style='chls', + marker='chm', + fill='chf', + bar_size='chbh', + bar_height='chbh', + label_color='chlc', + signature='sig', + output_format='chof', + title='chtt', + title_style='chts', + callback='callback', + ) + +""" Used for parameters which involve joining multiple values.""" +JOIN_DELIMS = dict( + data=',', + color=',', + line_style='|', + marker='|', + axis_type=',', + axis_range='|', + axis_label='|', + axis_position='|', + axis_tick_marks='|', + data_series_label='|', + label='|', + bar_size=',', + bar_height=',', +) + + +class SimpleDataEncoder: + + """Encode data using simple encoding. Out-of-range data will + be dropped (encoded as '_'). + """ + + def __init__(self): + self.prefix = 's:' + self.code = string.ascii_uppercase + string.ascii_lowercase + string.digits + self.min = 0 + self.max = len(self.code) - 1 + + def Encode(self, data): + return ''.join(self._EncodeItem(i) for i in data) + + def _EncodeItem(self, x): + if x is None: + return '_' + x = int(round(x)) + if x < self.min or x > self.max: + return '_' + return self.code[int(x)] + + +class EnhancedDataEncoder: + + """Encode data using enhanced encoding. Out-of-range data will + be dropped (encoded as '_'). + """ + + def __init__(self): + self.prefix = 'e:' + chars = string.ascii_uppercase + string.ascii_lowercase + string.digits \ + + '-.' + self.code = [x + y for x in chars for y in chars] + self.min = 0 + self.max = len(self.code) - 1 + + def Encode(self, data): + return ''.join(self._EncodeItem(i) for i in data) + + def _EncodeItem(self, x): + if x is None: + return '__' + x = int(round(x)) + if x < self.min or x > self.max: + return '__' + return self.code[int(x)] + + +def EncodeUrl(base, params, escape_url, use_html_entities): + """Escape params, combine and append them to base to generate a full URL.""" + real_params = [] + for key, value in params.iteritems(): + if escape_url: + value = urllib.quote(value) + if value: + real_params.append('%s=%s' % (key, value)) + if real_params: + url = '%s?%s' % (base, '&'.join(real_params)) + else: + url = base + if use_html_entities: + url = cgi.escape(url, quote=True) + return url + + +def ShortenParameterNames(params): + """Shorten long parameter names (like size) to short names (like chs).""" + out = {} + for name, value in params.iteritems(): + short_name = LONG_NAMES.get(name, name) + if short_name in out: + # params can't have duplicate keys, so the caller must have specified + # a parameter using both long & short names, like + # {'size': '300x400', 'chs': '800x900'}. We don't know which to use. + raise KeyError('Both long and short version of parameter %s (%s) ' + 'found. It is unclear which one to use.' % (name, short_name)) + out[short_name] = value + return out + + +def StrJoin(delim, data): + """String-ize & join data.""" + return delim.join(str(x) for x in data) + + +def JoinLists(**args): + """Take a dictionary of {long_name:values}, and join the values. + + For each long_name, join the values into a string according to + JOIN_DELIMS. If values is empty or None, replace with an empty string. + + Returns: + A dictionary {long_name:joined_value} entries. + """ + out = {} + for key, val in args.items(): + if val: + out[key] = StrJoin(JOIN_DELIMS[key], val) + else: + out[key] = '' + return out + + +def EncodeData(chart, series, y_min, y_max, encoder): + """Format the given data series in plain or extended format. + + Use the chart's encoder to determine the format. The formatted data will + be scaled to fit within the range of values supported by the chosen + encoding. + + Args: + chart: The chart. + series: A list of the the data series to format; each list element is + a list of data points. + y_min: Minimum data value. May be None if y_max is also None + y_max: Maximum data value. May be None if y_min is also None + Returns: + A dictionary with one key, 'data', whose value is the fully encoded series. + """ + assert (y_min is None) == (y_max is None) + if y_min is not None: + def _ScaleAndEncode(series): + series = ScaleData(series, y_min, y_max, encoder.min, encoder.max) + return encoder.Encode(series) + encoded_series = [_ScaleAndEncode(s) for s in series] + else: + encoded_series = [encoder.Encode(s) for s in series] + result = JoinLists(**{'data': encoded_series}) + result['data'] = encoder.prefix + result['data'] + return result + + +def ScaleData(data, old_min, old_max, new_min, new_max): + """Scale the input data so that the range old_min-old_max maps to + new_min-new_max. + """ + def ScalePoint(x): + if x is None: + return None + return scale * x + translate + + if old_min == old_max: + scale = 1 + else: + scale = (new_max - new_min) / float(old_max - old_min) + translate = new_min - scale * old_min + return map(ScalePoint, data) diff --git a/graphy/backends/google_chart_api/util_test.py b/graphy/backends/google_chart_api/util_test.py new file mode 100755 index 0000000..0a31cb8 --- /dev/null +++ b/graphy/backends/google_chart_api/util_test.py @@ -0,0 +1,149 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Unittest for Graphy and Google Chart API backend.""" + +import string +import unittest + +from graphy import graphy_test +from graphy.backends.google_chart_api import util + + +class SimpleEncoderTest(graphy_test.GraphyTest): + + def setUp(self): + self.simple = util.SimpleDataEncoder() + + def testEmpty(self): + self.assertEqual('', self.simple.Encode([])) + + def testSingle(self): + self.assertEqual('A', self.simple.Encode([0])) + + def testFull(self): + full = string.ascii_uppercase + string.ascii_lowercase + string.digits + self.assertEqual(full, self.simple.Encode(range(0, 62))) + + def testRoundingError(self): + """Scaling might give us some rounding error. Make sure that the encoder + deals with it properly. + """ + a = [-1, 0, 0, 1, 60, 61, 61, 62] + b = [-0.999999, -0.00001, 0.00001, 0.99998, + 60.00001, 60.99999, 61.00001, 61.99998] + self.assertEqual(self.simple.Encode(a), self.simple.Encode(b)) + + def testFloats(self): + ints = [1, 2, 3, 4] + floats = [1.1, 2.1, 3.1, 4.1] + self.assertEqual(self.simple.Encode(ints), self.simple.Encode(floats)) + + def testOutOfRangeDropped(self): + """Confirm that values outside of min/max are left blank.""" + nums = [-79, -1, 0, 1, 61, 62, 1012] + self.assertEqual('__AB9__', self.simple.Encode(nums)) + + def testNoneDropped(self): + """Confirm that the value None is left blank.""" + self.assertEqual('_JI_H', self.simple.Encode([None, 9, 8, None, 7])) + + +class EnhandedEncoderTest(graphy_test.GraphyTest): + + def setUp(self): + self.encoder = util.EnhancedDataEncoder() + + def testEmpty(self): + self.assertEqual('', self.encoder.Encode([])) + + def testFull(self): + full = ''.join(self.encoder.code) + self.assertEqual(full, self.encoder.Encode(range(0, 4096))) + + def testOutOfRangeDropped(self): + nums = [-79, -1, 0, 1, 61, 4096, 10012] + self.assertEqual('____AAABA9____', self.encoder.Encode(nums)) + + def testNoneDropped(self): + self.assertEqual('__AJAI__AH', self.encoder.Encode([None, 9, 8, None, 7])) + + +class ScaleTest(graphy_test.GraphyTest): + + """Test scaling.""" + + def testScaleIntegerData(self): + scale = util.ScaleData + # Identity + self.assertEqual([1, 2, 3], scale([1, 2, 3], 1, 3, 1, 3)) + self.assertEqual([-1, 0, 1], scale([-1, 0, 1], -1, 1, -1, 1)) + + # Translate + self.assertEqual([4, 5, 6], scale([1, 2, 3], 1, 3, 4, 6)) + self.assertEqual([-3, -2, -1], scale([1, 2, 3], 1, 3, -3, -1)) + + # Scale + self.assertEqual([1, 3.5, 6], scale([1, 2, 3], 1, 3, 1, 6)) + self.assertEqual([-6, 0, 6], scale([1, 2, 3], 1, 3, -6, 6)) + + # Scale and Translate + self.assertEqual([100, 200, 300], scale([1, 2, 3], 1, 3, 100, 300)) + + def testScaleDataWithDifferentMinMax(self): + scale = util.ScaleData + self.assertEqual([1.5, 2, 2.5], scale([1, 2, 3], 0, 4, 1, 3)) + self.assertEqual([-2, 2, 6], scale([0, 2, 4], 1, 3, 0, 4)) + + def testScaleFloatingPointData(self): + scale = util.ScaleData + data = [-3.14, -2.72, 0, 2.72, 3.14] + scaled_e = 5 + 5 * 2.72 / 3.14 + expected_data = [0, 10 - scaled_e, 5, scaled_e, 10] + actual_data = scale(data, -3.14, 3.14, 0, 10) + for expected, actual in zip(expected_data, actual_data): + self.assertAlmostEqual(expected, actual) + + def testScaleDataOverRealRange(self): + scale = util.ScaleData + self.assertEqual([0, 30.5, 61], scale([1, 2, 3], 1, 3, 0, 61)) + + def testScalingLotsOfData(self): + data = range(0, 100) + expected = range(-100, 100, 2) + actual = util.ScaleData(data, 0, 100, -100, 100) + self.assertEqual(expected, actual) + + +class NameTest(graphy_test.GraphyTest): + + """Test long/short parameter names.""" + + def testLongNames(self): + params = dict(size='S', data='D', chg='G') + params = util.ShortenParameterNames(params) + self.assertEqual(dict(chs='S', chd='D', chg='G'), params) + + def testCantUseBothLongAndShortName(self): + """Make sure we don't let the user specify both the long and the short + version of a parameter. (If we did, which one would we pick?) + """ + params = dict(size='long', chs='short') + self.assertRaises(KeyError, util.ShortenParameterNames, params) + + +if __name__ == '__main__': + unittest.main() diff --git a/graphy/bar_chart.py b/graphy/bar_chart.py new file mode 100644 index 0000000..c046fca --- /dev/null +++ b/graphy/bar_chart.py @@ -0,0 +1,171 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Code related to bar charts.""" + +import copy +import warnings + +from graphy import common +from graphy import util + + +class BarsStyle(object): + """Style of a series of bars in a BarChart + + Object Attributes: + color: Hex string, like '00ff00' for green + """ + def __init__(self, color): + self.color = color + + +class BarChartStyle(object): + """Represents the style for bars on a BarChart. + + Any of the object attributes may be set to None, in which case the + value will be auto-calculated. + + Object Attributes: + bar_thickness: The thickness of a bar, in pixels. + bar_gap: The gap between bars, in pixels, or as a fraction of bar thickness + if use_fractional_gap_spacing is True. + group_gap: The gap between groups of bars, in pixels, or as a fraction of + bar thickness if use_fractional_gap_spacing is True. + use_fractional_gap_spacing: if True, bar_gap and group_gap specify gap + sizes as a fraction of bar width. Default is False. + """ + + _DEFAULT_GROUP_GAP = 8 + _DEFAULT_BAR_GAP = 4 + + def __init__(self, bar_thickness=None, + bar_gap=_DEFAULT_BAR_GAP, group_gap=_DEFAULT_GROUP_GAP, + use_fractional_gap_spacing=False): + """Create a new BarChartStyle. + + Args: + bar_thickness: The thickness of a bar, in pixels. Set this to None if + you want the bar thickness to be auto-calculated (this is the default + behaviour). + bar_gap: The gap between bars, in pixels. Default is 4. + group_gap: The gap between groups of bars, in pixels. Default is 8. + """ + self.bar_thickness = bar_thickness + self.bar_gap = bar_gap + self.group_gap = group_gap + self.use_fractional_gap_spacing = use_fractional_gap_spacing + + +class BarStyle(BarChartStyle): + + def __init__(self, *args, **kwargs): + warnings.warn('BarStyle is deprecated. Use BarChartStyle.', + DeprecationWarning, stacklevel=2) + super(BarStyle, self).__init__(*args, **kwargs) + + +class BarChart(common.BaseChart): + """Represents a bar chart. + + Object attributes: + vertical: if True, the bars will be vertical. Default is True. + stacked: if True, the bars will be stacked. Default is False. + style: The BarChartStyle for all bars on this chart, specifying bar + thickness and gaps between bars. + """ + + def __init__(self, points=None): + """Constructor for BarChart objects.""" + super(BarChart, self).__init__() + if points is not None: + self.AddBars(points) + self.vertical = True + self.stacked = False + self.style = BarChartStyle(None, None, None) # full auto + + def AddBars(self, points, label=None, color=None): + """Add a series of bars to the chart. + + points: List of y-values for the bars in this series + label: Name of the series (used in the legend) + color: Hex string, like '00ff00' for green + + This is a convenience method which constructs & appends the DataSeries for + you. + """ + if label is not None and util._IsColor(label): + warnings.warn('Your code may be broken! ' + 'Label is a hex triplet. Maybe it is a color? The ' + 'old argument order (color before label) is deprecated.', + DeprecationWarning, stacklevel=2) + style = BarsStyle(color) + series = common.DataSeries(points, label=label, style=style) + self.data.append(series) + return series + + def GetDependentAxes(self): + """Get the dependendant axes, which depend on orientation.""" + if self.vertical: + return (self._axes[common.AxisPosition.LEFT] + + self._axes[common.AxisPosition.RIGHT]) + else: + return (self._axes[common.AxisPosition.TOP] + + self._axes[common.AxisPosition.BOTTOM]) + + def GetIndependentAxes(self): + """Get the independendant axes, which depend on orientation.""" + if self.vertical: + return (self._axes[common.AxisPosition.TOP] + + self._axes[common.AxisPosition.BOTTOM]) + else: + return (self._axes[common.AxisPosition.LEFT] + + self._axes[common.AxisPosition.RIGHT]) + + def GetDependentAxis(self): + """Get the main dependendant axis, which depends on orientation.""" + if self.vertical: + return self.left + else: + return self.bottom + + def GetIndependentAxis(self): + """Get the main independendant axis, which depends on orientation.""" + if self.vertical: + return self.bottom + else: + return self.left + + def GetMinMaxValues(self): + """Get the largest & smallest bar values as (min_value, max_value).""" + if not self.stacked: + return super(BarChart, self).GetMinMaxValues() + + if not self.data: + return None, None # No data, nothing to do. + num_bars = max(len(series.data) for series in self.data) + positives = [0 for i in xrange(0, num_bars)] + negatives = list(positives) + for series in self.data: + for i, point in enumerate(series.data): + if point: + if point > 0: + positives[i] += point + else: + negatives[i] += point + min_value = min(min(positives), min(negatives)) + max_value = max(max(positives), max(negatives)) + return min_value, max_value diff --git a/graphy/bar_chart_test.py b/graphy/bar_chart_test.py new file mode 100755 index 0000000..701396d --- /dev/null +++ b/graphy/bar_chart_test.py @@ -0,0 +1,83 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Tests for bar_chart.py.""" + +import warnings + +from graphy import common +from graphy import bar_chart +from graphy import graphy_test +from graphy.backends import google_chart_api + + +class BarChartTest(graphy_test.GraphyTest): + + def setUp(self): + self.chart = google_chart_api.BarChart() + + def tearDown(self): + warnings.resetwarnings() + + # TODO: remove once the deprecation warning is removed + def testBarStyleStillExists(self): + warnings.filterwarnings('ignore') + x = bar_chart.BarStyle(None, None, None) + + # TODO: remove once the deprecation warning is removed + def testAddBarArgumentOrder(self): + # Deprecated approach + chart = bar_chart.BarChart() + warnings.filterwarnings('error') + self.assertRaises(DeprecationWarning, chart.AddBars, [1, 2, 3], + '0000FF', 'label') + + # New order + chart = bar_chart.BarChart() + chart.AddBars([1, 2, 3], 'label', '0000FF') + self.assertEqual('label', chart.data[0].label) + self.assertEqual('0000FF', chart.data[0].style.color) + + def testGetDependentIndependentAxes(self): + c = self.chart + c.vertical = True + self.assertEqual([c.left, c.right], c.GetDependentAxes()) + self.assertEqual([c.top, c.bottom], c.GetIndependentAxes()) + c.vertical = False + self.assertEqual([c.top, c.bottom], c.GetDependentAxes()) + self.assertEqual([c.left, c.right], c.GetIndependentAxes()) + + right2 = c.AddAxis(common.AxisPosition.RIGHT, common.Axis()) + bottom2 = c.AddAxis(common.AxisPosition.BOTTOM, common.Axis()) + + c.vertical = True + self.assertEqual([c.left, c.right, right2], c.GetDependentAxes()) + self.assertEqual([c.top, c.bottom, bottom2], c.GetIndependentAxes()) + c.vertical = False + self.assertEqual([c.top, c.bottom, bottom2], c.GetDependentAxes()) + self.assertEqual([c.left, c.right, right2], c.GetIndependentAxes()) + + def testDependentIndependentAxis(self): + self.chart.vertical = True + self.assertTrue(self.chart.left is self.chart.GetDependentAxis()) + self.assertTrue(self.chart.bottom is self.chart.GetIndependentAxis()) + self.chart.vertical = False + self.assertTrue(self.chart.bottom, self.chart.GetDependentAxis()) + self.assertTrue(self.chart.left, self.chart.GetIndependentAxis()) + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/common.py b/graphy/common.py new file mode 100644 index 0000000..f74ca8c --- /dev/null +++ b/graphy/common.py @@ -0,0 +1,412 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Code common to all chart types.""" + +import copy +import warnings + +from graphy import formatters +from graphy import util + + +class Marker(object): + + """Represents an abstract marker, without position. You can attach these to + a DataSeries. + + Object attributes: + shape: One of the shape codes (Marker.arrow, Marker.diamond, etc.) + color: color (as hex string, f.ex. '0000ff' for blue) + size: size of the marker + """ + # TODO: Write an example using markers. + + # Shapes: + arrow = 'a' + cross = 'c' + diamond = 'd' + circle = 'o' + square = 's' + x = 'x' + + # Note: The Google Chart API also knows some other markers ('v', 'V', 'r', + # 'b') that I think would fit better into a grid API. + # TODO: Make such a grid API + + def __init__(self, shape, color, size): + """Construct a Marker. See class docstring for details on args.""" + # TODO: Shapes 'r' and 'b' would be much easier to use if they had a + # special-purpose API (instead of trying to fake it with markers) + self.shape = shape + self.color = color + self.size = size + + +class _BasicStyle(object): + """Basic style object. Used internally.""" + + def __init__(self, color): + self.color = color + + +class DataSeries(object): + + """Represents one data series for a chart (both data & presentation + information). + + Object attributes: + points: List of numbers representing y-values (x-values are not specified + because the Google Chart API expects even x-value spacing). + label: String with the series' label in the legend. The chart will only + have a legend if at least one series has a label. If some series + do not have a label then they will have an empty description in + the legend. This is currently a limitation in the Google Chart + API. + style: A chart-type-specific style object. (LineStyle for LineChart, + BarsStyle for BarChart, etc.) + markers: List of (x, m) tuples where m is a Marker object and x is the + x-axis value to place it at. + + The "fill" markers ('r' & 'b') are a little weird because they + aren't a point on a line. For these, you can fake it by + passing slightly weird data (I'd like a better API for them at + some point): + For 'b', you attach the marker to the starting series, and set x + to the index of the ending line. Size is ignored, I think. + + For 'r', you can attach to any line, specify the starting + y-value for x and the ending y-value for size. Y, in this case, + is becase 0.0 (bottom) and 1.0 (top). + color: DEPRECATED + """ + + # TODO: Should we require the points list to be non-empty ? + # TODO: Do markers belong here? They are really only used for LineCharts + def __init__(self, points, label=None, style=None, markers=None, color=None): + """Construct a DataSeries. See class docstring for details on args.""" + if label is not None and util._IsColor(label): + warnings.warn('Your code may be broken! Label is a hex triplet. Maybe ' + 'it is a color? The old argument order (color & style ' + 'before label) is deprecated.', DeprecationWarning, + stacklevel=2) + if color is not None: + warnings.warn('Passing color is deprecated. Pass a style object ' + 'instead.', DeprecationWarning, stacklevel=2) + # Attempt to fix it for them. If they also passed a style, honor it. + if style is None: + style = _BasicStyle(color) + if style is not None and isinstance(style, basestring): + warnings.warn('Your code is broken! Style is a string, not an object. ' + 'Maybe you are passing a color? Passing color is ' + 'deprecated; pass a style object instead.', + DeprecationWarning, stacklevel=2) + if style is None: + style = _BasicStyle(None) + self.data = points + self.style = style + self.markers = markers or [] + self.label = label + + def _GetColor(self): + warnings.warn('DataSeries.color is deprecated, use ' + 'DataSeries.style.color instead.', DeprecationWarning, + stacklevel=2) + return self.style.color + + def _SetColor(self, color): + warnings.warn('DataSeries.color is deprecated, use ' + 'DataSeries.style.color instead.', DeprecationWarning, + stacklevel=2) + self.style.color = color + + color = property(_GetColor, _SetColor) + + +class AxisPosition(object): + """Represents all the available axis positions. + + The available positions are as follows: + AxisPosition.TOP + AxisPosition.BOTTOM + AxisPosition.LEFT + AxisPosition.RIGHT + """ + LEFT = 'y' + RIGHT = 'r' + BOTTOM = 'x' + TOP = 't' + + +class Axis(object): + + """Represents one axis. + + Object setings: + min: Minimum value for the bottom or left end of the axis + max: Max value. + labels: List of labels to show along the axis. + label_positions: List of positions to show the labels at. Uses the scale + set by min & max, so if you set min = 0 and max = 10, then + label positions [0, 5, 10] would be at the bottom, + middle, and top of the axis, respectively. + grid_spacing: Amount of space between gridlines (in min/max scale). + A value of 0 disables gridlines. + label_gridlines: If True, draw a line extending from each label + on the axis all the way across the chart. + """ + + def __init__(self, axis_min=None, axis_max=None): + """Construct a new Axis. + + Args: + axis_min: smallest value on the axis + axis_max: largest value on the axis + """ + self.min = axis_min + self.max = axis_max + self.labels = [] + self.label_positions = [] + self.grid_spacing = 0 + self.label_gridlines = False + +# TODO: Add other chart types. Order of preference: +# - scatter plots +# - us/world maps + +class BaseChart(object): + """Base chart object with standard behavior for all other charts. + + Object attributes: + data: List of DataSeries objects. Chart subtypes provide convenience + functions (like AddLine, AddBars, AddSegment) to add more series + later. + left/right/bottom/top: Axis objects for the 4 different axes. + formatters: A list of callables which will be used to format this chart for + display. TODO: Need better documentation for how these + work. + auto_scale, auto_color, auto_legend: + These aliases let users access the default formatters without poking + around in self.formatters. If the user removes them from + self.formatters then they will no longer be enabled, even though they'll + still be accessible through the aliases. Similarly, re-assigning the + aliases has no effect on the contents of self.formatters. + display: This variable is reserved for backends to populate with a display + object. The intention is that the display object would be used to + render this chart. The details of what gets put here depends on + the specific backend you are using. + """ + + # Canonical ordering of position keys + _POSITION_CODES = 'yrxt' + + # TODO: Add more inline args to __init__ (esp. labels). + # TODO: Support multiple series in the constructor, if given. + def __init__(self): + """Construct a BaseChart object.""" + self.data = [] + + self._axes = {} + for code in self._POSITION_CODES: + self._axes[code] = [Axis()] + self._legend_labels = [] # AutoLegend fills this out + self._show_legend = False # AutoLegend fills this out + + # Aliases for default formatters + self.auto_color = formatters.AutoColor() + self.auto_scale = formatters.AutoScale() + self.auto_legend = formatters.AutoLegend + self.formatters = [self.auto_color, self.auto_scale, self.auto_legend] + # display is used to convert the chart into something displayable (like a + # url or img tag). + self.display = None + + def AddFormatter(self, formatter): + """Add a new formatter to the chart (convenience method).""" + self.formatters.append(formatter) + + def AddSeries(self, points, color=None, style=None, markers=None, + label=None): + """DEPRECATED + + Add a new series of data to the chart; return the DataSeries object.""" + warnings.warn('AddSeries is deprecated. Instead, call AddLine for ' + 'LineCharts, AddBars for BarCharts, AddSegment for ' + 'PieCharts ', DeprecationWarning, stacklevel=2) + series = DataSeries(points, color=color, style=style, markers=markers, + label=label) + self.data.append(series) + return series + + def GetDependentAxes(self): + """Return any dependent axes ('left' and 'right' by default for LineCharts, + although bar charts would use 'bottom' and 'top'). + """ + return self._axes[AxisPosition.LEFT] + self._axes[AxisPosition.RIGHT] + + def GetIndependentAxes(self): + """Return any independent axes (normally top & bottom, although horizontal + bar charts use left & right by default). + """ + return self._axes[AxisPosition.TOP] + self._axes[AxisPosition.BOTTOM] + + def GetDependentAxis(self): + """Return this chart's main dependent axis (often 'left', but + horizontal bar-charts use 'bottom'). + """ + return self.left + + def GetIndependentAxis(self): + """Return this chart's main independent axis (often 'bottom', but + horizontal bar-charts use 'left'). + """ + return self.bottom + + def _Clone(self): + """Make a deep copy this chart. + + Formatters & display will be missing from the copy, due to limitations in + deepcopy. + """ + orig_values = {} + # Things which deepcopy will likely choke on if it tries to copy. + uncopyables = ['formatters', 'display', 'auto_color', 'auto_scale', + 'auto_legend'] + for name in uncopyables: + orig_values[name] = getattr(self, name) + setattr(self, name, None) + clone = copy.deepcopy(self) + for name, orig_value in orig_values.iteritems(): + setattr(self, name, orig_value) + return clone + + def GetFormattedChart(self): + """Get a copy of the chart with formatting applied.""" + # Formatters need to mutate the chart, but we don't want to change it out + # from under the user. So, we work on a copy of the chart. + scratchpad = self._Clone() + for formatter in self.formatters: + formatter(scratchpad) + return scratchpad + + def GetMinMaxValues(self): + """Get the largest & smallest values in this chart, returned as + (min_value, max_value). Takes into account complciations like stacked data + series. + + For example, with non-stacked series, a chart with [1, 2, 3] and [4, 5, 6] + would return (1, 6). If the same chart was stacking the data series, it + would return (5, 9). + """ + MinPoint = lambda data: min(x for x in data if x is not None) + MaxPoint = lambda data: max(x for x in data if x is not None) + mins = [MinPoint(series.data) for series in self.data if series.data] + maxes = [MaxPoint(series.data) for series in self.data if series.data] + if not mins or not maxes: + return None, None # No data, just bail. + return min(mins), max(maxes) + + def AddAxis(self, position, axis): + """Add an axis to this chart in the given position. + + Args: + position: an AxisPosition object specifying the axis's position + axis: The axis to add, an Axis object + Returns: + the value of the axis parameter + """ + self._axes.setdefault(position, []).append(axis) + return axis + + def GetAxis(self, position): + """Get or create the first available axis in the given position. + + This is a helper method for the left, right, top, and bottom properties. + If the specified axis does not exist, it will be created. + + Args: + position: the position to search for + Returns: + The first axis in the given position + """ + # Not using setdefault here just in case, to avoid calling the Axis() + # constructor needlessly + if position in self._axes: + return self._axes[position][0] + else: + axis = Axis() + self._axes[position] = [axis] + return axis + + def SetAxis(self, position, axis): + """Set the first axis in the given position to the given value. + + This is a helper method for the left, right, top, and bottom properties. + + Args: + position: an AxisPosition object specifying the axis's position + axis: The axis to set, an Axis object + Returns: + the value of the axis parameter + """ + self._axes.setdefault(position, [None])[0] = axis + return axis + + def _GetAxes(self): + """Return a generator of (position_code, Axis) tuples for this chart's axes. + + The axes will be sorted by position using the canonical ordering sequence, + _POSITION_CODES. + """ + for code in self._POSITION_CODES: + for axis in self._axes.get(code, []): + yield (code, axis) + + def _GetBottom(self): + return self.GetAxis(AxisPosition.BOTTOM) + + def _SetBottom(self, value): + self.SetAxis(AxisPosition.BOTTOM, value) + + bottom = property(_GetBottom, _SetBottom, + doc="""Get or set the bottom axis""") + + def _GetLeft(self): + return self.GetAxis(AxisPosition.LEFT) + + def _SetLeft(self, value): + self.SetAxis(AxisPosition.LEFT, value) + + left = property(_GetLeft, _SetLeft, + doc="""Get or set the left axis""") + + def _GetRight(self): + return self.GetAxis(AxisPosition.RIGHT) + + def _SetRight(self, value): + self.SetAxis(AxisPosition.RIGHT, value) + + right = property(_GetRight, _SetRight, + doc="""Get or set the right axis""") + + def _GetTop(self): + return self.GetAxis(AxisPosition.TOP) + + def _SetTop(self, value): + self.SetAxis(AxisPosition.TOP, value) + + top = property(_GetTop, _SetTop, + doc="""Get or set the top axis""") diff --git a/graphy/common_test.py b/graphy/common_test.py new file mode 100755 index 0000000..ef53ba7 --- /dev/null +++ b/graphy/common_test.py @@ -0,0 +1,108 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Tests for common.py.""" + +import warnings + +from graphy import common +from graphy import graphy_test +from graphy.backends import google_chart_api + + +class CommonTest(graphy_test.GraphyTest): + + def setUp(self): + self.chart = google_chart_api.LineChart() + + def tearDown(self): + warnings.resetwarnings() + + def testDependentAxis(self): + self.assertTrue(self.chart.left is self.chart.GetDependentAxis()) + self.assertTrue(self.chart.bottom is self.chart.GetIndependentAxis()) + + def testAxisAssignment(self): + """Make sure axis assignment works properly""" + new_axis = common.Axis() + self.chart.top = new_axis + self.assertTrue(self.chart.top is new_axis) + new_axis = common.Axis() + self.chart.bottom = new_axis + self.assertTrue(self.chart.bottom is new_axis) + new_axis = common.Axis() + self.chart.left = new_axis + self.assertTrue(self.chart.left is new_axis) + new_axis = common.Axis() + self.chart.right = new_axis + self.assertTrue(self.chart.right is new_axis) + + def testAxisConstruction(self): + axis = common.Axis() + self.assertTrue(axis.min is None) + self.assertTrue(axis.max is None) + axis = common.Axis(-2, 16) + self.assertEqual(axis.min, -2) + self.assertEqual(axis.max, 16) + + def testGetDependentIndependentAxes(self): + c = self.chart + self.assertEqual([c.left, c.right], c.GetDependentAxes()) + self.assertEqual([c.top, c.bottom], c.GetIndependentAxes()) + right2 = c.AddAxis(common.AxisPosition.RIGHT, common.Axis()) + bottom2 = c.AddAxis(common.AxisPosition.BOTTOM, common.Axis()) + self.assertEqual([c.left, c.right, right2], c.GetDependentAxes()) + self.assertEqual([c.top, c.bottom, bottom2], c.GetIndependentAxes()) + + # TODO: remove once AddSeries is deleted + def testAddSeries(self): + warnings.filterwarnings('ignore') + chart = common.BaseChart() + chart.AddSeries(points=[1, 2, 3], style='foo', + markers='markers', label='label') + series = chart.data[0] + self.assertEqual(series.data, [1, 2, 3]) + self.assertEqual(series.style, 'foo') + self.assertEqual(series.markers, 'markers') + self.assertEqual(series.label, 'label') + + # TODO: remove once the deprecation warning is removed + def testDataSeriesStyles(self): + # Deprecated approach + warnings.filterwarnings('error') + self.assertRaises(DeprecationWarning, common.DataSeries, [1, 2, 3], + color='0000FF') + warnings.filterwarnings('ignore') + d = common.DataSeries([1, 2, 3], color='0000FF') + self.assertEqual('0000FF', d.color) + d.color = 'F00' + self.assertEqual('F00', d.color) + + # TODO: remove once the deprecation warning is removed + def testDataSeriesArgumentOrder(self): + # Deprecated approach + warnings.filterwarnings('error') + self.assertRaises(DeprecationWarning, common.DataSeries, [1, 2, 3], + '0000FF', 'style') + + # New order + style = common._BasicStyle('0000FF') + d = common.DataSeries([1, 2, 3], 'label', style) + self.assertEqual('label', d.label) + self.assertEqual(style, d.style) + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/formatters.py b/graphy/formatters.py new file mode 100644 index 0000000..b2991b1 --- /dev/null +++ b/graphy/formatters.py @@ -0,0 +1,192 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""This module contains various formatters which can help format a chart +object. To use these, add them to your chart's list of formatters. For +example: + chart.formatters.append(InlineLegend) + chart.formatters.append(LabelSeparator(right=8)) + +Feel free to write your own formatter. Formatters are just callables that +modify the chart in some (hopefully useful) way. For example, the AutoColor +formatter makes sure each DataSeries has a color applied to it. The formatter +should take the chart to format as its only argument. + +(The formatters work on a deepcopy of the user's chart, so modifications +shouldn't leak back into the user's original chart) +""" + +def AutoLegend(chart): + """Automatically fill out the legend based on series labels. This will only + fill out the legend if is at least one series with a label. + """ + chart._show_legend = False + labels = [] + for series in chart.data: + if series.label is None: + labels.append('') + else: + labels.append(series.label) + chart._show_legend = True + if chart._show_legend: + chart._legend_labels = labels + + +class AutoColor(object): + """Automatically add colors to any series without colors. + + Object attributes: + colors: The list of colors (hex strings) to cycle through. You can modify + this list if you don't like the default colors. + """ + def __init__(self): + # TODO: Add a few more default colors. + # TODO: Add a default styles too, so if you don't specify color or + # style, you get a unique set of colors & styles for your data. + self.colors = ['0000ff', 'ff0000', '00dd00', '000000'] + + def __call__(self, chart): + index = -1 + for series in chart.data: + if series.style.color is None: + index += 1 + if index >= len(self.colors): + index = 0 + series.style.color = self.colors[index] + + +class AutoScale(object): + """If you don't set min/max on the dependent axes, this fills them in + automatically by calculating min/max dynamically from the data. + + You can set just min or just max and this formatter will fill in the other + value for you automatically. For example, if you only set min then this will + set max automatically, but leave min untouched. + + Charts can have multiple dependent axes (chart.left & chart.right, for + example.) If you set min/max on some axes but not others, then this formatter + copies your min/max to the un-set axes. For example, if you set up min/max on + only the right axis then your values will be automatically copied to the left + axis. (if you use different min/max values for different axes, the + precendence is undefined. So don't do that.) + """ + + def __init__(self, buffer=0.05): + """Create a new AutoScale formatter. + + Args: + buffer: percentage of extra space to allocate around the chart's axes. + """ + self.buffer = buffer + + def __call__(self, chart): + """Format the chart by setting the min/max values on its dependent axis.""" + if not chart.data: + return # Nothing to do. + min_value, max_value = chart.GetMinMaxValues() + if None in (min_value, max_value): + return # No data. Nothing to do. + + # Honor user's choice, if they've picked min/max. + for axis in chart.GetDependentAxes(): + if axis.min is not None: + min_value = axis.min + if axis.max is not None: + max_value = axis.max + + buffer = (max_value - min_value) * self.buffer # Stay away from edge. + + for axis in chart.GetDependentAxes(): + if axis.min is None: + axis.min = min_value - buffer + if axis.max is None: + axis.max = max_value + buffer + + +class LabelSeparator(object): + + """Adjust the label positions to avoid having them overlap. This happens for + any axis with minimum_label_spacing set. + """ + + def __init__(self, left=None, right=None, bottom=None): + self.left = left + self.right = right + self.bottom = bottom + + def __call__(self, chart): + self.AdjustLabels(chart.left, self.left) + self.AdjustLabels(chart.right, self.right) + self.AdjustLabels(chart.bottom, self.bottom) + + def AdjustLabels(self, axis, minimum_label_spacing): + if minimum_label_spacing is None: + return + if len(axis.labels) <= 1: # Nothing to adjust + return + if axis.max is not None and axis.min is not None: + # Find the spacing required to fit all labels evenly. + # Don't try to push them farther apart than that. + maximum_possible_spacing = (axis.max - axis.min) / (len(axis.labels) - 1) + if minimum_label_spacing > maximum_possible_spacing: + minimum_label_spacing = maximum_possible_spacing + + labels = [list(x) for x in zip(axis.label_positions, axis.labels)] + labels = sorted(labels, reverse=True) + + # First pass from the top, moving colliding labels downward + for i in range(1, len(labels)): + if labels[i - 1][0] - labels[i][0] < minimum_label_spacing: + new_position = labels[i - 1][0] - minimum_label_spacing + if axis.min is not None and new_position < axis.min: + new_position = axis.min + labels[i][0] = new_position + + # Second pass from the bottom, moving colliding labels upward + for i in range(len(labels) - 2, -1, -1): + if labels[i][0] - labels[i + 1][0] < minimum_label_spacing: + new_position = labels[i + 1][0] + minimum_label_spacing + if axis.max is not None and new_position > axis.max: + new_position = axis.max + labels[i][0] = new_position + + # Separate positions and labels + label_positions, labels = zip(*labels) + axis.labels = labels + axis.label_positions = label_positions + + +def InlineLegend(chart): + """Provide a legend for line charts by attaching labels to the right + end of each line. Supresses the regular legend. + """ + show = False + labels = [] + label_positions = [] + for series in chart.data: + if series.label is None: + labels.append('') + else: + labels.append(series.label) + show = True + label_positions.append(series.data[-1]) + + if show: + chart.right.min = chart.left.min + chart.right.max = chart.left.max + chart.right.labels = labels + chart.right.label_positions = label_positions + chart._show_legend = False # Supress the regular legend. diff --git a/graphy/formatters_test.py b/graphy/formatters_test.py new file mode 100755 index 0000000..79efc20 --- /dev/null +++ b/graphy/formatters_test.py @@ -0,0 +1,106 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Tests for the formatters.""" + +from graphy import common +from graphy import formatters +from graphy import graphy_test +from graphy.backends import google_chart_api + + +class InlineLegendTest(graphy_test.GraphyTest): + + def setUp(self): + self.chart = google_chart_api.LineChart() + self.chart.formatters.append(formatters.InlineLegend) + self.chart.AddLine([1, 2, 3], label='A') + self.chart.AddLine([4, 5, 6], label='B') + self.chart.auto_scale.buffer = 0 + + def testLabelsAdded(self): + self.assertEqual(self.Param('chxl'), '0:|A|B') + + def testLabelPositionedCorrectly(self): + self.assertEqual(self.Param('chxp'), '0,3,6') + self.assertEqual(self.Param('chxr'), '0,1,6') + + def testRegularLegendSuppressed(self): + self.assertRaises(KeyError, self.Param, 'chdl') + + +class AutoScaleTest(graphy_test.GraphyTest): + + def setUp(self): + self.chart = google_chart_api.LineChart([1, 2, 3]) + self.auto_scale = formatters.AutoScale(buffer=0) + + def testNormalCase(self): + self.auto_scale(self.chart) + self.assertEqual(1, self.chart.left.min) + self.assertEqual(3, self.chart.left.max) + + def testKeepsDataAwayFromEdgesByDefault(self): + self.auto_scale = formatters.AutoScale() + self.auto_scale(self.chart) + self.assertTrue(1 > self.chart.left.min) + self.assertTrue(3 < self.chart.left.max) + + def testDoNothingIfNoData(self): + self.chart.data = [] + self.auto_scale(self.chart) + self.assertEqual(None, self.chart.left.min) + self.assertEqual(None, self.chart.left.max) + self.chart.AddLine([]) + self.auto_scale(self.chart) + self.assertEqual(None, self.chart.left.min) + self.assertEqual(None, self.chart.left.max) + + def testKeepMinIfSet(self): + self.chart.left.min = -10 + self.auto_scale(self.chart) + self.assertEqual(-10, self.chart.left.min) + self.assertEqual(3, self.chart.left.max) + + def testKeepMaxIfSet(self): + self.chart.left.max = 9 + self.auto_scale(self.chart) + self.assertEqual(1, self.chart.left.min) + self.assertEqual(9, self.chart.left.max) + + def testOtherDependentAxesAreAlsoSet(self): + self.chart.AddAxis(common.AxisPosition.LEFT, common.Axis()) + self.chart.AddAxis(common.AxisPosition.RIGHT, common.Axis()) + self.assertEqual(4, len(self.chart.GetDependentAxes())) + self.auto_scale(self.chart) + for axis in self.chart.GetDependentAxes(): + self.assertEqual(1, axis.min) + self.assertEqual(3, axis.max) + + def testRightSetsLeft(self): + """If user sets min/max on right but NOT left, they are copied to left. + (Otherwise the data will be scaled differently from the right-axis labels, + which is bad). + """ + self.chart.right.min = 18 + self.chart.right.max = 19 + self.auto_scale(self.chart) + self.assertEqual(18, self.chart.left.min) + self.assertEqual(19, self.chart.left.max) + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/graphy_test.py b/graphy/graphy_test.py new file mode 100755 index 0000000..24119a4 --- /dev/null +++ b/graphy/graphy_test.py @@ -0,0 +1,46 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Base test code for Graphy.""" + +import unittest + + +class GraphyTest(unittest.TestCase): + """Base class for other Graphy tests.""" + + def assertIn(self, a, b, msg=None): + """Just like self.assert_(a in b), but with a nicer default message.""" + if msg is None: + msg = '"%s" not found in "%s"' % (a, b) + self.assert_(a in b, msg) + + def assertNotIn(self, a, b, msg=None): + """Just like self.assert_(a not in b), but with a nicer default message.""" + if msg is None: + msg = '"%s" unexpectedly found in "%s"' % (a, b) + self.assert_(a not in b, msg) + + def Param(self, param_name, chart=None): + """Helper to look up a Google Chart API parameter for the given chart.""" + if chart is None: + chart = self.chart + params = chart.display._Params(chart) + return params[param_name] + +def main(): + """Wrap unittest.main (for convenience of caller).""" + return unittest.main() diff --git a/graphy/line_chart.py b/graphy/line_chart.py new file mode 100644 index 0000000..b8ad5af --- /dev/null +++ b/graphy/line_chart.py @@ -0,0 +1,122 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Code related to line charts.""" + +import copy +import warnings + +from graphy import common + + +class LineStyle(object): + + """Represents the style for a line on a line chart. Also provides some + convenient presets. + + Object attributes (Passed directly to the Google Chart API. Check there for + details): + width: Width of the line + on: Length of a line segment (for dashed/dotted lines) + off: Length of a break (for dashed/dotted lines) + color: Color of the line. A hex string, like 'ff0000' for red. Optional, + AutoColor will fill this in for you automatically if empty. + + Some common styles, such as LineStyle.dashed, are available: + solid + dashed + dotted + thick_solid + thick_dashed + thick_dotted + """ + + # Widths + THIN = 1 + THICK = 2 + + # Patterns + # ((on, off) tuples, as passed to LineChart.AddLine) + SOLID = (1, 0) + DASHED = (8, 4) + DOTTED = (2, 4) + + def __init__(self, width, on, off, color=None): + """Construct a LineStyle. See class docstring for details on args.""" + self.width = width + self.on = on + self.off = off + self.color = color + + +LineStyle.solid = LineStyle(1, 1, 0) +LineStyle.dashed = LineStyle(1, 8, 4) +LineStyle.dotted = LineStyle(1, 2, 4) +LineStyle.thick_solid = LineStyle(2, 1, 0) +LineStyle.thick_dashed = LineStyle(2, 8, 4) +LineStyle.thick_dotted = LineStyle(2, 2, 4) + + +class LineChart(common.BaseChart): + + """Represents a line chart.""" + + def __init__(self, points=None): + super(LineChart, self).__init__() + if points is not None: + self.AddLine(points) + + def AddLine(self, points, label=None, color=None, + pattern=LineStyle.SOLID, width=LineStyle.THIN, markers=None): + """Add a new line to the chart. + + This is a convenience method which constructs the DataSeries and appends it + for you. It returns the new series. + + points: List of equally-spaced y-values for the line + label: Name of the line (used for the legend) + color: Hex string, like 'ff0000' for red + pattern: Tuple for (length of segment, length of gap). i.e. + LineStyle.DASHED + width: Width of the line (i.e. LineStyle.THIN) + markers: List of Marker objects to attach to this line (see DataSeries + for more info) + """ + if color is not None and isinstance(color[0], common.Marker): + warnings.warn('Your code may be broken! ' + 'You passed a list of Markers instead of a color. The ' + 'old argument order (markers before color) is deprecated.', + DeprecationWarning, stacklevel=2) + style = LineStyle(width, pattern[0], pattern[1], color=color) + series = common.DataSeries(points, label=label, style=style, + markers=markers) + self.data.append(series) + return series + + def AddSeries(self, points, color=None, style=LineStyle.solid, markers=None, + label=None): + """DEPRECATED""" + warnings.warn('LineChart.AddSeries is deprecated. Call AddLine instead. ', + DeprecationWarning, stacklevel=2) + return self.AddLine(points, color=color, width=style.width, + pattern=(style.on, style.off), markers=markers, + label=label) + + +class Sparkline(LineChart): + """Represent a sparkline. These behave like LineCharts, + mostly, but come without axes. + """ diff --git a/graphy/line_chart_test.py b/graphy/line_chart_test.py new file mode 100644 index 0000000..887cb75 --- /dev/null +++ b/graphy/line_chart_test.py @@ -0,0 +1,77 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Tests for line_chart.py.""" + +import warnings + +from graphy import common +from graphy import line_chart +from graphy import graphy_test + + +# TODO: All the different charts are expected to support a similar API (like +# having a display object, having a list of data series, axes, etc.). Add some +# tests that run against all the charts to make sure they conform to the API. + +class LineChartTest(graphy_test.GraphyTest): + + def tearDown(self): + warnings.resetwarnings() + + # TODO: remove once AddSeries is deleted + def testAddSeries(self): + warnings.filterwarnings('ignore') + chart = line_chart.LineChart() + chart.AddSeries(points=[1, 2, 3], style=line_chart.LineStyle.solid, + markers='markers', label='label') + series = chart.data[0] + self.assertEqual(series.data, [1, 2, 3]) + self.assertEqual(series.style.width, line_chart.LineStyle.solid.width) + self.assertEqual(series.style.on, line_chart.LineStyle.solid.on) + self.assertEqual(series.style.off, line_chart.LineStyle.solid.off) + self.assertEqual(series.markers, 'markers') + self.assertEqual(series.label, 'label') + + # TODO: remove once the deprecation warning is removed + def testAddLineArgumentOrder(self): + x = common.Marker(common.Marker.x, '0000ff', 5) + + # Deprecated approach + chart = line_chart.LineChart() + warnings.filterwarnings("error") + self.assertRaises(DeprecationWarning, chart.AddLine, [1, 2, 3], + 'label', [x], 'color') + + # New order + chart = line_chart.LineChart() + chart.AddLine([1, 2, 3], 'label', 'color', markers=[x]) + self.assertEqual('label', chart.data[0].label) + self.assertEqual([x], chart.data[0].markers) + self.assertEqual('color', chart.data[0].style.color) + +class LineStyleTest(graphy_test.GraphyTest): + + def testPresets(self): + """Test selected traits from the preset line styles.""" + self.assertEqual(0, line_chart.LineStyle.solid.off) + self.assert_(line_chart.LineStyle.dashed.off > 0) + self.assert_(line_chart.LineStyle.solid.width < + line_chart.LineStyle.thick_solid.width) + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/pie_chart.py b/graphy/pie_chart.py new file mode 100644 index 0000000..a7fd5f2 --- /dev/null +++ b/graphy/pie_chart.py @@ -0,0 +1,178 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Code for pie charts.""" + +import warnings + +from graphy import common +from graphy import util + + +class Segment(common.DataSeries): + """A single segment of the pie chart. + + Object attributes: + size: relative size of the segment + label: label of the segment (if any) + color: color of the segment (if any) + """ + def __init__(self, size, label=None, color=None): + if label is not None and util._IsColor(label): + warnings.warn('Your code may be broken! ' + 'Label looks like a hex triplet; it might be a color. ' + 'The old argument order (color before label) is ' + 'deprecated.', + DeprecationWarning, stacklevel=2) + style = common._BasicStyle(color) + super(Segment, self).__init__([size], label=label, style=style) + assert size >= 0 + + def _GetSize(self): + return self.data[0] + + def _SetSize(self, value): + assert value >= 0 + self.data[0] = value + + size = property(_GetSize, _SetSize, + doc = """The relative size of this pie segment.""") + + # Since Segments are so simple, provide color for convenience. + def _GetColor(self): + return self.style.color + + def _SetColor(self, color): + self.style.color = color + + color = property(_GetColor, _SetColor, + doc = """The color of this pie segment.""") + + +class PieChart(common.BaseChart): + """Represents a pie chart. + + The pie chart consists of a single "pie" by default, but additional pies + may be added using the AddPie method. The Google Chart API will display + the pies as concentric circles, with pie #0 on the inside; other backends + may display the pies differently. + """ + + def __init__(self, points=None, labels=None, colors=None): + """Constructor for PieChart objects. + + Creates a pie chart with a single pie. + + Args: + points: A list of data points for the pie chart; + i.e., relative sizes of the pie segments + labels: A list of labels for the pie segments. + TODO: Allow the user to pass in None as one of + the labels in order to skip that label. + colors: A list of colors for the pie segments, as hex strings + (f.ex. '0000ff' for blue). If there are less colors than pie + segments, the Google Chart API will attempt to produce a smooth + color transition between segments by spreading the colors across + them. + """ + super(PieChart, self).__init__() + self.formatters = [] + self._colors = None + if points: + self.AddPie(points, labels, colors) + + def AddPie(self, points, labels=None, colors=None): + """Add a whole pie to the chart. + + Args: + points: A list of pie segment sizes + labels: A list of labels for the pie segments + colors: A list of colors for the segments. Missing colors will be chosen + automatically. + Return: + The index of the newly added pie. + """ + num_colors = len(colors or []) + num_labels = len(labels or []) + pie_index = len(self.data) + self.data.append([]) + for i, pt in enumerate(points): + label = None + if i < num_labels: + label = labels[i] + color = None + if i < num_colors: + color = colors[i] + self.AddSegment(pt, label=label, color=color, pie_index=pie_index) + return pie_index + + def AddSegments(self, points, labels, colors): + """DEPRECATED.""" + warnings.warn('PieChart.AddSegments is deprecated. Call AddPie instead. ', + DeprecationWarning, stacklevel=2) + num_colors = len(colors or []) + for i, pt in enumerate(points): + assert pt >= 0 + label = labels[i] + color = None + if i < num_colors: + color = colors[i] + self.AddSegment(pt, label=label, color=color) + + def AddSegment(self, size, label=None, color=None, pie_index=0): + """Add a pie segment to this chart, and return the segment. + + size: The size of the segment. + label: The label for the segment. + color: The color of the segment, or None to automatically choose the color. + pie_index: The index of the pie that will receive the new segment. + By default, the chart has one pie (pie #0); use the AddPie method to + add more pies. + """ + if isinstance(size, Segment): + warnings.warn("AddSegment(segment) is deprecated. Use AddSegment(size, " + "label, color) instead", DeprecationWarning, stacklevel=2) + segment = size + else: + segment = Segment(size, label=label, color=color) + assert segment.size >= 0 + if pie_index == 0 and not self.data: + # Create the default pie + self.data.append([]) + assert (pie_index >= 0 and pie_index < len(self.data)) + self.data[pie_index].append(segment) + return segment + + def AddSeries(self, points, color=None, style=None, markers=None, label=None): + """DEPRECATED + + Add a new segment to the chart and return it. + + The segment must contain exactly one data point; all parameters + other than color and label are ignored. + """ + warnings.warn('PieChart.AddSeries is deprecated. Call AddSegment or ' + 'AddSegments instead.', DeprecationWarning) + return self.AddSegment(Segment(points[0], color=color, label=label)) + + def SetColors(self, *colors): + """Change the colors of this chart to the specified list of colors. + + Note that this will completely override the individual colors specified + in the pie segments. Missing colors will be interpolated, so that the + list of colors covers all segments in all the pies. + """ + self._colors = colors diff --git a/graphy/pie_chart_test.py b/graphy/pie_chart_test.py new file mode 100755 index 0000000..5b49ebc --- /dev/null +++ b/graphy/pie_chart_test.py @@ -0,0 +1,110 @@ +#!/usr/bin/python2.4 +# +# Copyright 2008 Google Inc. +# +# Licensed 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. + +"""Tests for pie_chart.py.""" + +import warnings + +from graphy import pie_chart +from graphy import graphy_test + + +class SegmentTest(graphy_test.GraphyTest): + + def setUp(self): + warnings.resetwarnings() + + # TODO: remove once the deprecation warning is removed + def testSegmentOrder(self): + # Deprecated approach + warnings.filterwarnings('error') + self.assertRaises(DeprecationWarning, pie_chart.Segment, 1, + '0000FF', 'label') + + # New order + s = pie_chart.Segment(1, 'label', '0000FF') + self.assertEqual('label', s.label) + self.assertEqual('0000FF', s.color) + + +class PieChartTest(graphy_test.GraphyTest): + + def tearDown(self): + warnings.resetwarnings() + + def testNegativeSegmentSizes(self): + self.assertRaises(AssertionError, pie_chart.PieChart, + [-5, 10], ['Negative', 'Positive']) + chart = pie_chart.PieChart() + self.assertRaises(AssertionError, pie_chart.Segment, -5, 'Dummy', '0000ff') + segment = chart.AddSegment(10, label='Dummy', color='0000ff') + self.assertRaises(AssertionError, segment._SetSize, -5) + + # TODO: remove once the deprecation warning is removed + def testAddSegmentOrder(self): + chart = pie_chart.PieChart() + # Deprecated approach + warnings.filterwarnings('error') + self.assertRaises(DeprecationWarning, chart.AddSegment, 1, + '0000FF', 'label') + + # New order + chart.AddSegment(1, 'label', '0000FF') + self.assertEqual('label', chart.data[0][0].label) + self.assertEqual('0000FF', chart.data[0][0].color) + + # TODO: remove once the deprecation warning is removed + def testAddSegmentsOrder(self): + chart = pie_chart.PieChart() + # Deprecated approach + warnings.filterwarnings('error') + self.assertRaises(DeprecationWarning, chart.AddSegments, [1], + ['0000FF'], ['label']) + # New order + warnings.filterwarnings('ignore') + chart.AddSegments([1], ['label'], ['0000FF']) + self.assertEqual('label', chart.data[0][0].label) + self.assertEqual('0000FF', chart.data[0][0].color) + + def testAddPie(self): + chart = pie_chart.PieChart() + i = chart.AddPie([1], ['A'], ['ff0000']) + self.assertEqual(i, 0) + self.assertEqual(len(chart.data), 1) + self.assertEqual(len(chart.data[0]), 1) + self.assertEqual(chart.data[0][0].size, 1) + i = chart.AddPie([2], ['B'], ['0000ff']) + self.assertEqual(i, 1) + self.assertEqual(len(chart.data), 2) + self.assertEqual(len(chart.data[0]), 1) + self.assertEqual(chart.data[0][0].size, 1) + self.assertEqual(len(chart.data[1]), 1) + self.assertEqual(chart.data[1][0].size, 2) + + def testAddSegmentToPie(self): + chart = pie_chart.PieChart() + chart.AddPie([1], ['A'], ['ff0000']) + chart.AddPie([2], ['B'], ['0000ff']) + chart.AddSegment([10], ['AA']) + self.assertEqual(len(chart.data[0]), 2) + self.assertEqual(len(chart.data[1]), 1) + chart.AddSegment([20], ['BB'], pie_index=1) + self.assertEqual(len(chart.data[0]), 2) + self.assertEqual(len(chart.data[1]), 2) + + +if __name__ == '__main__': + graphy_test.main() diff --git a/graphy/util.py b/graphy/util.py new file mode 100644 index 0000000..a34dafd --- /dev/null +++ b/graphy/util.py @@ -0,0 +1,13 @@ +def _IsColor(color): + """Try to determine if color is a hex color string. + Labels that look like hex colors will match too, unfortunately.""" + if not isinstance(color, basestring): + return False + color = color.strip('#') + if len(color) != 3 and len(color) != 6: + return False + hex_letters = '0123456789abcdefABCDEF' + for letter in color: + if letter not in hex_letters: + return False + return True diff --git a/pomobot.py b/pomobot.py index 3d234c8..a864544 100644 --- a/pomobot.py +++ b/pomobot.py @@ -1,6 +1,135 @@ from ircbotframe import ircBot import sys import datetime +from graphy.backends import google_chart_api + + +class PomoChart: + def __init__(self, bot): + self.bot = bot + + self.color_list= ['8A2BE2', 'A52A2A', 'FFD700', 'DEB887', '5F9EA0', '7FFF00', 'D2691E', 'FF7F50', '6495ED', + 'DC143C', '00FFFF', '00008B', '008B8B', 'B8860B', 'A9A9A9', 'A9A9A9', + '006400', 'BDB76B', '8B008B', '556B2F',] + + def __parsePomo(self, people, line, number): + + #to get only the names + for i in line: + if not i: + continue + id = str(i.split(" ")[0].split(".")[1]) + + if id not in people: + people[id] = [0] * number + + # now the values + for i in line: + if not i: + continue + + id = str(i.split(" ")[0].split(".")[1]) + people[id].append(int(i.split(" ")[1][1:-1])) + + #mark 0 if none pomodoro done + for i in people: + if len(people[i]) <= number: + people[i].append(0) + + def __setUpChart(self, weeks, min = 0, max = 40, labels = [0, 10, 20, 30, 40]): + self.chartBar = google_chart_api.BarChart() + self.chartBar.bottom.labels = weeks + self.chartBar.left.min = min + self.chartBar.left.max = max + self.chartBar.left.labels = labels + self.chartBar.left.labels_positions = labels + + self.chartLine = google_chart_api.LineChart() + self.chartLine.bottom.labels = weeks + self.chartLine.left.min = min + self.chartLine.left.max = max + self.chartLine.left.labels = labels + self.chartLine.left.labels_positions = labels + + def generateChart(self): + result = self.bot.query("Select * from lastranking", ()) + + weeks = [] + pomos = [] + + for i in result: + weeks.append(i[1]) + pomos.append(i[0]) + + pomo_pomocall = {} + + for i in range(len(weeks)): + pomo = pomos[i].split("-")[0][10:] + + if len(pomos[i].split("-")) > 1: + call = pomos[i].split("-")[1][7:] + else: + call = '' + + pomo_pomocall[weeks[i]] = (pomo, call) + + + #Pomodoros + pomo_rank = {} + x = 0 + for i in weeks: + line = pomo_pomocall[i][0].split("|") + + self.__parsePomo(pomo_rank, line , x) + + x=x+1 + + self.__setUpChart(weeks) + color_index = 0 + for i in pomo_rank: + + color = self.color_list[color_index] + self.chartLine.AddLine(pomo_rank[i], label = i, color = color) + self.chartBar.AddBars(pomo_rank[i], label = i, color = color) + color_index = color_index + 1 + + html = "

Pomodoro

" + \ + self.chartLine.display.Img(1000,300) + \ + "

" + \ + self.chartBar.display.Img(1000,300) + + #PomoCalls + pomocall_rank = {} + x = 0 + for i in weeks: + if not pomo_pomocall[i][1]: + continue + + line = pomo_pomocall[i][1].split("|") + + if len(line) > 0: + self.__parsePomo(pomocall_rank, line , x) + + x=x+1 + + self.__setUpChart(weeks, 0, 1500, [0, 100, 200, 300, 400, 600, 800, 1000, + 1200]) + + color_index = 0 + for i in pomocall_rank: + + color = self.color_list[color_index] + self.chartLine.AddLine(pomocall_rank[i], label = i, color = color) + self.chartBar.AddBars(pomocall_rank[i], label = i, color = color) + color_index = color_index + 1 + + html = html + "

PomoCall

" + \ + self.chartLine.display.Img(1000,300) + \ + "

" + \ + self.chartBar.display.Img(1000,300) + \ + "" + + return html # Bot specific function definitions diff --git a/test/test_bot.py b/test/test_bot.py index a3f047a..fce0bd4 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -9,6 +9,7 @@ from ircbotframe import ircBot from pomobot import Pomobot +from pomobot import PomoChart class fake_ircOutputBuffer: # Delays consecutive messages by at least 1 second. @@ -56,10 +57,13 @@ def __init__(self, db): self.connection = sql.connect(db) self.cursor = self.connection.cursor() - def query(self, querymsg, param = ()): + def query_commit(self, querymsg, param = ()): self.cursor.execute(querymsg, param) self.connection.commit() + def query(self, querymsg, param = ()): + return self.cursor.execute(querymsg, param) + class FrameTest(unittest.TestCase): def setUp(self): @@ -91,29 +95,27 @@ def setUp(self): self.populateDB() def startDB(self): - self.db.query("CREATE TABLE callrank(name varchar(20), min int);") - self.db.query("CREATE TABLE lastranking(lastrank varchar(300), week 'varchar(11)');") - self.db.query("CREATE TABLE ranking (name text, qts int);") + self.db.query_commit("CREATE TABLE callrank(name varchar(20), min int);") + self.db.query_commit("CREATE TABLE lastranking(lastrank varchar(300), week 'varchar(11)');") + self.db.query_commit("CREATE TABLE ranking (name text, qts int);") def populateDB(self): ''' Populating the DB''' - self.db.query("INSERT INTO callrank VALUES('camponez',9);") + self.db.query_commit("INSERT INTO callrank VALUES('camponez',9);") + + self.db.query_commit("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr (22) |2.tuliom (15) |3.cascardo (12) |4.sene (11) |5.camponez (6) - Call: 1.cascardo (245)|2.maurosr (105) |3.camponez (70)', '2012-05-10');") - self.db.query("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr \ - (22) |2.tuliom (18) |3.cascardo (12) |4.sene (11) \ - |5.camponez (6) - Call: 1.cascardo (245)|2.maurosr (105) \ - |3.camponez (70)', '2012-05-10');") + self.db.query_commit("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr (20) |2.tuliom (18) |3.cascardo (10) |4.sene (8) |5.camponez (3) - Call: 1.cascardo (205)|2.maurosr (125) |3.camponez (90)','2012-05-17');") - self.db.query("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr \ - (20) |2.tuliom (18) |3.cascardo (12) |4.sene (12) \ - |5.camponez (6) - Call: 1.cascardo (245)|2.maurosr (105) \ - |3.camponez (70)','2012-05-17');") + self.db.query_commit("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr (19) |2.tuliom (15) |3.cascardo (11) |4.sene (10) |5.camponez (9) - Call: 1.cascardo (105)|2.maurosr (100) |3.camponez (90)','2012-05-27');") - self.db.query("INSERT INTO ranking VALUES('T3',3);") - self.db.query("INSERT INTO ranking VALUES('maurosr',6);") - self.db.query("INSERT INTO ranking VALUES('sene',3);") - self.db.query("INSERT INTO ranking VALUES('camponez',5);") + self.db.query_commit("INSERT INTO lastranking VALUES('Pomodoro: 1.maurosr (19) |2.tuliom (15) |3.cascardo (11) |4.sene (10) |5.camponez (9)','2012-05-27');") + + self.db.query_commit("INSERT INTO ranking VALUES('T3',3);") + self.db.query_commit("INSERT INTO ranking VALUES('maurosr',6);") + self.db.query_commit("INSERT INTO ranking VALUES('sene',3);") + self.db.query_commit("INSERT INTO ranking VALUES('camponez',5);") def tearDown(self): self.db.query("drop table callrank;") @@ -170,7 +172,7 @@ def testAddCall(self): self.bot.createTopicRank()) def testCallMinus(self): - '''Test call minus''' + """Test call minus""" self.bot.pomocallminus('camponez', 4) self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.camponez (5)', @@ -180,6 +182,12 @@ def testCallMinus(self): self.assertEqual('Pomodoro: 1.maurosr (6) |2.camponez (5) |3.T3 (3) |4.sene (3) - Call: 1.maurosr (10) |2.camponez (5)', self.bot.createTopicRank()) + def testChart(self): + """Test Chart""" + + graph = PomoChart(self.db) + + self.assertEqual(graph.generateChart(), '

Pomodoro

chart

chart

PomoCall

chart

chart') def main(): unittest.main() From de0ad3f8187c110028ce47b563e50426ccde05ff Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 29 Oct 2012 16:59:10 -0200 Subject: [PATCH 66/68] Enable Generate chart after saving ranking --- pomobot.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pomobot.py b/pomobot.py index a864544..d56dd93 100644 --- a/pomobot.py +++ b/pomobot.py @@ -327,6 +327,18 @@ def delrank(self, chan): self.bot.say(chan, "Last rank is saved. New rank is clear!") self.bot.topic(chan, " Be the first: !pomo+ ") + self.__generateChart() + + def __generateChart(self): + graph = PomoChart(self.bot) + + f = open('graph.html', 'wb') + + f.write(graph.generateChart()) + + f.close() + + def privmsg(self, sender, headers, message): # Restricted commands From 2e2c5118a236fe43996dc2633c7f4d61da026bd4 Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Mon, 29 Oct 2012 17:19:11 -0200 Subject: [PATCH 67/68] New option on !help --- pomobot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pomobot.py b/pomobot.py index d56dd93..732da91 100644 --- a/pomobot.py +++ b/pomobot.py @@ -338,7 +338,6 @@ def __generateChart(self): f.close() - def privmsg(self, sender, headers, message): # Restricted commands @@ -376,6 +375,7 @@ def privmsg(self, sender, headers, message): self.bot.say(self.chanName, "!pomocall- Decrease minutes in a call - Usage: \"!pomocall- 30 \" (for 30 min)") self.bot.say(self.chanName, "!lastrank Shows a last week pomodoro rank - Usage: \"!lastrank \"") self.bot.say(self.chanName, "!git Shows the link to git repository - Usage: \"!git \"") + self.bot.say(self.chanName, "!chart a link to a chart with a past pomodoros - Usage: \"!chart \"") if message.startswith("!pomocall+ "): self.pomocalladd(sender, message[10:].strip()) @@ -401,6 +401,9 @@ def privmsg(self, sender, headers, message): if message.startswith("!git"): self.bot.say(self.chanName, "git clone http://pokgsa.ibm.com/~edusf/public/git/botpomodoro/") + if message.startswith("!chart"): + self.bot.say(self.chanName, "http://pokgsa.ibm.com/~edusf/public/graph.html") + def actionmsg(self, sender, headers, message): print "An ACTION message was sent by " + sender + " with the headers " + headers + ". It says: \"" + sender + " " + message From 68cf3cba5f67633599c3281b2aaaf7f3bca39dca Mon Sep 17 00:00:00 2001 From: Eduardo Elias Camponez Date: Wed, 31 Oct 2012 12:18:00 -0200 Subject: [PATCH 68/68] Release 1.0 --- __init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/__init__.py b/__init__.py index e69de29..6cd828b 100644 --- a/__init__.py +++ b/__init__.py @@ -0,0 +1 @@ +__version__='1.0'