1 """
2 pygooglechart - A complete Python wrapper for the Google Chart API
3
4 http://pygooglechart.slowchop.com/
5
6 Copyright 2007-2008 Gerald Kaszuba
7
8 This program is free software: you can redistribute it and/or modify
9 it under the terms of the GNU General Public License as published by
10 the Free Software Foundation, either version 3 of the License, or
11 (at your option) any later version.
12
13 This program is distributed in the hope that it will be useful,
14 but WITHOUT ANY WARRANTY; without even the implied warranty of
15 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 GNU General Public License for more details.
17
18 You should have received a copy of the GNU General Public License
19 along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21 """
22
23 import os
24 import urllib
25 import urllib2
26 import math
27 import random
28 import re
29 import warnings
30 import copy
31
32
33
34
35 __version__ = '0.2.1'
36 __author__ = 'Gerald Kaszuba'
37
38 reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
45
48 """Helper function to reset all warnings. Used by the unit tests."""
49 globals()['__warningregistry__'] = None
50
58
62
66
70
74
75
76 -class BadContentTypeException(PyGoogleChartException):
78
82
86
87
88
89
90
91
92 -class Data(object):
93
98
99 @classmethod
101 lower, upper = range
102 assert(upper > lower)
103 scaled = (value - lower) * (float(cls.max_value) / (upper - lower))
104 return scaled
105
106 @classmethod
109
110 @classmethod
113
114 @classmethod
120
121 @staticmethod
123 if clipped != scaled:
124 warnings.warn('One or more of of your data points has been '
125 'clipped because it is out of range.')
126
129
130 max_value = 61
131 enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
132
134 encoded_data = []
135 for data in self.data:
136 sub_data = []
137 for value in data:
138 if value is None:
139 sub_data.append('_')
140 elif value >= 0 and value <= self.max_value:
141 sub_data.append(SimpleData.enc_map[value])
142 else:
143 raise DataOutOfRangeException('cannot encode value: %d'
144 % value)
145 encoded_data.append(''.join(sub_data))
146 return 'chd=s:' + ','.join(encoded_data)
147
148
149 -class TextData(Data):
150
151 max_value = 100
152
153 - def __repr__(self):
154 encoded_data = []
155 for data in self.data:
156 sub_data = []
157 for value in data:
158 if value is None:
159 sub_data.append(-1)
160 elif value >= 0 and value <= self.max_value:
161 sub_data.append("%.1f" % float(value))
162 else:
163 raise DataOutOfRangeException()
164 encoded_data.append(','.join(sub_data))
165 return 'chd=t:' + '|'.join(encoded_data)
166
167 @classmethod
168 - def scale_value(cls, value, range):
169
170
171 scaled = cls.float_scale_value(value, range)
172 clipped = cls.clip_value(scaled)
173 Data.check_clip(scaled, clipped)
174 return clipped
175
178
179 max_value = 4095
180 enc_map = \
181 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
182
184 encoded_data = []
185 enc_size = len(ExtendedData.enc_map)
186 for data in self.data:
187 sub_data = []
188 for value in data:
189 if value is None:
190 sub_data.append('__')
191 elif value >= 0 and value <= self.max_value:
192 first, second = divmod(int(value), enc_size)
193 sub_data.append('%s%s' % (
194 ExtendedData.enc_map[first],
195 ExtendedData.enc_map[second]))
196 else:
197 raise DataOutOfRangeException( \
198 'Item #%i "%s" is out of range' % (data.index(value), \
199 value))
200 encoded_data.append(''.join(sub_data))
201 return 'chd=e:' + ','.join(encoded_data)
202
203
204
205
206
207
208 -class Axis(object):
209
210 BOTTOM = 'x'
211 TOP = 't'
212 LEFT = 'y'
213 RIGHT = 'r'
214 TYPES = (BOTTOM, TOP, LEFT, RIGHT)
215
216 - def __init__(self, axis_index, axis_type, **kw):
217 assert(axis_type in Axis.TYPES)
218 self.has_style = False
219 self.axis_index = axis_index
220 self.axis_type = axis_type
221 self.positions = None
222
224 self.axis_index = axis_index
225
227 self.positions = positions
228
229 - def set_style(self, colour, font_size=None, alignment=None):
230 _check_colour(colour)
231 self.colour = colour
232 self.font_size = font_size
233 self.alignment = alignment
234 self.has_style = True
235
237 bits = []
238 bits.append(str(self.axis_index))
239 bits.append(self.colour)
240 if self.font_size is not None:
241 bits.append(str(self.font_size))
242 if self.alignment is not None:
243 bits.append(str(self.alignment))
244 return ','.join(bits)
245
247 bits = []
248 bits.append(str(self.axis_index))
249 bits += [str(a) for a in self.positions]
250 return ','.join(bits)
251
254
255 - def __init__(self, axis_index, axis_type, values, **kwargs):
258
260 return '%i:|%s' % (self.axis_index, '|'.join(self.values))
261
264
265 - def __init__(self, axis_index, axis_type, low, high, **kwargs):
266 Axis.__init__(self, axis_index, axis_type, **kwargs)
267 self.low = low
268 self.high = high
269
271 return '%i,%s,%s' % (self.axis_index, self.low, self.high)
272
273
274
275
276
277 -class Chart(object):
278 """Abstract class for all chart types.
279
280 width are height specify the dimensions of the image. title sets the title
281 of the chart. legend requires a list that corresponds to datasets.
282 """
283
284 BASE_URL = 'http://chart.apis.google.com/chart?'
285 BACKGROUND = 'bg'
286 CHART = 'c'
287 ALPHA = 'a'
288 VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
289 SOLID = 's'
290 LINEAR_GRADIENT = 'lg'
291 LINEAR_STRIPES = 'ls'
292
293 - def __init__(self, width, height, title=None, legend=None, colours=None,
294 auto_scale=True, x_range=None, y_range=None,
295 colours_within_series=None):
296 if type(self) == Chart:
297 raise AbstractClassException('This is an abstract class')
298 assert(isinstance(width, int))
299 assert(isinstance(height, int))
300 self.width = width
301 self.height = height
302 self.data = []
303 self.set_title(title)
304 self.set_legend(legend)
305 self.set_legend_position(None)
306 self.set_colours(colours)
307 self.set_colours_within_series(colours_within_series)
308
309
310 self.auto_scale = auto_scale
311 self.x_range = x_range
312 self.y_range = y_range
313 self.scaled_data_class = None
314 self.scaled_x_range = None
315 self.scaled_y_range = None
316
317 self.fill_types = {
318 Chart.BACKGROUND: None,
319 Chart.CHART: None,
320 Chart.ALPHA: None,
321 }
322 self.fill_area = {
323 Chart.BACKGROUND: None,
324 Chart.CHART: None,
325 Chart.ALPHA: None,
326 }
327 self.axis = []
328 self.markers = []
329 self.line_styles = {}
330 self.grid = None
331
332
333
334
335 - def get_url(self, data_class=None):
336 url_bits = self.get_url_bits(data_class=data_class)
337 return self.BASE_URL + '&'.join(url_bits)
338
340 url_bits = []
341
342 url_bits.append(self.type_to_url())
343 url_bits.append('chs=%ix%i' % (self.width, self.height))
344 url_bits.append(self.data_to_url(data_class=data_class))
345
346 if self.title:
347 url_bits.append('chtt=%s' % self.title)
348 if self.legend:
349 url_bits.append('chdl=%s' % '|'.join(self.legend))
350 if self.legend_position:
351 url_bits.append('chdlp=%s' % (self.legend_position))
352 if self.colours:
353 url_bits.append('chco=%s' % ','.join(self.colours))
354 if self.colours_within_series:
355 url_bits.append('chco=%s' % '|'.join(self.colours_within_series))
356 ret = self.fill_to_url()
357 if ret:
358 url_bits.append(ret)
359 ret = self.axis_to_url()
360 if ret:
361 url_bits.append(ret)
362 if self.markers:
363 url_bits.append(self.markers_to_url())
364 if self.line_styles:
365 style = []
366 for index in xrange(max(self.line_styles) + 1):
367 if index in self.line_styles:
368 values = self.line_styles[index]
369 else:
370 values = ('1', )
371 style.append(','.join(values))
372 url_bits.append('chls=%s' % '|'.join(style))
373 if self.grid:
374 url_bits.append('chg=%s' % self.grid)
375 return url_bits
376
377
378
379
381 opener = urllib2.urlopen(self.get_url())
382
383 if opener.headers['content-type'] != 'image/png':
384 raise BadContentTypeException('Server responded with a ' \
385 'content-type of %s' % opener.headers['content-type'])
386
387 open(file_name, 'wb').write(opener.read())
388
389
390
391
393 if title:
394 self.title = urllib.quote(title)
395 else:
396 self.title = None
397
399 """legend needs to be a list, tuple or None"""
400 assert(isinstance(legend, list) or isinstance(legend, tuple) or
401 legend is None)
402 if legend:
403 self.legend = [urllib.quote(a) for a in legend]
404 else:
405 self.legend = None
406
408 if legend_position:
409 self.legend_position = urllib.quote(legend_position)
410 else:
411 self.legend_position = None
412
413
414
415
417
418 assert(isinstance(colours, list) or isinstance(colours, tuple) or
419 colours is None)
420
421 if colours:
422 for col in colours:
423 _check_colour(col)
424 self.colours = colours
425
427
428 assert(isinstance(colours, list) or isinstance(colours, tuple) or
429 colours is None)
430
431 if colours:
432 for col in colours:
433 _check_colour(col)
434 self.colours_within_series = colours
435
436
437
438
444
446 assert(isinstance(args, list) or isinstance(args, tuple))
447 assert(angle >= 0 and angle <= 90)
448 assert(len(args) % 2 == 0)
449 args = list(args)
450 for a in xrange(len(args) / 2):
451 col = args[a * 2]
452 offset = args[a * 2 + 1]
453 _check_colour(col)
454 assert(offset >= 0 and offset <= 1)
455 args[a * 2 + 1] = str(args[a * 2 + 1])
456 return args
457
463
469
471 areas = []
472 for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
473 if self.fill_types[area]:
474 areas.append('%s,%s,%s' % (area, self.fill_types[area], \
475 self.fill_area[area]))
476 if areas:
477 return 'chf=' + '|'.join(areas)
478
479
480
481
483 """Determines the appropriate data encoding type to give satisfactory
484 resolution (http://code.google.com/apis/chart/#chart_data).
485 """
486 assert(isinstance(data, list) or isinstance(data, tuple))
487 if not isinstance(self, (LineChart, BarChart, ScatterChart)):
488
489
490
491 return SimpleData
492 elif self.height < 100:
493
494
495
496
497 return SimpleData
498 else:
499 return ExtendedData
500
502 return [r for r in data if r is not None]
503
505 """Return a 2-tuple giving the minimum and maximum x-axis
506 data range.
507 """
508 try:
509 lower = min([min(self._filter_none(s))
510 for type, s in self.annotated_data()
511 if type == 'x'])
512 upper = max([max(self._filter_none(s))
513 for type, s in self.annotated_data()
514 if type == 'x'])
515 return (lower, upper)
516 except ValueError:
517 return None
518
520 """Return a 2-tuple giving the minimum and maximum y-axis
521 data range.
522 """
523 try:
524 lower = min([min(self._filter_none(s))
525 for type, s in self.annotated_data()
526 if type == 'y'])
527 upper = max([max(self._filter_none(s)) + 1
528 for type, s in self.annotated_data()
529 if type == 'y'])
530 return (lower, upper)
531 except ValueError:
532 return None
533
534 - def scaled_data(self, data_class, x_range=None, y_range=None):
535 """Scale `self.data` as appropriate for the given data encoding
536 (data_class) and return it.
537
538 An optional `y_range` -- a 2-tuple (lower, upper) -- can be
539 given to specify the y-axis bounds. If not given, the range is
540 inferred from the data: (0, <max-value>) presuming no negative
541 values, or (<min-value>, <max-value>) if there are negative
542 values. `self.scaled_y_range` is set to the actual lower and
543 upper scaling range.
544
545 Ditto for `x_range`. Note that some chart types don't have x-axis
546 data.
547 """
548 self.scaled_data_class = data_class
549
550
551 if x_range is None:
552 x_range = self.data_x_range()
553 if x_range and x_range[0] > 0:
554 x_range = (x_range[0], x_range[1])
555 self.scaled_x_range = x_range
556
557
558 if y_range is None:
559 y_range = self.data_y_range()
560 if y_range and y_range[0] > 0:
561 y_range = (y_range[0], y_range[1])
562 self.scaled_y_range = y_range
563
564 scaled_data = []
565 for type, dataset in self.annotated_data():
566 if type == 'x':
567 scale_range = x_range
568 elif type == 'y':
569 scale_range = y_range
570 elif type == 'marker-size':
571 scale_range = (0, max(dataset))
572 scaled_dataset = []
573 for v in dataset:
574 if v is None:
575 scaled_dataset.append(None)
576 else:
577 scaled_dataset.append(
578 data_class.scale_value(v, scale_range))
579 scaled_data.append(scaled_dataset)
580 return scaled_data
581
583 self.data.append(data)
584 return len(self.data) - 1
585
587 if not data_class:
588 data_class = self.data_class_detection(self.data)
589 if not issubclass(data_class, Data):
590 raise UnknownDataTypeException()
591 if self.auto_scale:
592 data = self.scaled_data(data_class, self.x_range, self.y_range)
593 else:
594 data = self.data
595 return repr(data_class(data))
596
598 for dataset in self.data:
599 yield ('x', dataset)
600
601
602
603
605 assert(axis_type in Axis.TYPES)
606 values = [urllib.quote(str(a)) for a in values]
607 axis_index = len(self.axis)
608 axis = LabelAxis(axis_index, axis_type, values)
609 self.axis.append(axis)
610 return axis_index
611
613 assert(axis_type in Axis.TYPES)
614 axis_index = len(self.axis)
615 axis = RangeAxis(axis_index, axis_type, low, high)
616 self.axis.append(axis)
617 return axis_index
618
625
626 - def set_axis_style(self, axis_index, colour, font_size=None, \
627 alignment=None):
628 try:
629 self.axis[axis_index].set_style(colour, font_size, alignment)
630 except IndexError:
631 raise InvalidParametersException('Axis index %i has not been ' \
632 'created' % axis)
633
635 available_axis = []
636 label_axis = []
637 range_axis = []
638 positions = []
639 styles = []
640 index = -1
641 for axis in self.axis:
642 available_axis.append(axis.axis_type)
643 if isinstance(axis, RangeAxis):
644 range_axis.append(repr(axis))
645 if isinstance(axis, LabelAxis):
646 label_axis.append(repr(axis))
647 if axis.positions:
648 positions.append(axis.positions_to_url())
649 if axis.has_style:
650 styles.append(axis.style_to_url())
651 if not available_axis:
652 return
653 url_bits = []
654 url_bits.append('chxt=%s' % ','.join(available_axis))
655 if label_axis:
656 url_bits.append('chxl=%s' % '|'.join(label_axis))
657 if range_axis:
658 url_bits.append('chxr=%s' % '|'.join(range_axis))
659 if positions:
660 url_bits.append('chxp=%s' % '|'.join(positions))
661 if styles:
662 url_bits.append('chxs=%s' % '|'.join(styles))
663 return '&'.join(url_bits)
664
665
666
667
669 return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
670
671 - def add_marker(self, index, point, marker_type, colour, size, priority=0):
672 self.markers.append((marker_type, colour, str(index), str(point), \
673 str(size), str(priority)))
674
677
679 self.markers.append(('D', colour, str(data_set), '0', str(size), str(priority)))
680
681 - def add_marker_text(self, string, colour, data_set, data_point, size, priority=0):
682 self.markers.append((str(string), colour, str(data_set), str(data_point), str(size), str(priority)))
683
686
688 self.markers.append(('b', colour, str(index_start), str(index_end), \
689 '1'))
690
692 self.markers.append(('B', colour, '1', '1', '1'))
693
694
695
696
697 - def set_line_style(self, index, thickness=1, line_segment=None, \
698 blank_segment=None):
699 value = []
700 value.append(str(thickness))
701 if line_segment:
702 value.append(str(line_segment))
703 value.append(str(blank_segment))
704 self.line_styles[index] = value
705
706
707
708
709 - def set_grid(self, x_step, y_step, line_segment=1, \
710 blank_segment=0):
711 self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
712 blank_segment)
713
716
719
721 yield ('x', self.data[0])
722 yield ('y', self.data[1])
723 if len(self.data) > 2:
724
725
726 yield ('marker-size', self.data[2])
727
735
738
741
743
744 for dataset in self.data:
745 yield ('y', dataset)
746
752
755
758
760
761 for i, dataset in enumerate(self.data):
762 if i % 2 == 0:
763 yield ('x', dataset)
764 else:
765 yield ('y', dataset)
766
769
776
778 self.bar_width = bar_width
779
781 self.zero_lines[index] = zero_line
782
784 url_bits = Chart.get_url_bits(self, data_class=data_class)
785 if not skip_chbh and self.bar_width is not None:
786 url_bits.append('chbh=%i' % self.bar_width)
787 zero_line = []
788 if self.zero_lines:
789 for index in xrange(max(self.zero_lines) + 1):
790 if index in self.zero_lines:
791 zero_line.append(str(self.zero_lines[index]))
792 else:
793 zero_line.append('0')
794 url_bits.append('chp=%s' % ','.join(zero_line))
795 return url_bits
796
802
805
808
810 for dataset in self.data:
811 yield ('y', dataset)
812
815
822
824 """Set spacing between bars in a group."""
825 self.bar_spacing = spacing
826
828 """Set spacing between groups of bars."""
829 self.group_spacing = spacing
830
832
833
834 url_bits = BarChart.get_url_bits(self, data_class=data_class,
835 skip_chbh=True)
836 if self.group_spacing is not None:
837 if self.bar_spacing is None:
838 raise InvalidParametersException('Bar spacing is required ' \
839 'to be set when setting group spacing')
840 if self.bar_width is None:
841 raise InvalidParametersException('Bar width is required to ' \
842 'be set when setting bar spacing')
843 url_bits.append('chbh=%i,%i,%i'
844 % (self.bar_width, self.bar_spacing, self.group_spacing))
845 elif self.bar_spacing is not None:
846 if self.bar_width is None:
847 raise InvalidParametersException('Bar width is required to ' \
848 'be set when setting bar spacing')
849 url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
850 elif self.bar_width:
851 url_bits.append('chbh=%i' % self.bar_width)
852 return url_bits
853
859
862
865
867 for dataset in self.data:
868 yield ('y', dataset)
869
872
874 if type(self) == PieChart:
875 raise AbstractClassException('This is an abstract class')
876 Chart.__init__(self, *args, **kwargs)
877 self.pie_labels = []
878 if self.y_range:
879 warnings.warn('y_range is not used with %s.' % \
880 (self.__class__.__name__))
881
883 self.pie_labels = [urllib.quote(a) for a in labels]
884
886 url_bits = Chart.get_url_bits(self, data_class=data_class)
887 if self.pie_labels:
888 url_bits.append('chl=%s' % '|'.join(self.pie_labels))
889 return url_bits
890
892
893
894 for dataset in self.data:
895 yield ('x', dataset)
896
897 - def scaled_data(self, data_class, x_range=None, y_range=None):
898 if not x_range:
899 x_range = [0, sum(self.data[0])]
900 return Chart.scaled_data(self, data_class, x_range, self.y_range)
901
907
913
916
919
921 for dataset in self.data:
922 yield ('y', dataset)
923
929
935
938
940 Chart.__init__(self, *args, **kwargs)
941 self.geo_area = 'world'
942 self.codes = []
943
946
949
951 url_bits = Chart.get_url_bits(self, data_class=data_class)
952 url_bits.append('chtm=%s' % self.geo_area)
953 if self.codes:
954 url_bits.append('chld=%s' % ''.join(self.codes))
955 return url_bits
956
959 """Inheriting from PieChart because of similar labeling"""
960
962 PieChart.__init__(self, *args, **kwargs)
963 if self.auto_scale and not self.x_range:
964 warnings.warn('Please specify an x_range with GoogleOMeterChart, '
965 'otherwise one arrow will always be at the max.')
966
969
972
974 Chart.__init__(self, *args, **kwargs)
975 self.encoding = None
976 self.ec_level = None
977 self.margin = None
978
981
983 if not self.data:
984 raise NoDataGivenException()
985 return 'chl=%s' % urllib.quote(self.data[0])
986
988 url_bits = Chart.get_url_bits(self, data_class=data_class)
989 if self.encoding:
990 url_bits.append('choe=%s' % self.encoding)
991 if self.ec_level:
992 url_bits.append('chld=%s|%s' % (self.ec_level, self.margin))
993 return url_bits
994
996 self.encoding = encoding
997
998 - def set_ec(self, level, margin):
999 self.ec_level = level
1000 self.margin = margin
1001
1004
1006 self.grammar = None
1007 self.chart = None
1008
1009 - def parse(self, grammar):
1010 self.grammar = grammar
1011 self.chart = self.create_chart_instance()
1012
1013 for attr in self.grammar:
1014 if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
1015 continue
1016 attr_func = 'parse_' + attr
1017 if not hasattr(self, attr_func):
1018 warnings.warn('No parser for grammar attribute "%s"' % (attr))
1019 continue
1020 getattr(self, attr_func)(grammar[attr])
1021
1022 return self.chart
1023
1025 self.chart.data = data
1026
1027 @staticmethod
1029 possible_charts = []
1030 for cls_name in globals().keys():
1031 if not cls_name.endswith('Chart'):
1032 continue
1033 cls = globals()[cls_name]
1034
1035 try:
1036 a = cls(1, 1, auto_scale=False)
1037 del a
1038 except AbstractClassException:
1039 continue
1040
1041 possible_charts.append(cls_name[:-5])
1042 return possible_charts
1043
1045 if not grammar:
1046 grammar = self.grammar
1047 assert(isinstance(grammar, dict))
1048 assert('w' in grammar)
1049 assert('h' in grammar)
1050 assert('type' in grammar)
1051 chart_type = grammar['type']
1052 w = grammar['w']
1053 h = grammar['h']
1054 auto_scale = grammar.get('auto_scale', None)
1055 x_range = grammar.get('x_range', None)
1056 y_range = grammar.get('y_range', None)
1057 types = ChartGrammar.get_possible_chart_types()
1058 if chart_type not in types:
1059 raise UnknownChartType('%s is an unknown chart type. Possible '
1060 'chart types are %s' % (chart_type, ','.join(types)))
1061 return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
1062 x_range=x_range, y_range=y_range)
1063
1066