diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..446bd8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.*.swp +*.pyc +test/pomodoro.db diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6cd828b --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +__version__='1.0' diff --git a/examplebot.py b/examplebot.py deleted file mode 100644 index ff6fd5c..0000000 --- a/examplebot.py +++ /dev/null @@ -1,78 +0,0 @@ -from ircbotframe import ircBot -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) - -def saySuccess(channel, message): - bot.say(channel, message) - -def kickSuccess(nick, channel, reason): - bot.kick(nick, channel, reason) - -def identPass(): - pass - -def identFail(): - pass - -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,)) - elif message.startswith("!quit"): - if sender == owner: - 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: - 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: - 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 - -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__": - 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") - bot.bind("PRIVMSG", privmsg) - bot.bind("ACTION", actionmsg) - bot.bind("376", endMOTD) - bot.connect() - bot.run() - else: - print "Usage: python examplebot.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/ircbotframe.py b/ircbotframe.py index f374c02..162c8da 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,8 +75,8 @@ def getLine(self): return str(line) class ircBot: - def __init__(self, network, port, name, description): - 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 @@ -83,11 +85,13 @@ def __init__(self, network, port, name, description): self.identifyLock = False self.serverName = "" self.binds = [] - self.debug = True + self.debug = debug + self.__db = db + # 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: @@ -124,11 +128,13 @@ 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]) - #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: @@ -145,7 +151,13 @@ 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() + # PUBLIC FUNCTIONS + def ban(self, banMask, channel, reason): self.__debugPrint("Banning " + banMask + "...") self.outBuf.sendBuffered("MODE +b " + channel + " " + banMask) @@ -155,6 +167,10 @@ 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() + self.cursor.close() + def connect(self): self.__debugPrint("Connecting...") self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -181,6 +197,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 reconnect(self): self.disconnect("Reconnecting") self.__debugPrint("Pausing before reconnecting...") @@ -203,11 +224,14 @@ 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): 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) diff --git a/pomobot.py b/pomobot.py new file mode 100644 index 0000000..732da91 --- /dev/null +++ b/pomobot.py @@ -0,0 +1,426 @@ +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 + +class Pomobot(): + + def __init__(self, network, chanName, owner, botnick): + self.network = network + 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) + 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") + + def quitSuccess(self, quitMessage): + self.bot.disconnect(quitMessage) + self.bot.stop() + + def joinSuccess(self, channel): + self.bot.join(channel) + + def saySuccess(self, channel, message): + self.bot.say(channel, message) + + def kickSuccess(self, nick, channel, reason): + self.bot.kick(nick, channel, reason) + + def identPass(self): + pass + + def identFail(self): + pass + + def updateRankTopic(self): + # rank for topic + topic = self.createTopicRank(6) + self.bot.topic(self.chanName, topic) + + 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', ()) + + 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 + + topic = topic[:-2] + " - Call: " + + 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 + + return topic[:-2] + + def lastRank(self, sender, chan, limit = 1): + sql = 'select week, lastrank from lastranking ' + + sql = sql + ' order by week desc ' + + if limit: + sql = sql + ' limit ' + str(limit) + + + r = self.bot.query(sql, ()) + + for i in r: + lastrank = i[1] + + self.bot.say(sender, "Last week's rank is: " + lastrank) + + def __isOnDbpomo(self, sender): + r = self.bot.query("select count(name) from ranking where name =?", (sender,)) + + for i in r: + count = i[0] + + return count == 1 + + def __isOnDbcall(self, sender): + r = self.bot.query("select count(name) from callrank where name =?", (sender,)) + + for i in r: + count = i[0] + + 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)) + + self.bot.commit() + + self.updateRankTopic() + + def pomominus(self, sender): + + # 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() + + self.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 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 + 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:") + 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 \"") + 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()) + + 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 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 + + def endMOTD(self, sender, headers, message): + self.bot.join(self.chanName) + self.updateRankTopic() + +# Main program begins here +if __name__ == "__main__": + if len(sys.argv) == 5: + owner = sys.argv[2] + chanName = "#" + sys.argv[4] + network = sys.argv[1] + botnick = sys.argv[3] + bot = Pomobot(network, chanName, owner, botnick) + bot.bot.connect() + bot.bot.run() + else: + print "Usage: python examplebot.py " + diff --git a/tags b/tags new file mode 100644 index 0000000..2ebae1d --- /dev/null +++ b/tags @@ -0,0 +1,107 @@ +!_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.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, 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: +__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 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 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 pomobot.py /^ def joinSuccess(self, channel):$/;" m class:Pomobot +kick ircbotframe.py /^ def kick(self, nick, channel, reason):$/;" m class:ircBot +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 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 diff --git a/test/test_bot.py b/test/test_bot.py new file mode 100644 index 0000000..fce0bd4 --- /dev/null +++ b/test/test_bot.py @@ -0,0 +1,196 @@ +import unittest +import sys + +import threading +import sqlite3 as sql + + +sys.path.insert(0, '../') + +from ircbotframe import ircBot +from pomobot import Pomobot +from pomobot import PomoChart + +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_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): + 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', "Pomobot2") + self.db = DB('pomodoro.db') + self.bot.bot.outBuf = fake_ircOutputBuffer() + self.startDB() + self.populateDB() + + def startDB(self): + 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_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_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_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_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;") + 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()) + + 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""" + + 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()) + + def testChart(self): + """Test Chart""" + + graph = PomoChart(self.db) + + self.assertEqual(graph.generateChart(), '

Pomodoro

chart

chart

PomoCall

chart

chart') + +def main(): + unittest.main() + +if __name__ == '__main__': + main()