/+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: 2007-12-15 23:21:31 UTC
  • Revision ID: git-v1:857495549bac47c6a70872d4b36dd1fe49270357
version bump and MANIFEST fixes to allow examples and COPYING in the sdist

Show diffs side-by-side

added added

removed removed

Lines of Context:
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
 
from __future__ import division
23
 
 
24
 
import os
25
 
import urllib
26
 
import urllib2
27
 
import math
28
 
import random
29
 
import re
30
 
import warnings
31
 
import copy
32
 
 
33
 
# Helper variables and functions
34
 
# -----------------------------------------------------------------------------
35
 
 
36
 
__version__ = '0.2.2'
37
 
__author__ = 'Gerald Kaszuba'
38
 
 
39
 
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
40
 
 
41
 
def _check_colour(colour):
42
 
    if not reo_colour.match(colour):
43
 
        raise InvalidParametersException('Colours need to be in ' \
44
 
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
45
 
            colour)
46
 
 
47
 
 
48
 
def _reset_warnings():
49
 
    """Helper function to reset all warnings. Used by the unit tests."""
50
 
    globals()['__warningregistry__'] = None
51
 
 
52
 
 
53
 
# Exception Classes
54
 
# -----------------------------------------------------------------------------
55
 
 
56
 
 
57
 
class PyGoogleChartException(Exception):
58
 
    pass
59
 
 
60
 
 
61
 
class DataOutOfRangeException(PyGoogleChartException):
62
 
    pass
63
 
 
64
 
 
65
 
class UnknownDataTypeException(PyGoogleChartException):
66
 
    pass
67
 
 
68
 
 
69
 
class NoDataGivenException(PyGoogleChartException):
70
 
    pass
71
 
 
72
 
 
73
 
class InvalidParametersException(PyGoogleChartException):
74
 
    pass
75
 
 
76
 
 
77
 
class BadContentTypeException(PyGoogleChartException):
78
 
    pass
79
 
 
80
 
 
81
 
class AbstractClassException(PyGoogleChartException):
82
 
    pass
83
 
 
84
 
 
85
 
class UnknownChartType(PyGoogleChartException):
86
 
    pass
87
 
 
88
 
 
89
 
# Data Classes
90
 
# -----------------------------------------------------------------------------
91
 
 
92
 
 
93
 
class Data(object):
94
 
 
95
 
    def __init__(self, data):
96
 
        if type(self) == Data:
97
 
            raise AbstractClassException('This is an abstract class')
98
 
        self.data = data
99
 
 
100
 
    @classmethod
101
 
    def float_scale_value(cls, value, range):
102
 
        lower, upper = range
103
 
        assert(upper > lower)
104
 
        scaled = (value - lower) * (cls.max_value / (upper - lower))
105
 
        return scaled
106
 
 
107
 
    @classmethod
108
 
    def clip_value(cls, value):
109
 
        return max(0, min(value, cls.max_value))
110
 
 
111
 
    @classmethod
112
 
    def int_scale_value(cls, value, range):
113
 
        return int(round(cls.float_scale_value(value, range)))
114
 
 
115
 
    @classmethod
116
 
    def scale_value(cls, value, range):
117
 
        scaled = cls.int_scale_value(value, range)
118
 
        clipped = cls.clip_value(scaled)
119
 
        Data.check_clip(scaled, clipped)
120
 
        return clipped
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
 
 
128
 
 
129
 
class SimpleData(Data):
130
 
 
131
 
    max_value = 61
132
 
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
133
 
 
134
 
    def __repr__(self):
135
 
        encoded_data = []
136
 
        for data in self.data:
137
 
            sub_data = []
138
 
            for value in data:
139
 
                if value is None:
140
 
                    sub_data.append('_')
141
 
                elif value >= 0 and value <= self.max_value:
142
 
                    sub_data.append(SimpleData.enc_map[value])
143
 
                else:
144
 
                    raise DataOutOfRangeException('cannot encode value: %d'
145
 
                                                  % value)
146
 
            encoded_data.append(''.join(sub_data))
147
 
        return 'chd=s:' + ','.join(encoded_data)
148
 
 
149
 
 
150
 
class TextData(Data):
151
 
 
152
 
    max_value = 100
153
 
 
154
 
    def __repr__(self):
155
 
        encoded_data = []
156
 
        for data in self.data:
157
 
            sub_data = []
158
 
            for value in data:
159
 
                if value is None:
160
 
                    sub_data.append(-1)
161
 
                elif value >= 0 and value <= self.max_value:
162
 
                    sub_data.append("%.1f" % float(value))
163
 
                else:
164
 
                    raise DataOutOfRangeException()
165
 
            encoded_data.append(','.join(sub_data))
166
 
        return 'chd=t:' + '%7c'.join(encoded_data)
167
 
 
168
 
    @classmethod
169
 
    def scale_value(cls, value, range):
170
 
        # use float values instead of integers because we don't need an encode
171
 
        # map index
172
 
        scaled = cls.float_scale_value(value, range)
173
 
        clipped = cls.clip_value(scaled)
174
 
        Data.check_clip(scaled, clipped)
175
 
        return clipped
176
 
 
177
 
 
178
 
class ExtendedData(Data):
179
 
 
180
 
    max_value = 4095
181
 
    enc_map = \
182
 
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
183
 
 
184
 
    def __repr__(self):
185
 
        encoded_data = []
186
 
        enc_size = len(ExtendedData.enc_map)
187
 
        for data in self.data:
188
 
            sub_data = []
189
 
            for value in data:
190
 
                if value is None:
191
 
                    sub_data.append('__')
192
 
                elif value >= 0 and value <= self.max_value:
193
 
                    first, second = divmod(int(value), enc_size)
194
 
                    sub_data.append('%s%s' % (
195
 
                        ExtendedData.enc_map[first],
196
 
                        ExtendedData.enc_map[second]))
197
 
                else:
198
 
                    raise DataOutOfRangeException( \
199
 
                        'Item #%i "%s" is out of range' % (data.index(value), \
200
 
                        value))
201
 
            encoded_data.append(''.join(sub_data))
202
 
        return 'chd=e:' + ','.join(encoded_data)
203
 
 
204
 
 
205
 
# Axis Classes
206
 
# -----------------------------------------------------------------------------
207
 
 
208
 
 
209
 
class Axis(object):
210
 
 
211
 
    BOTTOM = 'x'
212
 
    TOP = 't'
213
 
    LEFT = 'y'
214
 
    RIGHT = 'r'
215
 
    TYPES = (BOTTOM, TOP, LEFT, RIGHT)
216
 
 
217
 
    def __init__(self, axis_index, axis_type, **kw):
218
 
        assert(axis_type in Axis.TYPES)
219
 
        self.has_style = False
220
 
        self.axis_index = axis_index
221
 
        self.axis_type = axis_type
222
 
        self.positions = None
223
 
 
224
 
    def set_index(self, axis_index):
225
 
        self.axis_index = axis_index
226
 
 
227
 
    def set_positions(self, positions):
228
 
        self.positions = positions
229
 
 
230
 
    def set_style(self, colour, font_size=None, alignment=None):
231
 
        _check_colour(colour)
232
 
        self.colour = colour
233
 
        self.font_size = font_size
234
 
        self.alignment = alignment
235
 
        self.has_style = True
236
 
 
237
 
    def style_to_url(self):
238
 
        bits = []
239
 
        bits.append(str(self.axis_index))
240
 
        bits.append(self.colour)
241
 
        if self.font_size is not None:
242
 
            bits.append(str(self.font_size))
243
 
            if self.alignment is not None:
244
 
                bits.append(str(self.alignment))
245
 
        return ','.join(bits)
246
 
 
247
 
    def positions_to_url(self):
248
 
        bits = []
249
 
        bits.append(str(self.axis_index))
250
 
        bits += [str(a) for a in self.positions]
251
 
        return ','.join(bits)
252
 
 
253
 
 
254
 
class LabelAxis(Axis):
255
 
 
256
 
    def __init__(self, axis_index, axis_type, values, **kwargs):
257
 
        Axis.__init__(self, axis_index, axis_type, **kwargs)
258
 
        self.values = [str(a) for a in values]
259
 
 
260
 
    def __repr__(self):
261
 
        return '%i:%%7c%s' % (self.axis_index, '%7c'.join(self.values))
262
 
 
263
 
 
264
 
class RangeAxis(Axis):
265
 
 
266
 
    def __init__(self, axis_index, axis_type, low, high, **kwargs):
267
 
        Axis.__init__(self, axis_index, axis_type, **kwargs)
268
 
        self.low = low
269
 
        self.high = high
270
 
 
271
 
    def __repr__(self):
272
 
        return '%i,%s,%s' % (self.axis_index, self.low, self.high)
273
 
 
274
 
# Chart Classes
275
 
# -----------------------------------------------------------------------------
276
 
 
277
 
 
278
 
class Chart(object):
279
 
    """Abstract class for all chart types.
280
 
 
281
 
    width are height specify the dimensions of the image. title sets the title
282
 
    of the chart. legend requires a list that corresponds to datasets.
283
 
    """
284
 
 
285
 
    BASE_URL = 'http://chart.apis.google.com/chart?'
286
 
    BACKGROUND = 'bg'
287
 
    CHART = 'c'
288
 
    ALPHA = 'a'
289
 
    VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
290
 
    SOLID = 's'
291
 
    LINEAR_GRADIENT = 'lg'
292
 
    LINEAR_STRIPES = 'ls'
293
 
 
294
 
    def __init__(self, width, height, title=None, legend=None, colours=None,
295
 
            auto_scale=True, x_range=None, y_range=None,
296
 
            colours_within_series=None):
297
 
        if type(self) == Chart:
298
 
            raise AbstractClassException('This is an abstract class')
299
 
        assert(isinstance(width, int))
300
 
        assert(isinstance(height, int))
301
 
        self.width = width
302
 
        self.height = height
303
 
        self.data = []
304
 
        self.set_title(title)
305
 
        self.set_title_style(None, None)
306
 
        self.set_legend(legend)
307
 
        self.set_legend_position(None)
308
 
        self.set_colours(colours)
309
 
        self.set_colours_within_series(colours_within_series)
310
 
 
311
 
        # Data 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
315
 
        self.scaled_data_class = None
316
 
        self.scaled_x_range = None
317
 
        self.scaled_y_range = None
318
 
 
319
 
        self.fill_types = {
320
 
            Chart.BACKGROUND: None,
321
 
            Chart.CHART: None,
322
 
            Chart.ALPHA: None,
323
 
        }
324
 
        self.fill_area = {
325
 
            Chart.BACKGROUND: None,
326
 
            Chart.CHART: None,
327
 
            Chart.ALPHA: None,
328
 
        }
329
 
        self.axis = []
330
 
        self.markers = []
331
 
        self.line_styles = {}
332
 
        self.grid = None
333
 
 
334
 
    # URL generation
335
 
    # -------------------------------------------------------------------------
336
 
 
337
 
    def get_url(self, data_class=None):
338
 
        url_bits = self.get_url_bits(data_class=data_class)
339
 
        return self.BASE_URL + '&'.join(url_bits)
340
 
 
341
 
    def get_url_bits(self, data_class=None):
342
 
        url_bits = []
343
 
        # required arguments
344
 
        url_bits.append(self.type_to_url())
345
 
        url_bits.append('chs=%ix%i' % (self.width, self.height))
346
 
        url_bits.append(self.data_to_url(data_class=data_class))
347
 
        # optional arguments
348
 
        if self.title:
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))
353
 
        if 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))
357
 
        if 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))
361
 
        ret = self.fill_to_url()
362
 
        if ret:
363
 
            url_bits.append(ret)
364
 
        ret = self.axis_to_url()
365
 
        if ret:
366
 
            url_bits.append(ret)                    
367
 
        if self.markers:
368
 
            url_bits.append(self.markers_to_url())        
369
 
        if self.line_styles:
370
 
            style = []
371
 
            for index in xrange(max(self.line_styles) + 1):
372
 
                if index in self.line_styles:
373
 
                    values = self.line_styles[index]
374
 
                else:
375
 
                    values = ('1', )
376
 
                style.append(','.join(values))
377
 
            url_bits.append('chls=%s' % '%7c'.join(style))
378
 
        if self.grid:
379
 
            url_bits.append('chg=%s' % self.grid)
380
 
        return url_bits
381
 
 
382
 
    # Downloading
383
 
    # -------------------------------------------------------------------------
384
 
 
385
 
    def download(self, file_name):
386
 
        opener = urllib2.urlopen(self.get_url())
387
 
 
388
 
        if opener.headers['content-type'] != 'image/png':
389
 
            raise BadContentTypeException('Server responded with a ' \
390
 
                'content-type of %s' % opener.headers['content-type'])
391
 
 
392
 
        open(file_name, 'wb').write(opener.read())
393
 
 
394
 
    # Simple settings
395
 
    # -------------------------------------------------------------------------
396
 
 
397
 
    def set_title(self, title):
398
 
        if title:
399
 
            self.title = urllib.quote(title)
400
 
        else:
401
 
            self.title = None
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
 
 
409
 
    def set_legend(self, legend):
410
 
        """legend needs to be a list, tuple or None"""
411
 
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
412
 
            legend is None)
413
 
        if legend:
414
 
            self.legend = [urllib.quote(a) for a in legend]
415
 
        else:
416
 
            self.legend = None
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
 
 
424
 
    # Chart colours
425
 
    # -------------------------------------------------------------------------
426
 
 
427
 
    def set_colours(self, colours):
428
 
        # colours needs to be a list, tuple or None
429
 
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
430
 
            colours is None)
431
 
        # make sure the colours are in the right format
432
 
        if colours:
433
 
            for col in colours:
434
 
                _check_colour(col)
435
 
        self.colours = colours
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
 
 
447
 
    # Background/Chart colours
448
 
    # -------------------------------------------------------------------------
449
 
 
450
 
    def fill_solid(self, area, colour):
451
 
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
452
 
        _check_colour(colour)
453
 
        self.fill_area[area] = colour
454
 
        self.fill_types[area] = Chart.SOLID
455
 
 
456
 
    def _check_fill_linear(self, angle, *args):
457
 
        assert(isinstance(args, list) or isinstance(args, tuple))
458
 
        assert(angle >= 0 and angle <= 90)
459
 
        assert(len(args) % 2 == 0)
460
 
        args = list(args)  # args is probably a tuple and we need to mutate
461
 
        for a in xrange(int(len(args) / 2)):
462
 
            col = args[a * 2]
463
 
            offset = args[a * 2 + 1]
464
 
            _check_colour(col)
465
 
            assert(offset >= 0 and offset <= 1)
466
 
            args[a * 2 + 1] = str(args[a * 2 + 1])
467
 
        return args
468
 
 
469
 
    def fill_linear_gradient(self, area, angle, *args):
470
 
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
471
 
        args = self._check_fill_linear(angle, *args)
472
 
        self.fill_types[area] = Chart.LINEAR_GRADIENT
473
 
        self.fill_area[area] = ','.join([str(angle)] + args)
474
 
 
475
 
    def fill_linear_stripes(self, area, angle, *args):
476
 
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
477
 
        args = self._check_fill_linear(angle, *args)
478
 
        self.fill_types[area] = Chart.LINEAR_STRIPES
479
 
        self.fill_area[area] = ','.join([str(angle)] + args)
480
 
 
481
 
    def fill_to_url(self):
482
 
        areas = []
483
 
        for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
484
 
            if self.fill_types[area]:
485
 
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
486
 
                    self.fill_area[area]))
487
 
        if areas:
488
 
            return 'chf=' + '%7c'.join(areas)
489
 
 
490
 
    # Data
491
 
    # -------------------------------------------------------------------------
492
 
 
493
 
    def data_class_detection(self, data):
494
 
        """Determines the appropriate data encoding type to give satisfactory
495
 
        resolution (http://code.google.com/apis/chart/#chart_data).
496
 
        """
497
 
        assert(isinstance(data, list) or isinstance(data, tuple))
498
 
        if not isinstance(self, (LineChart, BarChart, ScatterChart)):
499
 
            # From the link above:
500
 
            #   Simple encoding is suitable for all other types of chart
501
 
            #   regardless of size.
502
 
            return SimpleData
503
 
        elif self.height < 100:
504
 
            # The link above indicates that line and bar charts less
505
 
            # than 300px in size can be suitably represented with the
506
 
            # simple encoding. I've found that this isn't sufficient,
507
 
            # e.g. examples/line-xy-circle.png. Let's try 100px.
508
 
            return SimpleData
509
 
        else:
510
 
            return ExtendedData
511
 
 
512
 
    def _filter_none(self, data):
513
 
        return [r for r in data if r is not None]
514
 
 
515
 
    def data_x_range(self):
516
 
        """Return a 2-tuple giving the minimum and maximum x-axis
517
 
        data range.
518
 
        """
519
 
        try:
520
 
            lower = min([min(self._filter_none(s))
521
 
                         for type, s in self.annotated_data()
522
 
                         if type == 'x'])
523
 
            upper = max([max(self._filter_none(s))
524
 
                         for type, s in self.annotated_data()
525
 
                         if type == 'x'])
526
 
            return (lower, upper)
527
 
        except ValueError:
528
 
            return None     # no x-axis datasets
529
 
 
530
 
    def data_y_range(self):
531
 
        """Return a 2-tuple giving the minimum and maximum y-axis
532
 
        data range.
533
 
        """
534
 
        try:
535
 
            lower = min([min(self._filter_none(s))
536
 
                         for type, s in self.annotated_data()
537
 
                         if type == 'y'])
538
 
            upper = max([max(self._filter_none(s)) + 1
539
 
                         for type, s in self.annotated_data()
540
 
                         if type == 'y'])
541
 
            return (lower, upper)
542
 
        except ValueError:
543
 
            return None     # no y-axis datasets
544
 
 
545
 
    def scaled_data(self, data_class, x_range=None, y_range=None):
546
 
        """Scale `self.data` as appropriate for the given data encoding
547
 
        (data_class) and return it.
548
 
 
549
 
        An optional `y_range` -- a 2-tuple (lower, upper) -- can be
550
 
        given to specify the y-axis bounds. If not given, the range is
551
 
        inferred from the data: (0, <max-value>) presuming no negative
552
 
        values, or (<min-value>, <max-value>) if there are negative
553
 
        values.  `self.scaled_y_range` is set to the actual lower and
554
 
        upper scaling range.
555
 
 
556
 
        Ditto for `x_range`. Note that some chart types don't have x-axis
557
 
        data.
558
 
        """
559
 
        self.scaled_data_class = data_class
560
 
 
561
 
        # Determine the x-axis range for scaling.
562
 
        if x_range is None:
563
 
            x_range = self.data_x_range()
564
 
            if x_range and x_range[0] > 0:
565
 
                x_range = (x_range[0], x_range[1])
566
 
        self.scaled_x_range = x_range
567
 
 
568
 
        # Determine the y-axis range for scaling.
569
 
        if y_range is None:
570
 
            y_range = self.data_y_range()
571
 
            if y_range and y_range[0] > 0:
572
 
                y_range = (y_range[0], y_range[1])
573
 
        self.scaled_y_range = y_range
574
 
 
575
 
        scaled_data = []
576
 
        for type, dataset in self.annotated_data():
577
 
            if type == 'x':
578
 
                scale_range = x_range
579
 
            elif type == 'y':
580
 
                scale_range = y_range
581
 
            elif type == 'marker-size':
582
 
                scale_range = (0, max(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)
591
 
        return scaled_data
592
 
 
593
 
    def add_data(self, data):
594
 
        self.data.append(data)
595
 
        return len(self.data) - 1  # return the "index" of the data set
596
 
 
597
 
    def data_to_url(self, data_class=None):
598
 
        if not data_class:
599
 
            data_class = self.data_class_detection(self.data)
600
 
        if not issubclass(data_class, Data):
601
 
            raise UnknownDataTypeException()
602
 
        if self.auto_scale:
603
 
            data = self.scaled_data(data_class, self.x_range, self.y_range)
604
 
        else:
605
 
            data = self.data
606
 
        return repr(data_class(data))
607
 
 
608
 
    def annotated_data(self):
609
 
        for dataset in self.data:
610
 
            yield ('x', dataset)
611
 
 
612
 
    # Axis Labels
613
 
    # -------------------------------------------------------------------------
614
 
 
615
 
    def set_axis_labels(self, axis_type, values):
616
 
        assert(axis_type in Axis.TYPES)
617
 
        values = [urllib.quote(str(a)) for a in values]
618
 
        axis_index = len(self.axis)
619
 
        axis = LabelAxis(axis_index, axis_type, values)
620
 
        self.axis.append(axis)
621
 
        return axis_index
622
 
 
623
 
    def set_axis_range(self, axis_type, low, high):
624
 
        assert(axis_type in Axis.TYPES)
625
 
        axis_index = len(self.axis)
626
 
        axis = RangeAxis(axis_index, axis_type, low, high)
627
 
        self.axis.append(axis)
628
 
        return axis_index
629
 
 
630
 
    def set_axis_positions(self, axis_index, positions):
631
 
        try:
632
 
            self.axis[axis_index].set_positions(positions)
633
 
        except IndexError:
634
 
            raise InvalidParametersException('Axis index %i has not been ' \
635
 
                'created' % axis)
636
 
 
637
 
    def set_axis_style(self, axis_index, colour, font_size=None, \
638
 
            alignment=None):
639
 
        try:
640
 
            self.axis[axis_index].set_style(colour, font_size, alignment)
641
 
        except IndexError:
642
 
            raise InvalidParametersException('Axis index %i has not been ' \
643
 
                'created' % axis)
644
 
 
645
 
    def axis_to_url(self):
646
 
        available_axis = []
647
 
        label_axis = []
648
 
        range_axis = []
649
 
        positions = []
650
 
        styles = []
651
 
        index = -1
652
 
        for axis in self.axis:
653
 
            available_axis.append(axis.axis_type)
654
 
            if isinstance(axis, RangeAxis):
655
 
                range_axis.append(repr(axis))
656
 
            if isinstance(axis, LabelAxis):
657
 
                label_axis.append(repr(axis))
658
 
            if axis.positions:
659
 
                positions.append(axis.positions_to_url())
660
 
            if axis.has_style:
661
 
                styles.append(axis.style_to_url())
662
 
        if not available_axis:
663
 
            return
664
 
        url_bits = []
665
 
        url_bits.append('chxt=%s' % ','.join(available_axis))
666
 
        if label_axis:
667
 
            url_bits.append('chxl=%s' % '%7c'.join(label_axis))
668
 
        if range_axis:
669
 
            url_bits.append('chxr=%s' % '%7c'.join(range_axis))
670
 
        if positions:
671
 
            url_bits.append('chxp=%s' % '%7c'.join(positions))
672
 
        if styles:
673
 
            url_bits.append('chxs=%s' % '%7c'.join(styles))
674
 
        return '&'.join(url_bits)
675
 
 
676
 
    # Markers, Ranges and Fill area (chm)
677
 
    # -------------------------------------------------------------------------
678
 
 
679
 
    def markers_to_url(self):        
680
 
        return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
681
 
 
682
 
    def add_marker(self, index, point, marker_type, colour, size, priority=0):
683
 
        self.markers.append((marker_type, colour, str(index), str(point), \
684
 
            str(size), str(priority)))
685
 
 
686
 
    def add_horizontal_range(self, colour, start, 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)))        
697
 
 
698
 
    def add_vertical_range(self, colour, start, stop):
699
 
        self.markers.append(('R', colour, '0', str(start), str(stop)))
700
 
 
701
 
    def add_fill_range(self, colour, index_start, index_end):
702
 
        self.markers.append(('b', colour, str(index_start), str(index_end), \
703
 
            '1'))
704
 
 
705
 
    def add_fill_simple(self, colour):
706
 
        self.markers.append(('B', colour, '1', '1', '1'))
707
 
 
708
 
    # Line styles
709
 
    # -------------------------------------------------------------------------
710
 
 
711
 
    def set_line_style(self, index, thickness=1, line_segment=None, \
712
 
            blank_segment=None):
713
 
        value = []
714
 
        value.append(str(thickness))
715
 
        if line_segment:
716
 
            value.append(str(line_segment))
717
 
            value.append(str(blank_segment))
718
 
        self.line_styles[index] = value
719
 
 
720
 
    # Grid
721
 
    # -------------------------------------------------------------------------
722
 
 
723
 
    def set_grid(self, x_step, y_step, line_segment=1, \
724
 
            blank_segment=0):
725
 
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
726
 
            blank_segment)
727
 
 
728
 
 
729
 
class ScatterChart(Chart):
730
 
 
731
 
    def type_to_url(self):
732
 
        return 'cht=s'
733
 
 
734
 
    def annotated_data(self):
735
 
        yield ('x', self.data[0])
736
 
        yield ('y', self.data[1])
737
 
        if len(self.data) > 2:
738
 
            # The optional third dataset is relative sizing for point
739
 
            # markers.
740
 
            yield ('marker-size', self.data[2])
741
 
 
742
 
 
743
 
class LineChart(Chart):
744
 
 
745
 
    def __init__(self, *args, **kwargs):
746
 
        if type(self) == LineChart:
747
 
            raise AbstractClassException('This is an abstract class')
748
 
        Chart.__init__(self, *args, **kwargs)
749
 
 
750
 
 
751
 
class SimpleLineChart(LineChart):
752
 
 
753
 
    def type_to_url(self):
754
 
        return 'cht=lc'
755
 
 
756
 
    def annotated_data(self):
757
 
        # All datasets are y-axis data.
758
 
        for dataset in self.data:
759
 
            yield ('y', dataset)
760
 
 
761
 
 
762
 
class SparkLineChart(SimpleLineChart):
763
 
 
764
 
    def type_to_url(self):
765
 
        return 'cht=ls'
766
 
 
767
 
 
768
 
class XYLineChart(LineChart):
769
 
 
770
 
    def type_to_url(self):
771
 
        return 'cht=lxy'
772
 
 
773
 
    def annotated_data(self):
774
 
        # Datasets alternate between x-axis, y-axis.
775
 
        for i, dataset in enumerate(self.data):
776
 
            if i % 2 == 0:
777
 
                yield ('x', dataset)
778
 
            else:
779
 
                yield ('y', dataset)
780
 
 
781
 
 
782
 
class BarChart(Chart):
783
 
 
784
 
    def __init__(self, *args, **kwargs):
785
 
        if type(self) == BarChart:
786
 
            raise AbstractClassException('This is an abstract class')
787
 
        Chart.__init__(self, *args, **kwargs)
788
 
        self.bar_width = None
789
 
        self.zero_lines = {}
790
 
 
791
 
    def set_bar_width(self, bar_width):
792
 
        self.bar_width = bar_width
793
 
 
794
 
    def set_zero_line(self, index, zero_line):
795
 
        self.zero_lines[index] = zero_line
796
 
 
797
 
    def get_url_bits(self, data_class=None, skip_chbh=False):
798
 
        url_bits = Chart.get_url_bits(self, data_class=data_class)
799
 
        if not skip_chbh and self.bar_width is not None:
800
 
            url_bits.append('chbh=%i' % self.bar_width)
801
 
        zero_line = []
802
 
        if self.zero_lines:
803
 
            for index in xrange(max(self.zero_lines) + 1):
804
 
                if index in self.zero_lines:
805
 
                    zero_line.append(str(self.zero_lines[index]))
806
 
                else:
807
 
                    zero_line.append('0')
808
 
            url_bits.append('chp=%s' % ','.join(zero_line))
809
 
        return url_bits
810
 
 
811
 
 
812
 
class StackedHorizontalBarChart(BarChart):
813
 
 
814
 
    def type_to_url(self):
815
 
        return 'cht=bhs'
816
 
 
817
 
 
818
 
class StackedVerticalBarChart(BarChart):
819
 
 
820
 
    def type_to_url(self):
821
 
        return 'cht=bvs'
822
 
 
823
 
    def annotated_data(self):
824
 
        for dataset in self.data:
825
 
            yield ('y', dataset)
826
 
 
827
 
 
828
 
class GroupedBarChart(BarChart):
829
 
 
830
 
    def __init__(self, *args, **kwargs):
831
 
        if type(self) == GroupedBarChart:
832
 
            raise AbstractClassException('This is an abstract class')
833
 
        BarChart.__init__(self, *args, **kwargs)
834
 
        self.bar_spacing = None
835
 
        self.group_spacing = None
836
 
 
837
 
    def set_bar_spacing(self, spacing):
838
 
        """Set spacing between bars in a group."""
839
 
        self.bar_spacing = spacing
840
 
 
841
 
    def set_group_spacing(self, spacing):
842
 
        """Set spacing between groups of bars."""
843
 
        self.group_spacing = spacing
844
 
 
845
 
    def get_url_bits(self, data_class=None):
846
 
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
847
 
        # doesn't add "chbh" before we do.
848
 
        url_bits = BarChart.get_url_bits(self, data_class=data_class,
849
 
            skip_chbh=True)
850
 
        if self.group_spacing is not None:
851
 
            if self.bar_spacing is None:
852
 
                raise InvalidParametersException('Bar spacing is required ' \
853
 
                    'to be set when setting group spacing')
854
 
            if self.bar_width is None:
855
 
                raise InvalidParametersException('Bar width is required to ' \
856
 
                    'be set when setting bar spacing')
857
 
            url_bits.append('chbh=%i,%i,%i'
858
 
                % (self.bar_width, self.bar_spacing, self.group_spacing))
859
 
        elif self.bar_spacing is not None:
860
 
            if self.bar_width is None:
861
 
                raise InvalidParametersException('Bar width is required to ' \
862
 
                    'be set when setting bar spacing')
863
 
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
864
 
        elif self.bar_width:
865
 
            url_bits.append('chbh=%i' % self.bar_width)
866
 
        return url_bits
867
 
 
868
 
 
869
 
class GroupedHorizontalBarChart(GroupedBarChart):
870
 
 
871
 
    def type_to_url(self):
872
 
        return 'cht=bhg'
873
 
 
874
 
 
875
 
class GroupedVerticalBarChart(GroupedBarChart):
876
 
 
877
 
    def type_to_url(self):
878
 
        return 'cht=bvg'
879
 
 
880
 
    def annotated_data(self):
881
 
        for dataset in self.data:
882
 
            yield ('y', dataset)
883
 
 
884
 
 
885
 
class PieChart(Chart):
886
 
 
887
 
    def __init__(self, *args, **kwargs):
888
 
        if type(self) == PieChart:
889
 
            raise AbstractClassException('This is an abstract class')
890
 
        Chart.__init__(self, *args, **kwargs)
891
 
        self.pie_labels = []
892
 
        if self.y_range:
893
 
            warnings.warn('y_range is not used with %s.' % \
894
 
                (self.__class__.__name__))
895
 
 
896
 
    def set_pie_labels(self, labels):
897
 
        self.pie_labels = [urllib.quote(a) for a in labels]
898
 
 
899
 
    def get_url_bits(self, data_class=None):
900
 
        url_bits = Chart.get_url_bits(self, data_class=data_class)
901
 
        if self.pie_labels:
902
 
            url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
903
 
        return url_bits
904
 
 
905
 
    def annotated_data(self):
906
 
        # Datasets are all y-axis data. However, there should only be
907
 
        # one dataset for pie charts.
908
 
        for dataset in self.data:
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)
915
 
 
916
 
 
917
 
class PieChart2D(PieChart):
918
 
 
919
 
    def type_to_url(self):
920
 
        return 'cht=p'
921
 
 
922
 
 
923
 
class PieChart3D(PieChart):
924
 
 
925
 
    def type_to_url(self):
926
 
        return 'cht=p3'
927
 
 
928
 
 
929
 
class VennChart(Chart):
930
 
 
931
 
    def type_to_url(self):
932
 
        return 'cht=v'
933
 
 
934
 
    def annotated_data(self):
935
 
        for dataset in self.data:
936
 
            yield ('y', dataset)
937
 
 
938
 
 
939
 
class RadarChart(Chart):
940
 
 
941
 
    def type_to_url(self):
942
 
        return 'cht=r'
943
 
 
944
 
 
945
 
class SplineRadarChart(RadarChart):
946
 
 
947
 
    def type_to_url(self):
948
 
        return 'cht=rs'
949
 
 
950
 
 
951
 
class MapChart(Chart):
952
 
 
953
 
    def __init__(self, *args, **kwargs):
954
 
        Chart.__init__(self, *args, **kwargs)
955
 
        self.geo_area = 'world'
956
 
        self.codes = []
957
 
 
958
 
    def type_to_url(self):
959
 
        return 'cht=t'
960
 
 
961
 
    def set_codes(self, codes):
962
 
        self.codes = codes
963
 
 
964
 
    def get_url_bits(self, data_class=None):
965
 
        url_bits = Chart.get_url_bits(self, data_class=data_class)
966
 
        url_bits.append('chtm=%s' % self.geo_area)
967
 
        if self.codes:
968
 
            url_bits.append('chld=%s' % ''.join(self.codes))
969
 
        return url_bits
970
 
 
971
 
 
972
 
class GoogleOMeterChart(PieChart):
973
 
    """Inheriting from PieChart because of similar labeling"""
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
 
 
981
 
    def type_to_url(self):
982
 
        return 'cht=gom'
983
 
 
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
 
 
1017
 
class ChartGrammar(object):
1018
 
 
1019
 
    def __init__(self):
1020
 
        self.grammar = None
1021
 
        self.chart = None
1022
 
 
1023
 
    def parse(self, grammar):
1024
 
        self.grammar = grammar
1025
 
        self.chart = self.create_chart_instance()
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
 
 
1041
 
    @staticmethod
1042
 
    def get_possible_chart_types():
1043
 
        possible_charts = []
1044
 
        for cls_name in globals().keys():
1045
 
            if not cls_name.endswith('Chart'):
1046
 
                continue
1047
 
            cls = globals()[cls_name]
1048
 
            # Check if it is an abstract class
1049
 
            try:
1050
 
                a = cls(1, 1, auto_scale=False)
1051
 
                del a
1052
 
            except AbstractClassException:
1053
 
                continue
1054
 
            # Strip off "Class"
1055
 
            possible_charts.append(cls_name[:-5])
1056
 
        return possible_charts
1057
 
 
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
1062
 
        assert('w' in grammar)  # width is required
1063
 
        assert('h' in grammar)  # height is required
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)
1071
 
        types = ChartGrammar.get_possible_chart_types()
1072
 
        if chart_type not in types:
1073
 
            raise UnknownChartType('%s is an unknown chart type. Possible '
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)
1077
 
 
1078
 
    def download(self):
1079
 
        pass
1080
 
 
 
1
"""
 
2
PyGoogleChart - A complete Python wrapper for the Google Chart API
 
3
 
 
4
http://pygooglechart.slowchop.com/
 
5
 
 
6
Copyright 2007 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
 
 
30
# Helper variables and functions
 
31
# -----------------------------------------------------------------------------
 
32
 
 
33
__version__ = '0.1.2'
 
34
 
 
35
reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')
 
36
 
 
37
 
 
38
def _check_colour(colour):
 
39
    if not reo_colour.match(colour):
 
40
        raise InvalidParametersException('Colours need to be in ' \
 
41
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
 
42
            colour)
 
43
 
 
44
# Exception Classes
 
45
# -----------------------------------------------------------------------------
 
46
 
 
47
 
 
48
class PyGoogleChartException(Exception):
 
49
    pass
 
50
 
 
51
 
 
52
class DataOutOfRangeException(PyGoogleChartException):
 
53
    pass
 
54
 
 
55
 
 
56
class UnknownDataTypeException(PyGoogleChartException):
 
57
    pass
 
58
 
 
59
 
 
60
class NoDataGivenException(PyGoogleChartException):
 
61
    pass
 
62
 
 
63
 
 
64
class InvalidParametersException(PyGoogleChartException):
 
65
    pass
 
66
 
 
67
 
 
68
class BadContentTypeException(PyGoogleChartException):
 
69
    pass
 
70
 
 
71
 
 
72
# Data Classes
 
73
# -----------------------------------------------------------------------------
 
74
 
 
75
 
 
76
class Data(object):
 
77
 
 
78
    def __init__(self, data):
 
79
        assert(type(self) != Data)  # This is an abstract class
 
80
        self.data = data
 
81
 
 
82
 
 
83
class SimpleData(Data):
 
84
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
 
85
 
 
86
    def __repr__(self):
 
87
        encoded_data = []
 
88
        for data in self.data:
 
89
            sub_data = []
 
90
            for value in data:
 
91
                if value is None:
 
92
                    sub_data.append('_')
 
93
                elif value >= 0 and value <= SimpleData.max_value:
 
94
                    sub_data.append(SimpleData.enc_map[value])
 
95
                else:
 
96
                    raise DataOutOfRangeException()
 
97
            encoded_data.append(''.join(sub_data))
 
98
        return 'chd=s:' + ','.join(encoded_data)
 
99
 
 
100
    @staticmethod
 
101
    def max_value():
 
102
        return 61
 
103
 
 
104
 
 
105
class TextData(Data):
 
106
 
 
107
    def __repr__(self):
 
108
        encoded_data = []
 
109
        for data in self.data:
 
110
            sub_data = []
 
111
            for value in data:
 
112
                if value is None:
 
113
                    sub_data.append(-1)
 
114
                elif value >= 0 and value <= TextData.max_value:
 
115
                    sub_data.append(str(float(value)))
 
116
                else:
 
117
                    raise DataOutOfRangeException()
 
118
            encoded_data.append(','.join(sub_data))
 
119
        return 'chd=t:' + '|'.join(encoded_data)
 
120
 
 
121
    @staticmethod
 
122
    def max_value():
 
123
        return 100
 
124
 
 
125
 
 
126
class ExtendedData(Data):
 
127
    enc_map = \
 
128
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
 
129
 
 
130
    def __repr__(self):
 
131
        encoded_data = []
 
132
        enc_size = len(ExtendedData.enc_map)
 
133
        for data in self.data:
 
134
            sub_data = []
 
135
            for value in data:
 
136
                if value is None:
 
137
                    sub_data.append('__')
 
138
                elif value >= 0 and value <= ExtendedData.max_value:
 
139
                    first, second = divmod(int(value), enc_size)
 
140
                    sub_data.append('%s%s' % (
 
141
                        ExtendedData.enc_map[first],
 
142
                        ExtendedData.enc_map[second]))
 
143
                else:
 
144
                    raise DataOutOfRangeException( \
 
145
                        'Item #%i "%s" is out of range' % (data.index(value), \
 
146
                        value))
 
147
            encoded_data.append(''.join(sub_data))
 
148
        return 'chd=e:' + ','.join(encoded_data)
 
149
 
 
150
    @staticmethod
 
151
    def max_value():
 
152
        return 4095
 
153
 
 
154
# Axis Classes
 
155
# -----------------------------------------------------------------------------
 
156
 
 
157
 
 
158
class Axis(object):
 
159
    BOTTOM = 'x'
 
160
    TOP = 't'
 
161
    LEFT = 'y'
 
162
    RIGHT = 'r'
 
163
    TYPES = (BOTTOM, TOP, LEFT, RIGHT)
 
164
 
 
165
    def __init__(self, axis_index, axis_type, **kw):
 
166
        assert(axis_type in Axis.TYPES)
 
167
        self.has_style = False
 
168
        self.axis_index = axis_index
 
169
        self.axis_type = axis_type
 
170
        self.positions = None
 
171
 
 
172
    def set_index(self, axis_index):
 
173
        self.axis_index = axis_index
 
174
 
 
175
    def set_positions(self, positions):
 
176
        self.positions = positions
 
177
 
 
178
    def set_style(self, colour, font_size=None, alignment=None):
 
179
        _check_colour(colour)
 
180
        self.colour = colour
 
181
        self.font_size = font_size
 
182
        self.alignment = alignment
 
183
        self.has_style = True
 
184
 
 
185
    def style_to_url(self):
 
186
        bits = []
 
187
        bits.append(str(self.axis_index))
 
188
        bits.append(self.colour)
 
189
        if self.font_size is not None:
 
190
            bits.append(str(self.font_size))
 
191
            if self.alignment is not None:
 
192
                bits.append(str(self.alignment))
 
193
        return ','.join(bits)
 
194
 
 
195
    def positions_to_url(self):
 
196
        bits = []
 
197
        bits.append(str(self.axis_index))
 
198
        bits += [str(a) for a in self.positions]
 
199
        return ','.join(bits)
 
200
 
 
201
 
 
202
class LabelAxis(Axis):
 
203
 
 
204
    def __init__(self, axis_index, axis_type, values, **kwargs):
 
205
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
206
        self.values = [str(a) for a in values]
 
207
 
 
208
    def __repr__(self):
 
209
        return '%i:|%s' % (self.axis_index, '|'.join(self.values))
 
210
 
 
211
 
 
212
class RangeAxis(Axis):
 
213
 
 
214
    def __init__(self, axis_index, axis_type, low, high, **kwargs):
 
215
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
216
        self.low = low
 
217
        self.high = high
 
218
 
 
219
    def __repr__(self):
 
220
        return '%i,%s,%s' % (self.axis_index, self.low, self.high)
 
221
 
 
222
# Chart Classes
 
223
# -----------------------------------------------------------------------------
 
224
 
 
225
 
 
226
class Chart(object):
 
227
    """Abstract class for all chart types.
 
228
 
 
229
    width are height specify the dimensions of the image. title sets the title
 
230
    of the chart. legend requires a list that corresponds to datasets.
 
231
    """
 
232
 
 
233
    BASE_URL = 'http://chart.apis.google.com/chart?'
 
234
    BACKGROUND = 'bg'
 
235
    CHART = 'c'
 
236
    SOLID = 's'
 
237
    LINEAR_GRADIENT = 'lg'
 
238
    LINEAR_STRIPES = 'ls'
 
239
 
 
240
    def __init__(self, width, height, title=None, legend=None, colours=None):
 
241
        assert(type(self) != Chart)  # This is an abstract class
 
242
        assert(isinstance(width, int))
 
243
        assert(isinstance(height, int))
 
244
        self.width = width
 
245
        self.height = height
 
246
        self.data = []
 
247
        self.set_title(title)
 
248
        self.set_legend(legend)
 
249
        self.set_colours(colours)
 
250
        self.fill_types = {
 
251
            Chart.BACKGROUND: None,
 
252
            Chart.CHART: None,
 
253
        }
 
254
        self.fill_area = {
 
255
            Chart.BACKGROUND: None,
 
256
            Chart.CHART: None,
 
257
        }
 
258
#        self.axis = {
 
259
#            Axis.TOP: None,
 
260
#            Axis.BOTTOM: None,
 
261
#            Axis.LEFT: None,
 
262
#            Axis.RIGHT: None,
 
263
#        }
 
264
        self.axis = []
 
265
        self.markers = []
 
266
 
 
267
    # URL generation
 
268
    # -------------------------------------------------------------------------
 
269
 
 
270
    def get_url(self):
 
271
        url_bits = self.get_url_bits()
 
272
        return self.BASE_URL + '&'.join(url_bits)
 
273
 
 
274
    def get_url_bits(self):
 
275
        url_bits = []
 
276
        # required arguments
 
277
        url_bits.append(self.type_to_url())
 
278
        url_bits.append('chs=%ix%i' % (self.width, self.height))
 
279
        url_bits.append(self.data_to_url())
 
280
        # optional arguments
 
281
        if self.title:
 
282
            url_bits.append('chtt=%s' % self.title)
 
283
        if self.legend:
 
284
            url_bits.append('chdl=%s' % '|'.join(self.legend))
 
285
        if self.colours:
 
286
            url_bits.append('chco=%s' % ','.join(self.colours))
 
287
        ret = self.fill_to_url()
 
288
        if ret:
 
289
            url_bits.append(ret)
 
290
        ret = self.axis_to_url()
 
291
        if ret:
 
292
            url_bits.append(ret)
 
293
        if self.markers:
 
294
            url_bits.append(self.markers_to_url())
 
295
        return url_bits
 
296
 
 
297
    # Downloading
 
298
    # -------------------------------------------------------------------------
 
299
 
 
300
    def download(self, file_name):
 
301
        opener = urllib2.urlopen(self.get_url())
 
302
 
 
303
        if opener.headers['content-type'] != 'image/png':
 
304
            raise BadContentTypeException('Server responded with a ' \
 
305
                'content-type of %s' % opener.headers['content-type'])
 
306
 
 
307
        open(file_name, 'wb').write(urllib.urlopen(self.get_url()).read())
 
308
 
 
309
    # Simple settings
 
310
    # -------------------------------------------------------------------------
 
311
 
 
312
    def set_title(self, title):
 
313
        if title:
 
314
            self.title = urllib.quote(title)
 
315
        else:
 
316
            self.title = None
 
317
 
 
318
    def set_legend(self, legend):
 
319
        # legend needs to be a list, tuple or None
 
320
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
 
321
            legend is None)
 
322
        if legend:
 
323
            self.legend = [urllib.quote(a) for a in legend]
 
324
        else:
 
325
            self.legend = None
 
326
 
 
327
    # Chart colours
 
328
    # -------------------------------------------------------------------------
 
329
 
 
330
    def set_colours(self, colours):
 
331
        # colours needs to be a list, tuple or None
 
332
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
333
            colours is None)
 
334
        # make sure the colours are in the right format
 
335
        if colours:
 
336
            for col in colours:
 
337
                _check_colour(col)
 
338
        self.colours = colours
 
339
 
 
340
    # Background/Chart colours
 
341
    # -------------------------------------------------------------------------
 
342
 
 
343
    def fill_solid(self, area, colour):
 
344
        assert(area in (Chart.BACKGROUND, Chart.CHART))
 
345
        _check_colour(colour)
 
346
        self.fill_area[area] = colour
 
347
        self.fill_types[area] = Chart.SOLID
 
348
 
 
349
    def _check_fill_linear(self, angle, *args):
 
350
        assert(isinstance(args, list) or isinstance(args, tuple))
 
351
        assert(angle >= 0 and angle <= 90)
 
352
        assert(len(args) % 2 == 0)
 
353
        args = list(args)  # args is probably a tuple and we need to mutate
 
354
        for a in xrange(len(args) / 2):
 
355
            col = args[a * 2]
 
356
            offset = args[a * 2 + 1]
 
357
            _check_colour(col)
 
358
            assert(offset >= 0 and offset <= 1)
 
359
            args[a * 2 + 1] = str(args[a * 2 + 1])
 
360
        return args
 
361
 
 
362
    def fill_linear_gradient(self, area, angle, *args):
 
363
        assert(area in (Chart.BACKGROUND, Chart.CHART))
 
364
        args = self._check_fill_linear(angle, *args)
 
365
        self.fill_types[area] = Chart.LINEAR_GRADIENT
 
366
        self.fill_area[area] = ','.join([str(angle)] + args)
 
367
 
 
368
    def fill_linear_stripes(self, area, angle, *args):
 
369
        assert(area in (Chart.BACKGROUND, Chart.CHART))
 
370
        args = self._check_fill_linear(angle, *args)
 
371
        self.fill_types[area] = Chart.LINEAR_STRIPES
 
372
        self.fill_area[area] = ','.join([str(angle)] + args)
 
373
 
 
374
    def fill_to_url(self):
 
375
        areas = []
 
376
        for area in (Chart.BACKGROUND, Chart.CHART):
 
377
            if self.fill_types[area]:
 
378
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
 
379
                    self.fill_area[area]))
 
380
        if areas:
 
381
            return 'chf=' + '|'.join(areas)
 
382
 
 
383
    # Data
 
384
    # -------------------------------------------------------------------------
 
385
 
 
386
    def data_class_detection(self, data):
 
387
        """
 
388
        Detects and returns the data type required based on the range of the
 
389
        data given. The data given must be lists of numbers within a list.
 
390
        """
 
391
        assert(isinstance(data, list) or isinstance(data, tuple))
 
392
        max_value = None
 
393
        for a in data:
 
394
            assert(isinstance(a, list) or isinstance(a, tuple))
 
395
            if max_value is None or max(a) > max_value:
 
396
                max_value = max(a)
 
397
        for data_class in (SimpleData, TextData, ExtendedData):
 
398
            if max_value <= data_class.max_value():
 
399
                return data_class
 
400
        raise DataOutOfRangeException()
 
401
 
 
402
    def add_data(self, data):
 
403
        self.data.append(data)
 
404
        return len(self.data) - 1  # return the "index" of the data set
 
405
 
 
406
    def data_to_url(self, data_class=None):
 
407
        if not data_class:
 
408
            data_class = self.data_class_detection(self.data)
 
409
        if not issubclass(data_class, Data):
 
410
            raise UnknownDataTypeException()
 
411
        return repr(data_class(self.data))
 
412
 
 
413
    # Axis Labels
 
414
    # -------------------------------------------------------------------------
 
415
 
 
416
    def set_axis_labels(self, axis_type, values):
 
417
        assert(axis_type in Axis.TYPES)
 
418
        values = [ urllib.quote(a) for a in values ]
 
419
        axis_index = len(self.axis)
 
420
        axis = LabelAxis(axis_index, axis_type, values)
 
421
        self.axis.append(axis)
 
422
        return axis_index
 
423
 
 
424
    def set_axis_range(self, axis_type, low, high):
 
425
        assert(axis_type in Axis.TYPES)
 
426
        axis_index = len(self.axis)
 
427
        axis = RangeAxis(axis_index, axis_type, low, high)
 
428
        self.axis.append(axis)
 
429
        return axis_index
 
430
 
 
431
    def set_axis_positions(self, axis_index, positions):
 
432
        try:
 
433
            self.axis[axis_index].set_positions(positions)
 
434
        except IndexError:
 
435
            raise InvalidParametersException('Axis index %i has not been ' \
 
436
                'created' % axis)
 
437
 
 
438
    def set_axis_style(self, axis_index, colour, font_size=None, \
 
439
            alignment=None):
 
440
        try:
 
441
            self.axis[axis_index].set_style(colour, font_size, alignment)
 
442
        except IndexError:
 
443
            raise InvalidParametersException('Axis index %i has not been ' \
 
444
                'created' % axis)
 
445
 
 
446
    def axis_to_url(self):
 
447
        available_axis = []
 
448
        label_axis = []
 
449
        range_axis = []
 
450
        positions = []
 
451
        styles = []
 
452
        index = -1
 
453
        for axis in self.axis:
 
454
            available_axis.append(axis.axis_type)
 
455
            if isinstance(axis, RangeAxis):
 
456
                range_axis.append(repr(axis))
 
457
            if isinstance(axis, LabelAxis):
 
458
                label_axis.append(repr(axis))
 
459
            if axis.positions:
 
460
                positions.append(axis.positions_to_url())
 
461
            if axis.has_style:
 
462
                styles.append(axis.style_to_url())
 
463
        if not available_axis:
 
464
            return
 
465
        url_bits = []
 
466
        url_bits.append('chxt=%s' % ','.join(available_axis))
 
467
        if label_axis:
 
468
            url_bits.append('chxl=%s' % '|'.join(label_axis))
 
469
        if range_axis:
 
470
            url_bits.append('chxr=%s' % '|'.join(range_axis))
 
471
        if positions:
 
472
            url_bits.append('chxp=%s' % '|'.join(positions))
 
473
        if styles:
 
474
            url_bits.append('chxs=%s' % '|'.join(styles))
 
475
        return '&'.join(url_bits)
 
476
 
 
477
    # Markers, Ranges and Fill area (chm)
 
478
    # -------------------------------------------------------------------------
 
479
 
 
480
    def markers_to_url(self):
 
481
        return 'chm=%s' % '|'.join([','.join(a) for a in self.markers])
 
482
 
 
483
    def add_marker(self, index, point, marker_type, colour, size):
 
484
        self.markers.append((marker_type, colour, str(index), str(point), \
 
485
            str(size)))
 
486
 
 
487
    def add_horizontal_range(self, colour, start, stop):
 
488
        self.markers.append(('r', colour, '1', str(start), str(stop)))
 
489
 
 
490
    def add_vertical_range(self, colour, start, stop):
 
491
        self.markers.append(('R', colour, '1', str(start), str(stop)))
 
492
 
 
493
    def add_fill_range(self, colour, index_start, index_end):
 
494
        self.markers.append(('b', colour, str(index_start), str(index_end), \
 
495
            '1'))
 
496
 
 
497
    def add_fill_simple(self, colour):
 
498
        self.markers.append(('B', colour, '1', '1', '1'))
 
499
 
 
500
 
 
501
class ScatterChart(Chart):
 
502
 
 
503
    def __init__(self, *args, **kwargs):
 
504
        Chart.__init__(self, *args, **kwargs)
 
505
 
 
506
    def type_to_url(self):
 
507
        return 'cht=s'
 
508
 
 
509
 
 
510
class LineChart(Chart):
 
511
 
 
512
    def __init__(self, *args, **kwargs):
 
513
        assert(type(self) != LineChart)  # This is an abstract class
 
514
        Chart.__init__(self, *args, **kwargs)
 
515
        self.line_styles = {}
 
516
        self.grid = None
 
517
 
 
518
    def set_line_style(self, index, thickness=1, line_segment=None, \
 
519
            blank_segment=None):
 
520
        value = []
 
521
        value.append(str(thickness))
 
522
        if line_segment:
 
523
            value.append(str(line_segment))
 
524
            value.append(str(blank_segment))
 
525
        self.line_styles[index] = value
 
526
 
 
527
    def set_grid(self, x_step, y_step, line_segment=1, \
 
528
            blank_segment=0):
 
529
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
 
530
            blank_segment)
 
531
 
 
532
    def get_url_bits(self):
 
533
        url_bits = Chart.get_url_bits(self)
 
534
        if self.line_styles:
 
535
            style = []
 
536
            # for index, values in self.line_style.items():
 
537
            for index in xrange(max(self.line_styles) + 1):
 
538
                if index in self.line_styles:
 
539
                    values = self.line_styles[index]
 
540
                else:
 
541
                    values = ('1', )
 
542
                style.append(','.join(values))
 
543
            url_bits.append('chls=%s' % '|'.join(style))
 
544
        if self.grid:
 
545
            url_bits.append('chg=%s' % self.grid)
 
546
        return url_bits
 
547
 
 
548
 
 
549
class SimpleLineChart(LineChart):
 
550
 
 
551
    def type_to_url(self):
 
552
        return 'cht=lc'
 
553
 
 
554
 
 
555
class XYLineChart(LineChart):
 
556
 
 
557
    def type_to_url(self):
 
558
        return 'cht=lxy'
 
559
 
 
560
 
 
561
class BarChart(Chart):
 
562
 
 
563
    def __init__(self, *args, **kwargs):
 
564
        assert(type(self) != BarChart)  # This is an abstract class
 
565
        Chart.__init__(self, *args, **kwargs)
 
566
        self.bar_width = None
 
567
 
 
568
    def set_bar_width(self, bar_width):
 
569
        self.bar_width = bar_width
 
570
 
 
571
    def get_url_bits(self):
 
572
        url_bits = Chart.get_url_bits(self)
 
573
        url_bits.append('chbh=%i' % self.bar_width)
 
574
        return url_bits
 
575
 
 
576
 
 
577
class StackedHorizontalBarChart(BarChart):
 
578
 
 
579
    def type_to_url(self):
 
580
        return 'cht=bhs'
 
581
 
 
582
 
 
583
class StackedVerticalBarChart(BarChart):
 
584
 
 
585
    def type_to_url(self):
 
586
        return 'cht=bvs'
 
587
 
 
588
 
 
589
class GroupedBarChart(BarChart):
 
590
 
 
591
    def __init__(self, *args, **kwargs):
 
592
        assert(type(self) != GroupedBarChart)  # This is an abstract class
 
593
        BarChart.__init__(self, *args, **kwargs)
 
594
        self.bar_spacing = None
 
595
 
 
596
    def set_bar_spacing(self, spacing):
 
597
        self.bar_spacing = spacing
 
598
 
 
599
    def get_url_bits(self):
 
600
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
 
601
        # doesn't add "chbh" before we do.
 
602
        url_bits = Chart.get_url_bits(self)
 
603
        if self.bar_spacing is not None:
 
604
            if self.bar_width is None:
 
605
                raise InvalidParametersException('Bar width is required to ' \
 
606
                    'be set when setting spacing')
 
607
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
 
608
        else:
 
609
            url_bits.append('chbh=%i' % self.bar_width)
 
610
        return url_bits
 
611
 
 
612
 
 
613
class GroupedHorizontalBarChart(GroupedBarChart):
 
614
 
 
615
    def type_to_url(self):
 
616
        return 'cht=bhg'
 
617
 
 
618
 
 
619
class GroupedVerticalBarChart(GroupedBarChart):
 
620
 
 
621
    def type_to_url(self):
 
622
        return 'cht=bvg'
 
623
 
 
624
 
 
625
class PieChart(Chart):
 
626
 
 
627
    def __init__(self, *args, **kwargs):
 
628
        assert(type(self) != PieChart)  # This is an abstract class
 
629
        Chart.__init__(self, *args, **kwargs)
 
630
        self.pie_labels = []
 
631
 
 
632
    def set_pie_labels(self, labels):
 
633
        self.pie_labels = labels
 
634
 
 
635
    def get_url_bits(self):
 
636
        url_bits = Chart.get_url_bits(self)
 
637
        if self.pie_labels:
 
638
            url_bits.append('chl=%s' % '|'.join(self.pie_labels))
 
639
        return url_bits
 
640
 
 
641
 
 
642
class PieChart2D(PieChart):
 
643
 
 
644
    def type_to_url(self):
 
645
        return 'cht=p'
 
646
 
 
647
 
 
648
class PieChart3D(PieChart):
 
649
 
 
650
    def type_to_url(self):
 
651
        return 'cht=p3'
 
652
 
 
653
 
 
654
class VennChart(Chart):
 
655
 
 
656
    def type_to_url(self):
 
657
        return 'cht=v'
 
658
 
 
659
 
 
660
def test():
 
661
    chart = GroupedVerticalBarChart(320, 200)
 
662
    chart = PieChart2D(320, 200)
 
663
    chart = ScatterChart(320, 200)
 
664
    chart = SimpleLineChart(320, 200)
 
665
    sine_data = [math.sin(float(a) / 10) * 2000 + 2000 for a in xrange(100)]
 
666
    random_data = [a * random.random() * 30 for a in xrange(40)]
 
667
    random_data2 = [random.random() * 4000 for a in xrange(10)]
 
668
#    chart.set_bar_width(50)
 
669
#    chart.set_bar_spacing(0)
 
670
    chart.add_data(sine_data)
 
671
    chart.add_data(random_data)
 
672
    chart.add_data(random_data2)
 
673
#    chart.set_line_style(1, thickness=2)
 
674
#    chart.set_line_style(2, line_segment=10, blank_segment=5)
 
675
#    chart.set_title('heloooo')
 
676
#    chart.set_legend(('sine wave', 'random * x'))
 
677
#    chart.set_colours(('ee2000', 'DDDDAA', 'fF03f2'))
 
678
#    chart.fill_solid(Chart.BACKGROUND, '123456')
 
679
#    chart.fill_linear_gradient(Chart.CHART, 20, '004070', 1, '300040', 0,
 
680
#        'aabbcc00', 0.5)
 
681
#    chart.fill_linear_stripes(Chart.CHART, 20, '204070', .2, '300040', .2,
 
682
#        'aabbcc00', 0.2)
 
683
    axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
 
684
    axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
 
685
    axis_left_index = chart.set_axis_range(Axis.LEFT, 0, 10)
 
686
    axis_right_index = chart.set_axis_range(Axis.RIGHT, 5, 30)
 
687
    axis_bottom_index = chart.set_axis_labels(Axis.BOTTOM, [1, 25, 95])
 
688
    chart.set_axis_positions(axis_bottom_index, [1, 25, 95])
 
689
    chart.set_axis_style(axis_bottom_index, '003050', 15)
 
690
 
 
691
#    chart.set_pie_labels(('apples', 'oranges', 'bananas'))
 
692
 
 
693
#    chart.set_grid(10, 10)
 
694
 
 
695
#    for a in xrange(0, 100, 10):
 
696
#        chart.add_marker(1, a, 'a', 'AACA20', 10)
 
697
 
 
698
    chart.add_horizontal_range('00A020', .2, .5)
 
699
    chart.add_vertical_range('00c030', .2, .4)
 
700
 
 
701
    chart.add_fill_simple('303030A0')
 
702
 
 
703
    chart.download('test.png')
 
704
 
 
705
    url = chart.get_url()
 
706
    print url
 
707
    if 0:
 
708
        data = urllib.urlopen(chart.get_url()).read()
 
709
        open('meh.png', 'wb').write(data)
 
710
        os.system('start meh.png')
 
711
 
 
712
 
 
713
if __name__ == '__main__':
 
714
    test()