/+junk/pygooglechart-py3k

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/%2Bjunk/pygooglechart-py3k

« back to all changes in this revision

Viewing changes to pygooglechart.py

  • Committer: gak
  • Date: 2009-03-15 07:41:13 UTC
  • Revision ID: git-v1:76610bda260bfbf489d5e7efddca4313cc1bafd4
 - Fixed line endings to UNIX
 - Fixed Hashbangs
 - Added GPL headers

Show diffs side-by-side

added added

removed removed

Lines of Context:
19
19
along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
20
 
21
21
"""
 
22
from __future__ import division
22
23
 
23
24
import os
24
25
import urllib
26
27
import math
27
28
import random
28
29
import re
 
30
import warnings
 
31
import copy
29
32
 
30
33
# Helper variables and functions
31
34
# -----------------------------------------------------------------------------
32
35
 
33
 
__version__ = '0.2.1'
 
36
__version__ = '0.2.2'
34
37
__author__ = 'Gerald Kaszuba'
35
38
 
36
39
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
41
44
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
42
45
            colour)
43
46
 
 
47
 
 
48
def _reset_warnings():
 
49
    """Helper function to reset all warnings. Used by the unit tests."""
 
50
    globals()['__warningregistry__'] = None
 
51
 
 
52
 
44
53
# Exception Classes
45
54
# -----------------------------------------------------------------------------
46
55
 
92
101
    def float_scale_value(cls, value, range):
93
102
        lower, upper = range
94
103
        assert(upper > lower)
95
 
        max_value = cls.max_value()
96
 
        scaled = (value-lower) * (float(max_value) / (upper - lower))
 
104
        scaled = (value - lower) * (cls.max_value / (upper - lower))
97
105
        return scaled
98
106
 
99
107
    @classmethod
100
108
    def clip_value(cls, value):
101
 
        return max(0, min(value, cls.max_value()))
 
109
        return max(0, min(value, cls.max_value))
102
110
 
103
111
    @classmethod
104
112
    def int_scale_value(cls, value, range):
108
116
    def scale_value(cls, value, range):
109
117
        scaled = cls.int_scale_value(value, range)
110
118
        clipped = cls.clip_value(scaled)
 
119
        Data.check_clip(scaled, clipped)
111
120
        return clipped
112
121
 
 
122
    @staticmethod
 
123
    def check_clip(scaled, clipped):
 
124
        if clipped != scaled:
 
125
            warnings.warn('One or more of of your data points has been '
 
126
                'clipped because it is out of range.')
 
127
 
113
128
 
114
129
class SimpleData(Data):
115
130
 
 
131
    max_value = 61
116
132
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
117
133
 
118
134
    def __repr__(self):
119
 
        max_value = self.max_value()
120
135
        encoded_data = []
121
136
        for data in self.data:
122
137
            sub_data = []
123
138
            for value in data:
124
139
                if value is None:
125
140
                    sub_data.append('_')
126
 
                elif value >= 0 and value <= max_value:
 
141
                elif value >= 0 and value <= self.max_value:
127
142
                    sub_data.append(SimpleData.enc_map[value])
128
143
                else:
129
144
                    raise DataOutOfRangeException('cannot encode value: %d'
131
146
            encoded_data.append(''.join(sub_data))
132
147
        return 'chd=s:' + ','.join(encoded_data)
133
148
 
134
 
    @staticmethod
135
 
    def max_value():
136
 
        return 61
137
 
 
138
149
 
139
150
class TextData(Data):
140
151
 
 
152
    max_value = 100
 
153
 
141
154
    def __repr__(self):
142
 
        max_value = self.max_value()
143
155
        encoded_data = []
144
156
        for data in self.data:
145
157
            sub_data = []
146
158
            for value in data:
147
159
                if value is None:
148
160
                    sub_data.append(-1)
149
 
                elif value >= 0 and value <= max_value:
 
161
                elif value >= 0 and value <= self.max_value:
150
162
                    sub_data.append("%.1f" % float(value))
151
163
                else:
152
164
                    raise DataOutOfRangeException()
153
165
            encoded_data.append(','.join(sub_data))
154
 
        return 'chd=t:' + '|'.join(encoded_data)
155
 
 
156
 
    @staticmethod
157
 
    def max_value():
158
 
        return 100
 
166
        return 'chd=t:' + '%7c'.join(encoded_data)
159
167
 
160
168
    @classmethod
161
169
    def scale_value(cls, value, range):
163
171
        # map index
164
172
        scaled = cls.float_scale_value(value, range)
165
173
        clipped = cls.clip_value(scaled)
 
174
        Data.check_clip(scaled, clipped)
166
175
        return clipped
167
176
 
168
177
 
169
178
class ExtendedData(Data):
170
179
 
 
180
    max_value = 4095
171
181
    enc_map = \
172
182
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
173
183
 
174
184
    def __repr__(self):
175
 
        max_value = self.max_value()
176
185
        encoded_data = []
177
186
        enc_size = len(ExtendedData.enc_map)
178
187
        for data in self.data:
180
189
            for value in data:
181
190
                if value is None:
182
191
                    sub_data.append('__')
183
 
                elif value >= 0 and value <= max_value:
 
192
                elif value >= 0 and value <= self.max_value:
184
193
                    first, second = divmod(int(value), enc_size)
185
194
                    sub_data.append('%s%s' % (
186
195
                        ExtendedData.enc_map[first],
192
201
            encoded_data.append(''.join(sub_data))
193
202
        return 'chd=e:' + ','.join(encoded_data)
194
203
 
195
 
    @staticmethod
196
 
    def max_value():
197
 
        return 4095
198
 
 
199
204
 
200
205
# Axis Classes
201
206
# -----------------------------------------------------------------------------
253
258
        self.values = [str(a) for a in values]
254
259
 
255
260
    def __repr__(self):
256
 
        return '%i:|%s' % (self.axis_index, '|'.join(self.values))
 
261
        return '%i:%%7c%s' % (self.axis_index, '%7c'.join(self.values))
257
262
 
258
263
 
259
264
class RangeAxis(Axis):
287
292
    LINEAR_STRIPES = 'ls'
288
293
 
289
294
    def __init__(self, width, height, title=None, legend=None, colours=None,
290
 
                 auto_scale=True, x_range=None, y_range=None):
 
295
            auto_scale=True, x_range=None, y_range=None,
 
296
            colours_within_series=None):
291
297
        if type(self) == Chart:
292
298
            raise AbstractClassException('This is an abstract class')
293
299
        assert(isinstance(width, int))
296
302
        self.height = height
297
303
        self.data = []
298
304
        self.set_title(title)
 
305
        self.set_title_style(None, None)
299
306
        self.set_legend(legend)
 
307
        self.set_legend_position(None)
300
308
        self.set_colours(colours)
 
309
        self.set_colours_within_series(colours_within_series)
301
310
 
302
311
        # Data for scaling.
303
 
        self.auto_scale = auto_scale    # Whether to automatically scale data
304
 
        self.x_range = x_range          # (min, max) x-axis range for scaling
305
 
        self.y_range = y_range          # (min, max) y-axis range for scaling
 
312
        self.auto_scale = auto_scale  # Whether to automatically scale data
 
313
        self.x_range = x_range  # (min, max) x-axis range for scaling
 
314
        self.y_range = y_range  # (min, max) y-axis range for scaling
306
315
        self.scaled_data_class = None
307
316
        self.scaled_x_range = None
308
317
        self.scaled_y_range = None
338
347
        # optional arguments
339
348
        if self.title:
340
349
            url_bits.append('chtt=%s' % self.title)
 
350
        if self.title_colour and self.title_font_size:
 
351
            url_bits.append('chts=%s,%s' % (self.title_colour, \
 
352
                self.title_font_size))
341
353
        if self.legend:
342
 
            url_bits.append('chdl=%s' % '|'.join(self.legend))
 
354
            url_bits.append('chdl=%s' % '%7c'.join(self.legend))
 
355
        if self.legend_position:
 
356
            url_bits.append('chdlp=%s' % (self.legend_position))
343
357
        if self.colours:
344
 
            url_bits.append('chco=%s' % ','.join(self.colours))
 
358
            url_bits.append('chco=%s' % ','.join(self.colours))            
 
359
        if self.colours_within_series:
 
360
            url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series))
345
361
        ret = self.fill_to_url()
346
362
        if ret:
347
363
            url_bits.append(ret)
348
364
        ret = self.axis_to_url()
349
365
        if ret:
350
 
            url_bits.append(ret)
 
366
            url_bits.append(ret)                    
351
367
        if self.markers:
352
 
            url_bits.append(self.markers_to_url())
 
368
            url_bits.append(self.markers_to_url())        
353
369
        if self.line_styles:
354
370
            style = []
355
371
            for index in xrange(max(self.line_styles) + 1):
358
374
                else:
359
375
                    values = ('1', )
360
376
                style.append(','.join(values))
361
 
            url_bits.append('chls=%s' % '|'.join(style))
 
377
            url_bits.append('chls=%s' % '%7c'.join(style))
362
378
        if self.grid:
363
379
            url_bits.append('chg=%s' % self.grid)
364
380
        return url_bits
373
389
            raise BadContentTypeException('Server responded with a ' \
374
390
                'content-type of %s' % opener.headers['content-type'])
375
391
 
376
 
        open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
 
392
        open(file_name, 'wb').write(opener.read())
377
393
 
378
394
    # Simple settings
379
395
    # -------------------------------------------------------------------------
384
400
        else:
385
401
            self.title = None
386
402
 
 
403
    def set_title_style(self, colour, font_size):
 
404
        if not colour is None:
 
405
            _check_colour(colour)
 
406
        self.title_colour = colour
 
407
        self.title_font_size = font_size
 
408
 
387
409
    def set_legend(self, legend):
388
410
        """legend needs to be a list, tuple or None"""
389
411
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
393
415
        else:
394
416
            self.legend = None
395
417
 
 
418
    def set_legend_position(self, legend_position):
 
419
        if legend_position:
 
420
            self.legend_position = urllib.quote(legend_position)
 
421
        else:    
 
422
            self.legend_position = None
 
423
 
396
424
    # Chart colours
397
425
    # -------------------------------------------------------------------------
398
426
 
406
434
                _check_colour(col)
407
435
        self.colours = colours
408
436
 
 
437
    def set_colours_within_series(self, colours):
 
438
        # colours needs to be a list, tuple or None
 
439
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
440
            colours is None)
 
441
        # make sure the colours are in the right format
 
442
        if colours:
 
443
            for col in colours:
 
444
                _check_colour(col)
 
445
        self.colours_within_series = colours        
 
446
 
409
447
    # Background/Chart colours
410
448
    # -------------------------------------------------------------------------
411
449
 
420
458
        assert(angle >= 0 and angle <= 90)
421
459
        assert(len(args) % 2 == 0)
422
460
        args = list(args)  # args is probably a tuple and we need to mutate
423
 
        for a in xrange(len(args) / 2):
 
461
        for a in xrange(int(len(args) / 2)):
424
462
            col = args[a * 2]
425
463
            offset = args[a * 2 + 1]
426
464
            _check_colour(col)
447
485
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
448
486
                    self.fill_area[area]))
449
487
        if areas:
450
 
            return 'chf=' + '|'.join(areas)
 
488
            return 'chf=' + '%7c'.join(areas)
451
489
 
452
490
    # Data
453
491
    # -------------------------------------------------------------------------
471
509
        else:
472
510
            return ExtendedData
473
511
 
 
512
    def _filter_none(self, data):
 
513
        return [r for r in data if r is not None]
 
514
 
474
515
    def data_x_range(self):
475
516
        """Return a 2-tuple giving the minimum and maximum x-axis
476
517
        data range.
477
518
        """
478
519
        try:
479
 
            lower = min([min(s) for type, s in self.annotated_data()
 
520
            lower = min([min(self._filter_none(s))
 
521
                         for type, s in self.annotated_data()
480
522
                         if type == 'x'])
481
 
            upper = max([max(s) for type, s in self.annotated_data()
 
523
            upper = max([max(self._filter_none(s))
 
524
                         for type, s in self.annotated_data()
482
525
                         if type == 'x'])
483
526
            return (lower, upper)
484
527
        except ValueError:
489
532
        data range.
490
533
        """
491
534
        try:
492
 
            lower = min([min(s) for type, s in self.annotated_data()
 
535
            lower = min([min(self._filter_none(s))
 
536
                         for type, s in self.annotated_data()
493
537
                         if type == 'y'])
494
 
            upper = max([max(s) + 1 for type, s in self.annotated_data()
 
538
            upper = max([max(self._filter_none(s)) + 1
 
539
                         for type, s in self.annotated_data()
495
540
                         if type == 'y'])
496
541
            return (lower, upper)
497
542
        except ValueError:
517
562
        if x_range is None:
518
563
            x_range = self.data_x_range()
519
564
            if x_range and x_range[0] > 0:
520
 
                x_range = (0, x_range[1])
 
565
                x_range = (x_range[0], x_range[1])
521
566
        self.scaled_x_range = x_range
522
567
 
523
568
        # Determine the y-axis range for scaling.
524
569
        if y_range is None:
525
570
            y_range = self.data_y_range()
526
571
            if y_range and y_range[0] > 0:
527
 
                y_range = (0, y_range[1])
 
572
                y_range = (y_range[0], y_range[1])
528
573
        self.scaled_y_range = y_range
529
574
 
530
575
        scaled_data = []
535
580
                scale_range = y_range
536
581
            elif type == 'marker-size':
537
582
                scale_range = (0, max(dataset))
538
 
            scaled_data.append([data_class.scale_value(v, scale_range)
539
 
                                for v in dataset])
 
583
            scaled_dataset = []
 
584
            for v in dataset:
 
585
                if v is None:
 
586
                    scaled_dataset.append(None)
 
587
                else:
 
588
                    scaled_dataset.append(
 
589
                        data_class.scale_value(v, scale_range))
 
590
            scaled_data.append(scaled_dataset)
540
591
        return scaled_data
541
592
 
542
593
    def add_data(self, data):
549
600
        if not issubclass(data_class, Data):
550
601
            raise UnknownDataTypeException()
551
602
        if self.auto_scale:
552
 
            print data_class
553
603
            data = self.scaled_data(data_class, self.x_range, self.y_range)
554
604
        else:
555
605
            data = self.data
564
614
 
565
615
    def set_axis_labels(self, axis_type, values):
566
616
        assert(axis_type in Axis.TYPES)
567
 
        values = [urllib.quote(a) for a in values]
 
617
        values = [urllib.quote(str(a)) for a in values]
568
618
        axis_index = len(self.axis)
569
619
        axis = LabelAxis(axis_index, axis_type, values)
570
620
        self.axis.append(axis)
614
664
        url_bits = []
615
665
        url_bits.append('chxt=%s' % ','.join(available_axis))
616
666
        if label_axis:
617
 
            url_bits.append('chxl=%s' % '|'.join(label_axis))
 
667
            url_bits.append('chxl=%s' % '%7c'.join(label_axis))
618
668
        if range_axis:
619
 
            url_bits.append('chxr=%s' % '|'.join(range_axis))
 
669
            url_bits.append('chxr=%s' % '%7c'.join(range_axis))
620
670
        if positions:
621
 
            url_bits.append('chxp=%s' % '|'.join(positions))
 
671
            url_bits.append('chxp=%s' % '%7c'.join(positions))
622
672
        if styles:
623
 
            url_bits.append('chxs=%s' % '|'.join(styles))
 
673
            url_bits.append('chxs=%s' % '%7c'.join(styles))
624
674
        return '&'.join(url_bits)
625
675
 
626
676
    # Markers, Ranges and Fill area (chm)
627
677
    # -------------------------------------------------------------------------
628
678
 
629
 
    def markers_to_url(self):
630
 
        return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
 
679
    def markers_to_url(self):        
 
680
        return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
631
681
 
632
682
    def add_marker(self, index, point, marker_type, colour, size, priority=0):
633
683
        self.markers.append((marker_type, colour, str(index), str(point), \
634
684
            str(size), str(priority)))
635
685
 
636
686
    def add_horizontal_range(self, colour, start, stop):
637
 
        self.markers.append(('r', colour, '1', str(start), str(stop)))
 
687
        self.markers.append(('r', colour, '0', str(start), str(stop)))
 
688
 
 
689
    def add_data_line(self, colour, data_set, size, priority=0):
 
690
        self.markers.append(('D', colour, str(data_set), '0', str(size), \
 
691
            str(priority)))
 
692
 
 
693
    def add_marker_text(self, string, colour, data_set, data_point, size, \
 
694
            priority=0):
 
695
        self.markers.append((str(string), colour, str(data_set), \
 
696
            str(data_point), str(size), str(priority)))        
638
697
 
639
698
    def add_vertical_range(self, colour, start, stop):
640
 
        self.markers.append(('R', colour, '1', str(start), str(stop)))
 
699
        self.markers.append(('R', colour, '0', str(start), str(stop)))
641
700
 
642
701
    def add_fill_range(self, colour, index_start, index_end):
643
702
        self.markers.append(('b', colour, str(index_start), str(index_end), \
830
889
            raise AbstractClassException('This is an abstract class')
831
890
        Chart.__init__(self, *args, **kwargs)
832
891
        self.pie_labels = []
 
892
        if self.y_range:
 
893
            warnings.warn('y_range is not used with %s.' % \
 
894
                (self.__class__.__name__))
833
895
 
834
896
    def set_pie_labels(self, labels):
835
897
        self.pie_labels = [urllib.quote(a) for a in labels]
837
899
    def get_url_bits(self, data_class=None):
838
900
        url_bits = Chart.get_url_bits(self, data_class=data_class)
839
901
        if self.pie_labels:
840
 
            url_bits.append('chl=%s' % '|'.join(self.pie_labels))
 
902
            url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
841
903
        return url_bits
842
904
 
843
905
    def annotated_data(self):
844
906
        # Datasets are all y-axis data. However, there should only be
845
907
        # one dataset for pie charts.
846
908
        for dataset in self.data:
847
 
            yield ('y', dataset)
 
909
            yield ('x', dataset)
 
910
 
 
911
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
912
        if not x_range:
 
913
            x_range = [0, sum(self.data[0])]
 
914
        return Chart.scaled_data(self, data_class, x_range, self.y_range)
848
915
 
849
916
 
850
917
class PieChart2D(PieChart):
905
972
class GoogleOMeterChart(PieChart):
906
973
    """Inheriting from PieChart because of similar labeling"""
907
974
 
 
975
    def __init__(self, *args, **kwargs):
 
976
        PieChart.__init__(self, *args, **kwargs)
 
977
        if self.auto_scale and not self.x_range:
 
978
            warnings.warn('Please specify an x_range with GoogleOMeterChart, '
 
979
                'otherwise one arrow will always be at the max.')
 
980
 
908
981
    def type_to_url(self):
909
982
        return 'cht=gom'
910
983
 
911
984
 
 
985
class QRChart(Chart):
 
986
 
 
987
    def __init__(self, *args, **kwargs):
 
988
        Chart.__init__(self, *args, **kwargs)
 
989
        self.encoding = None
 
990
        self.ec_level = None
 
991
        self.margin = None
 
992
 
 
993
    def type_to_url(self):
 
994
        return 'cht=qr'
 
995
 
 
996
    def data_to_url(self, data_class=None):
 
997
        if not self.data:
 
998
            raise NoDataGivenException()
 
999
        return 'chl=%s' % urllib.quote(self.data[0])
 
1000
 
 
1001
    def get_url_bits(self, data_class=None):
 
1002
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
1003
        if self.encoding:
 
1004
            url_bits.append('choe=%s' % self.encoding)
 
1005
        if self.ec_level:
 
1006
            url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin))
 
1007
        return url_bits
 
1008
 
 
1009
    def set_encoding(self, encoding):
 
1010
        self.encoding = encoding
 
1011
 
 
1012
    def set_ec(self, level, margin):
 
1013
        self.ec_level = level
 
1014
        self.margin = margin
 
1015
 
 
1016
 
912
1017
class ChartGrammar(object):
913
1018
 
914
 
    def __init__(self, grammar):
 
1019
    def __init__(self):
 
1020
        self.grammar = None
 
1021
        self.chart = None
 
1022
 
 
1023
    def parse(self, grammar):
915
1024
        self.grammar = grammar
916
1025
        self.chart = self.create_chart_instance()
917
1026
 
 
1027
        for attr in self.grammar:
 
1028
            if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
 
1029
                continue  # These are already parsed in create_chart_instance
 
1030
            attr_func = 'parse_' + attr
 
1031
            if not hasattr(self, attr_func):
 
1032
                warnings.warn('No parser for grammar attribute "%s"' % (attr))
 
1033
                continue
 
1034
            getattr(self, attr_func)(grammar[attr])
 
1035
 
 
1036
        return self.chart
 
1037
 
 
1038
    def parse_data(self, data):
 
1039
        self.chart.data = data
 
1040
 
918
1041
    @staticmethod
919
1042
    def get_possible_chart_types():
920
1043
        possible_charts = []
921
 
        for cls_name in globals():
 
1044
        for cls_name in globals().keys():
922
1045
            if not cls_name.endswith('Chart'):
923
1046
                continue
924
1047
            cls = globals()[cls_name]
925
1048
            # Check if it is an abstract class
926
1049
            try:
927
 
                cls(1, 1)
 
1050
                a = cls(1, 1, auto_scale=False)
 
1051
                del a
928
1052
            except AbstractClassException:
929
1053
                continue
930
1054
            # Strip off "Class"
931
1055
            possible_charts.append(cls_name[:-5])
932
1056
        return possible_charts
933
1057
 
934
 
    def create_chart_instance(self):
 
1058
    def create_chart_instance(self, grammar=None):
 
1059
        if not grammar:
 
1060
            grammar = self.grammar
 
1061
        assert(isinstance(grammar, dict))  # grammar must be a dict
935
1062
        assert('w' in grammar)  # width is required
936
1063
        assert('h' in grammar)  # height is required
937
1064
        assert('type' in grammar)  # type is required
 
1065
        chart_type = grammar['type']
 
1066
        w = grammar['w']
 
1067
        h = grammar['h']
 
1068
        auto_scale = grammar.get('auto_scale', None)
 
1069
        x_range = grammar.get('x_range', None)
 
1070
        y_range = grammar.get('y_range', None)
938
1071
        types = ChartGrammar.get_possible_chart_types()
939
 
        if grammar['type'] not in types:
 
1072
        if chart_type not in types:
940
1073
            raise UnknownChartType('%s is an unknown chart type. Possible '
941
 
                'chart types are %s' % (grammar['type'], ','.join(types)))
 
1074
                'chart types are %s' % (chart_type, ','.join(types)))
 
1075
        return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
 
1076
            x_range=x_range, y_range=y_range)
942
1077
 
943
1078
    def download(self):
944
1079
        pass