/+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: Gustav Hartvigsson
  • Date: 2011-01-03 21:57:12 UTC
  • Revision ID: gustav.hartvigsson@gmail.com-20110103215712-1yeiw9tl7oiwh8w1
forgot the the the images in the examples folder...

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 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 = [urllib.quote(a) for a in 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()
 
1
"""
 
2
pygooglechart - A complete Python wrapper for the Google Chart API
 
3
 
 
4
http://pygooglechart.slowchop.com/
 
5
 
 
6
Copyright 2007-2009 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 urllib.request, urllib.error
 
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.3.0'
 
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
class UnknownCountryCodeException(PyGoogleChartException):
 
89
    pass
 
90
 
 
91
# Data Classes
 
92
# -----------------------------------------------------------------------------
 
93
 
 
94
 
 
95
class Data(object):
 
96
 
 
97
    def __init__(self, data):
 
98
        if type(self) == Data:
 
99
            raise AbstractClassException('This is an abstract class')
 
100
        self.data = data
 
101
 
 
102
    @classmethod
 
103
    def float_scale_value(cls, value, range):
 
104
        lower, upper = range
 
105
        assert(upper > lower)
 
106
        scaled = (value - lower) * (cls.max_value / (upper - lower))
 
107
        return scaled
 
108
 
 
109
    @classmethod
 
110
    def clip_value(cls, value):
 
111
        return max(0, min(value, cls.max_value))
 
112
 
 
113
    @classmethod
 
114
    def int_scale_value(cls, value, range):
 
115
        return int(round(cls.float_scale_value(value, range)))
 
116
 
 
117
    @classmethod
 
118
    def scale_value(cls, value, range):
 
119
        scaled = cls.int_scale_value(value, range)
 
120
        clipped = cls.clip_value(scaled)
 
121
        Data.check_clip(scaled, clipped)
 
122
        return clipped
 
123
 
 
124
    @staticmethod
 
125
    def check_clip(scaled, clipped):
 
126
        if clipped != scaled:
 
127
            warnings.warn('One or more of of your data points has been '
 
128
                'clipped because it is out of range.')
 
129
 
 
130
 
 
131
class SimpleData(Data):
 
132
 
 
133
    max_value = 61
 
134
    enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
 
135
 
 
136
    def __repr__(self):
 
137
        encoded_data = []
 
138
        for data in self.data:
 
139
            sub_data = []
 
140
            for value in data:
 
141
                if value is None:
 
142
                    sub_data.append('_')
 
143
                elif value >= 0 and value <= self.max_value:
 
144
                    sub_data.append(SimpleData.enc_map[value])
 
145
                else:
 
146
                    raise DataOutOfRangeException('cannot encode value: %d'
 
147
                                                  % value)
 
148
            encoded_data.append(''.join(sub_data))
 
149
        return 'chd=s:' + ','.join(encoded_data)
 
150
 
 
151
 
 
152
class TextData(Data):
 
153
 
 
154
    max_value = 100
 
155
 
 
156
    def __repr__(self):
 
157
        encoded_data = []
 
158
        for data in self.data:
 
159
            sub_data = []
 
160
            for value in data:
 
161
                if value is None:
 
162
                    sub_data.append(-1)
 
163
                elif value >= 0 and value <= self.max_value:
 
164
                    sub_data.append("%.1f" % float(value))
 
165
                else:
 
166
                    raise DataOutOfRangeException()
 
167
            encoded_data.append(','.join(sub_data))
 
168
        return 'chd=t:' + '%7c'.join(encoded_data)
 
169
 
 
170
    @classmethod
 
171
    def scale_value(cls, value, range):
 
172
        # use float values instead of integers because we don't need an encode
 
173
        # map index
 
174
        scaled = cls.float_scale_value(value, range)
 
175
        clipped = cls.clip_value(scaled)
 
176
        Data.check_clip(scaled, clipped)
 
177
        return clipped
 
178
 
 
179
 
 
180
class ExtendedData(Data):
 
181
 
 
182
    max_value = 4095
 
183
    enc_map = \
 
184
        'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.'
 
185
 
 
186
    def __repr__(self):
 
187
        encoded_data = []
 
188
        enc_size = len(ExtendedData.enc_map)
 
189
        for data in self.data:
 
190
            sub_data = []
 
191
            for value in data:
 
192
                if value is None:
 
193
                    sub_data.append('__')
 
194
                elif value >= 0 and value <= self.max_value:
 
195
                    first, second = divmod(int(value), enc_size)
 
196
                    sub_data.append('%s%s' % (
 
197
                        ExtendedData.enc_map[first],
 
198
                        ExtendedData.enc_map[second]))
 
199
                else:
 
200
                    raise DataOutOfRangeException( \
 
201
                        'Item #%i "%s" is out of range' % (data.index(value), \
 
202
                        value))
 
203
            encoded_data.append(''.join(sub_data))
 
204
        return 'chd=e:' + ','.join(encoded_data)
 
205
 
 
206
 
 
207
# Axis Classes
 
208
# -----------------------------------------------------------------------------
 
209
 
 
210
 
 
211
class Axis(object):
 
212
 
 
213
    BOTTOM = 'x'
 
214
    TOP = 't'
 
215
    LEFT = 'y'
 
216
    RIGHT = 'r'
 
217
    TYPES = (BOTTOM, TOP, LEFT, RIGHT)
 
218
 
 
219
    def __init__(self, axis_index, axis_type, **kw):
 
220
        assert(axis_type in Axis.TYPES)
 
221
        self.has_style = False
 
222
        self.axis_index = axis_index
 
223
        self.axis_type = axis_type
 
224
        self.positions = None
 
225
 
 
226
    def set_index(self, axis_index):
 
227
        self.axis_index = axis_index
 
228
 
 
229
    def set_positions(self, positions):
 
230
        self.positions = positions
 
231
 
 
232
    def set_style(self, colour, font_size=None, alignment=None):
 
233
        _check_colour(colour)
 
234
        self.colour = colour
 
235
        self.font_size = font_size
 
236
        self.alignment = alignment
 
237
        self.has_style = True
 
238
 
 
239
    def style_to_url(self):
 
240
        bits = []
 
241
        bits.append(str(self.axis_index))
 
242
        bits.append(self.colour)
 
243
        if self.font_size is not None:
 
244
            bits.append(str(self.font_size))
 
245
            if self.alignment is not None:
 
246
                bits.append(str(self.alignment))
 
247
        return ','.join(bits)
 
248
 
 
249
    def positions_to_url(self):
 
250
        bits = []
 
251
        bits.append(str(self.axis_index))
 
252
        bits += [str(a) for a in self.positions]
 
253
        return ','.join(bits)
 
254
 
 
255
 
 
256
class LabelAxis(Axis):
 
257
 
 
258
    def __init__(self, axis_index, axis_type, values, **kwargs):
 
259
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
260
        self.values = [str(a) for a in values]
 
261
 
 
262
    def __repr__(self):
 
263
        return '%i:%%7c%s' % (self.axis_index, '%7c'.join(self.values))
 
264
 
 
265
 
 
266
class RangeAxis(Axis):
 
267
 
 
268
    def __init__(self, axis_index, axis_type, low, high, **kwargs):
 
269
        Axis.__init__(self, axis_index, axis_type, **kwargs)
 
270
        self.low = low
 
271
        self.high = high
 
272
 
 
273
    def __repr__(self):
 
274
        return '%i,%s,%s' % (self.axis_index, self.low, self.high)
 
275
 
 
276
# Chart Classes
 
277
# -----------------------------------------------------------------------------
 
278
 
 
279
 
 
280
class Chart(object):
 
281
    """Abstract class for all chart types.
 
282
 
 
283
    width are height specify the dimensions of the image. title sets the title
 
284
    of the chart. legend requires a list that corresponds to datasets.
 
285
    """
 
286
 
 
287
    BASE_URL = 'http://chart.apis.google.com/chart'
 
288
    BACKGROUND = 'bg'
 
289
    CHART = 'c'
 
290
    ALPHA = 'a'
 
291
    VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA)
 
292
    SOLID = 's'
 
293
    LINEAR_GRADIENT = 'lg'
 
294
    LINEAR_STRIPES = 'ls'
 
295
 
 
296
    def __init__(self, width, height, title=None, legend=None, colours=None,
 
297
            auto_scale=True, x_range=None, y_range=None,
 
298
            colours_within_series=None):
 
299
        if type(self) == Chart:
 
300
            raise AbstractClassException('This is an abstract class')
 
301
        assert(isinstance(width, int))
 
302
        assert(isinstance(height, int))
 
303
        self.width = width
 
304
        self.height = height
 
305
        self.data = []
 
306
        self.set_title(title)
 
307
        self.set_title_style(None, None)
 
308
        self.set_legend(legend)
 
309
        self.set_legend_position(None)
 
310
        self.set_colours(colours)
 
311
        self.set_colours_within_series(colours_within_series)
 
312
 
 
313
        # Data for scaling.
 
314
        self.auto_scale = auto_scale  # Whether to automatically scale data
 
315
        self.x_range = x_range  # (min, max) x-axis range for scaling
 
316
        self.y_range = y_range  # (min, max) y-axis range for scaling
 
317
        self.scaled_data_class = None
 
318
        self.scaled_x_range = None
 
319
        self.scaled_y_range = None
 
320
 
 
321
        self.fill_types = {
 
322
            Chart.BACKGROUND: None,
 
323
            Chart.CHART: None,
 
324
            Chart.ALPHA: None,
 
325
        }
 
326
        self.fill_area = {
 
327
            Chart.BACKGROUND: None,
 
328
            Chart.CHART: None,
 
329
            Chart.ALPHA: None,
 
330
        }
 
331
        self.axis = []
 
332
        self.markers = []
 
333
        self.line_styles = {}
 
334
        self.grid = None
 
335
        self.title_colour = None
 
336
        self.title_font_size = None
 
337
 
 
338
    # URL generation
 
339
    # -------------------------------------------------------------------------
 
340
        
 
341
    def get_url(self, data_class=None):
 
342
        return self.BASE_URL + '?' + self.get_url_extension(data_class)
 
343
    
 
344
    def get_url_extension(self, data_class=None):
 
345
        url_bits = self.get_url_bits(data_class=data_class)
 
346
        return '&'.join(url_bits)
 
347
 
 
348
    def get_url_bits(self, data_class=None):
 
349
        url_bits = []
 
350
        # required arguments
 
351
        url_bits.append(self.type_to_url())
 
352
        url_bits.append('chs=%ix%i' % (self.width, self.height))
 
353
        url_bits.append(self.data_to_url(data_class=data_class))
 
354
        # optional arguments
 
355
        if self.title:
 
356
            url_bits.append('chtt=%s' % self.title)
 
357
        if self.title_colour and self.title_font_size:
 
358
            url_bits.append('chts=%s,%s' % (self.title_colour, \
 
359
                self.title_font_size))
 
360
        if self.legend:
 
361
            url_bits.append('chdl=%s' % '%7c'.join(self.legend))
 
362
        if self.legend_position:
 
363
            url_bits.append('chdlp=%s' % (self.legend_position))
 
364
        if self.colours:
 
365
            url_bits.append('chco=%s' % ','.join(self.colours))            
 
366
        if self.colours_within_series:
 
367
            url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series))
 
368
        ret = self.fill_to_url()
 
369
        if ret:
 
370
            url_bits.append(ret)
 
371
        ret = self.axis_to_url()
 
372
        if ret:
 
373
            url_bits.append(ret)                    
 
374
        if self.markers:
 
375
            url_bits.append(self.markers_to_url())        
 
376
        if self.line_styles:
 
377
            style = []
 
378
            for index in range(max(self.line_styles) + 1):
 
379
                if index in self.line_styles:
 
380
                    values = self.line_styles[index]
 
381
                else:
 
382
                    values = ('1', )
 
383
                style.append(','.join(values))
 
384
            url_bits.append('chls=%s' % '%7c'.join(style))
 
385
        if self.grid:
 
386
            url_bits.append('chg=%s' % self.grid)
 
387
        return url_bits
 
388
 
 
389
    # Downloading
 
390
    # -------------------------------------------------------------------------
 
391
 
 
392
    def download(self, file_name, use_post=True):
 
393
        if use_post:
 
394
            opener = urllib.request.urlopen(self.BASE_URL, self.get_url_extension())
 
395
        else:
 
396
            opener = urllib.request.urlopen(self.get_url())
 
397
 
 
398
        if opener.headers['content-type'] != 'image/png':
 
399
            raise BadContentTypeException('Server responded with a ' \
 
400
                'content-type of %s' % opener.headers['content-type'])
 
401
 
 
402
        open(file_name, 'wb').write(opener.read())
 
403
 
 
404
    # Simple settings
 
405
    # -------------------------------------------------------------------------
 
406
 
 
407
    def set_title(self, title):
 
408
        if title:
 
409
            self.title = urllib.parse.quote(title)
 
410
        else:
 
411
            self.title = None
 
412
 
 
413
    def set_title_style(self, colour=None, font_size=None):
 
414
        if not colour is None:
 
415
            _check_colour(colour)
 
416
        if not colour and not font_size:
 
417
            return
 
418
        self.title_colour = colour or '333333'
 
419
        self.title_font_size = font_size or 13.5
 
420
 
 
421
    def set_legend(self, legend):
 
422
        """legend needs to be a list, tuple or None"""
 
423
        assert(isinstance(legend, list) or isinstance(legend, tuple) or
 
424
            legend is None)
 
425
        if legend:
 
426
            self.legend = [urllib.parse.quote(a) for a in legend]
 
427
        else:
 
428
            self.legend = None
 
429
 
 
430
    def set_legend_position(self, legend_position):
 
431
        if legend_position:
 
432
            self.legend_position = urllib.parse.quote(legend_position)
 
433
        else:    
 
434
            self.legend_position = None
 
435
 
 
436
    # Chart colours
 
437
    # -------------------------------------------------------------------------
 
438
 
 
439
    def set_colours(self, colours):
 
440
        # colours needs to be a list, tuple or None
 
441
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
442
            colours is None)
 
443
        # make sure the colours are in the right format
 
444
        if colours:
 
445
            for col in colours:
 
446
                _check_colour(col)
 
447
        self.colours = colours
 
448
 
 
449
    def set_colours_within_series(self, colours):
 
450
        # colours needs to be a list, tuple or None
 
451
        assert(isinstance(colours, list) or isinstance(colours, tuple) or
 
452
            colours is None)
 
453
        # make sure the colours are in the right format
 
454
        if colours:
 
455
            for col in colours:
 
456
                _check_colour(col)
 
457
        self.colours_within_series = colours        
 
458
 
 
459
    # Background/Chart colours
 
460
    # -------------------------------------------------------------------------
 
461
 
 
462
    def fill_solid(self, area, colour):
 
463
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
464
        _check_colour(colour)
 
465
        self.fill_area[area] = colour
 
466
        self.fill_types[area] = Chart.SOLID
 
467
 
 
468
    def _check_fill_linear(self, angle, *args):
 
469
        assert(isinstance(args, list) or isinstance(args, tuple))
 
470
        assert(angle >= 0 and angle <= 90)
 
471
        assert(len(args) % 2 == 0)
 
472
        args = list(args)  # args is probably a tuple and we need to mutate
 
473
        for a in range(int(len(args) / 2)):
 
474
            col = args[a * 2]
 
475
            offset = args[a * 2 + 1]
 
476
            _check_colour(col)
 
477
            assert(offset >= 0 and offset <= 1)
 
478
            args[a * 2 + 1] = str(args[a * 2 + 1])
 
479
        return args
 
480
 
 
481
    def fill_linear_gradient(self, area, angle, *args):
 
482
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
483
        args = self._check_fill_linear(angle, *args)
 
484
        self.fill_types[area] = Chart.LINEAR_GRADIENT
 
485
        self.fill_area[area] = ','.join([str(angle)] + args)
 
486
 
 
487
    def fill_linear_stripes(self, area, angle, *args):
 
488
        assert(area in Chart.VALID_SOLID_FILL_TYPES)
 
489
        args = self._check_fill_linear(angle, *args)
 
490
        self.fill_types[area] = Chart.LINEAR_STRIPES
 
491
        self.fill_area[area] = ','.join([str(angle)] + args)
 
492
 
 
493
    def fill_to_url(self):
 
494
        areas = []
 
495
        for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA):
 
496
            if self.fill_types[area]:
 
497
                areas.append('%s,%s,%s' % (area, self.fill_types[area], \
 
498
                    self.fill_area[area]))
 
499
        if areas:
 
500
            return 'chf=' + '%7c'.join(areas)
 
501
 
 
502
    # Data
 
503
    # -------------------------------------------------------------------------
 
504
 
 
505
    def data_class_detection(self, data):
 
506
        """Determines the appropriate data encoding type to give satisfactory
 
507
        resolution (http://code.google.com/apis/chart/#chart_data).
 
508
        """
 
509
        assert(isinstance(data, list) or isinstance(data, tuple))
 
510
        if not isinstance(self, (LineChart, BarChart, ScatterChart)):
 
511
            # From the link above:
 
512
            #   Simple encoding is suitable for all other types of chart
 
513
            #   regardless of size.
 
514
            return SimpleData
 
515
        elif self.height < 100:
 
516
            # The link above indicates that line and bar charts less
 
517
            # than 300px in size can be suitably represented with the
 
518
            # simple encoding. I've found that this isn't sufficient,
 
519
            # e.g. examples/line-xy-circle.png. Let's try 100px.
 
520
            return SimpleData
 
521
        else:
 
522
            return ExtendedData
 
523
 
 
524
    def _filter_none(self, data):
 
525
        return [r for r in data if r is not None]
 
526
 
 
527
    def data_x_range(self):
 
528
        """Return a 2-tuple giving the minimum and maximum x-axis
 
529
        data range.
 
530
        """
 
531
        try:
 
532
            lower = min([min(self._filter_none(s))
 
533
                         for type, s in self.annotated_data()
 
534
                         if type == 'x'])
 
535
            upper = max([max(self._filter_none(s))
 
536
                         for type, s in self.annotated_data()
 
537
                         if type == 'x'])
 
538
            return (lower, upper)
 
539
        except ValueError:
 
540
            return None     # no x-axis datasets
 
541
 
 
542
    def data_y_range(self):
 
543
        """Return a 2-tuple giving the minimum and maximum y-axis
 
544
        data range.
 
545
        """
 
546
        try:
 
547
            lower = min([min(self._filter_none(s))
 
548
                         for type, s in self.annotated_data()
 
549
                         if type == 'y'])
 
550
            upper = max([max(self._filter_none(s)) + 1
 
551
                         for type, s in self.annotated_data()
 
552
                         if type == 'y'])
 
553
            return (lower, upper)
 
554
        except ValueError:
 
555
            return None     # no y-axis datasets
 
556
 
 
557
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
558
        """Scale `self.data` as appropriate for the given data encoding
 
559
        (data_class) and return it.
 
560
 
 
561
        An optional `y_range` -- a 2-tuple (lower, upper) -- can be
 
562
        given to specify the y-axis bounds. If not given, the range is
 
563
        inferred from the data: (0, <max-value>) presuming no negative
 
564
        values, or (<min-value>, <max-value>) if there are negative
 
565
        values.  `self.scaled_y_range` is set to the actual lower and
 
566
        upper scaling range.
 
567
 
 
568
        Ditto for `x_range`. Note that some chart types don't have x-axis
 
569
        data.
 
570
        """
 
571
        self.scaled_data_class = data_class
 
572
 
 
573
        # Determine the x-axis range for scaling.
 
574
        if x_range is None:
 
575
            x_range = self.data_x_range()
 
576
            if x_range and x_range[0] > 0:
 
577
                x_range = (x_range[0], x_range[1])
 
578
        self.scaled_x_range = x_range
 
579
 
 
580
        # Determine the y-axis range for scaling.
 
581
        if y_range is None:
 
582
            y_range = self.data_y_range()
 
583
            if y_range and y_range[0] > 0:
 
584
                y_range = (y_range[0], y_range[1])
 
585
        self.scaled_y_range = y_range
 
586
 
 
587
        scaled_data = []
 
588
        for type, dataset in self.annotated_data():
 
589
            if type == 'x':
 
590
                scale_range = x_range
 
591
            elif type == 'y':
 
592
                scale_range = y_range
 
593
            elif type == 'marker-size':
 
594
                scale_range = (0, max(dataset))
 
595
            scaled_dataset = []
 
596
            for v in dataset:
 
597
                if v is None:
 
598
                    scaled_dataset.append(None)
 
599
                else:
 
600
                    scaled_dataset.append(
 
601
                        data_class.scale_value(v, scale_range))
 
602
            scaled_data.append(scaled_dataset)
 
603
        return scaled_data
 
604
 
 
605
    def add_data(self, data):
 
606
        self.data.append(data)
 
607
        return len(self.data) - 1  # return the "index" of the data set
 
608
 
 
609
    def data_to_url(self, data_class=None):
 
610
        if not data_class:
 
611
            data_class = self.data_class_detection(self.data)
 
612
        if not issubclass(data_class, Data):
 
613
            raise UnknownDataTypeException()
 
614
        if self.auto_scale:
 
615
            data = self.scaled_data(data_class, self.x_range, self.y_range)
 
616
        else:
 
617
            data = self.data
 
618
        return repr(data_class(data))
 
619
 
 
620
    def annotated_data(self):
 
621
        for dataset in self.data:
 
622
            yield ('x', dataset)
 
623
 
 
624
    # Axis Labels
 
625
    # -------------------------------------------------------------------------
 
626
 
 
627
    def set_axis_labels(self, axis_type, values):
 
628
        assert(axis_type in Axis.TYPES)
 
629
        values = [urllib.parse.quote(str(a)) for a in values]
 
630
        axis_index = len(self.axis)
 
631
        axis = LabelAxis(axis_index, axis_type, values)
 
632
        self.axis.append(axis)
 
633
        return axis_index
 
634
 
 
635
    def set_axis_range(self, axis_type, low, high):
 
636
        assert(axis_type in Axis.TYPES)
 
637
        axis_index = len(self.axis)
 
638
        axis = RangeAxis(axis_index, axis_type, low, high)
 
639
        self.axis.append(axis)
 
640
        return axis_index
 
641
 
 
642
    def set_axis_positions(self, axis_index, positions):
 
643
        try:
 
644
            self.axis[axis_index].set_positions(positions)
 
645
        except IndexError:
 
646
            raise InvalidParametersException('Axis index %i has not been ' \
 
647
                'created' % axis)
 
648
 
 
649
    def set_axis_style(self, axis_index, colour, font_size=None, \
 
650
            alignment=None):
 
651
        try:
 
652
            self.axis[axis_index].set_style(colour, font_size, alignment)
 
653
        except IndexError:
 
654
            raise InvalidParametersException('Axis index %i has not been ' \
 
655
                'created' % axis)
 
656
 
 
657
    def axis_to_url(self):
 
658
        available_axis = []
 
659
        label_axis = []
 
660
        range_axis = []
 
661
        positions = []
 
662
        styles = []
 
663
        index = -1
 
664
        for axis in self.axis:
 
665
            available_axis.append(axis.axis_type)
 
666
            if isinstance(axis, RangeAxis):
 
667
                range_axis.append(repr(axis))
 
668
            if isinstance(axis, LabelAxis):
 
669
                label_axis.append(repr(axis))
 
670
            if axis.positions:
 
671
                positions.append(axis.positions_to_url())
 
672
            if axis.has_style:
 
673
                styles.append(axis.style_to_url())
 
674
        if not available_axis:
 
675
            return
 
676
        url_bits = []
 
677
        url_bits.append('chxt=%s' % ','.join(available_axis))
 
678
        if label_axis:
 
679
            url_bits.append('chxl=%s' % '%7c'.join(label_axis))
 
680
        if range_axis:
 
681
            url_bits.append('chxr=%s' % '%7c'.join(range_axis))
 
682
        if positions:
 
683
            url_bits.append('chxp=%s' % '%7c'.join(positions))
 
684
        if styles:
 
685
            url_bits.append('chxs=%s' % '%7c'.join(styles))
 
686
        return '&'.join(url_bits)
 
687
 
 
688
    # Markers, Ranges and Fill area (chm)
 
689
    # -------------------------------------------------------------------------
 
690
 
 
691
    def markers_to_url(self):        
 
692
        return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
 
693
 
 
694
    def add_marker(self, index, point, marker_type, colour, size, priority=0):
 
695
        self.markers.append((marker_type, colour, str(index), str(point), \
 
696
            str(size), str(priority)))
 
697
 
 
698
    def add_horizontal_range(self, colour, start, stop):
 
699
        self.markers.append(('r', colour, '0', str(start), str(stop)))
 
700
 
 
701
    def add_data_line(self, colour, data_set, size, priority=0):
 
702
        self.markers.append(('D', colour, str(data_set), '0', str(size), \
 
703
            str(priority)))
 
704
 
 
705
    def add_marker_text(self, string, colour, data_set, data_point, size, \
 
706
            priority=0):
 
707
        self.markers.append((str(string), colour, str(data_set), \
 
708
            str(data_point), str(size), str(priority)))        
 
709
 
 
710
    def add_vertical_range(self, colour, start, stop):
 
711
        self.markers.append(('R', colour, '0', str(start), str(stop)))
 
712
 
 
713
    def add_fill_range(self, colour, index_start, index_end):
 
714
        self.markers.append(('b', colour, str(index_start), str(index_end), \
 
715
            '1'))
 
716
 
 
717
    def add_fill_simple(self, colour):
 
718
        self.markers.append(('B', colour, '1', '1', '1'))
 
719
 
 
720
    # Line styles
 
721
    # -------------------------------------------------------------------------
 
722
 
 
723
    def set_line_style(self, index, thickness=1, line_segment=None, \
 
724
            blank_segment=None):
 
725
        value = []
 
726
        value.append(str(thickness))
 
727
        if line_segment:
 
728
            value.append(str(line_segment))
 
729
            value.append(str(blank_segment))
 
730
        self.line_styles[index] = value
 
731
 
 
732
    # Grid
 
733
    # -------------------------------------------------------------------------
 
734
 
 
735
    def set_grid(self, x_step, y_step, line_segment=1, \
 
736
            blank_segment=0):
 
737
        self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \
 
738
            blank_segment)
 
739
 
 
740
 
 
741
class ScatterChart(Chart):
 
742
 
 
743
    def type_to_url(self):
 
744
        return 'cht=s'
 
745
 
 
746
    def annotated_data(self):
 
747
        yield ('x', self.data[0])
 
748
        yield ('y', self.data[1])
 
749
        if len(self.data) > 2:
 
750
            # The optional third dataset is relative sizing for point
 
751
            # markers.
 
752
            yield ('marker-size', self.data[2])
 
753
 
 
754
 
 
755
class LineChart(Chart):
 
756
 
 
757
    def __init__(self, *args, **kwargs):
 
758
        if type(self) == LineChart:
 
759
            raise AbstractClassException('This is an abstract class')
 
760
        Chart.__init__(self, *args, **kwargs)
 
761
 
 
762
 
 
763
class SimpleLineChart(LineChart):
 
764
 
 
765
    def type_to_url(self):
 
766
        return 'cht=lc'
 
767
 
 
768
    def annotated_data(self):
 
769
        # All datasets are y-axis data.
 
770
        for dataset in self.data:
 
771
            yield ('y', dataset)
 
772
 
 
773
 
 
774
class SparkLineChart(SimpleLineChart):
 
775
 
 
776
    def type_to_url(self):
 
777
        return 'cht=ls'
 
778
 
 
779
 
 
780
class XYLineChart(LineChart):
 
781
 
 
782
    def type_to_url(self):
 
783
        return 'cht=lxy'
 
784
 
 
785
    def annotated_data(self):
 
786
        # Datasets alternate between x-axis, y-axis.
 
787
        for i, dataset in enumerate(self.data):
 
788
            if i % 2 == 0:
 
789
                yield ('x', dataset)
 
790
            else:
 
791
                yield ('y', dataset)
 
792
 
 
793
 
 
794
class BarChart(Chart):
 
795
 
 
796
    def __init__(self, *args, **kwargs):
 
797
        if type(self) == BarChart:
 
798
            raise AbstractClassException('This is an abstract class')
 
799
        Chart.__init__(self, *args, **kwargs)
 
800
        self.bar_width = None
 
801
        self.zero_lines = {}
 
802
 
 
803
    def set_bar_width(self, bar_width):
 
804
        self.bar_width = bar_width
 
805
 
 
806
    def set_zero_line(self, index, zero_line):
 
807
        self.zero_lines[index] = zero_line
 
808
 
 
809
    def get_url_bits(self, data_class=None, skip_chbh=False):
 
810
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
811
        if not skip_chbh and self.bar_width is not None:
 
812
            url_bits.append('chbh=%i' % self.bar_width)
 
813
        zero_line = []
 
814
        if self.zero_lines:
 
815
            for index in range(max(self.zero_lines) + 1):
 
816
                if index in self.zero_lines:
 
817
                    zero_line.append(str(self.zero_lines[index]))
 
818
                else:
 
819
                    zero_line.append('0')
 
820
            url_bits.append('chp=%s' % ','.join(zero_line))
 
821
        return url_bits
 
822
 
 
823
 
 
824
class StackedHorizontalBarChart(BarChart):
 
825
 
 
826
    def type_to_url(self):
 
827
        return 'cht=bhs'
 
828
 
 
829
 
 
830
class StackedVerticalBarChart(BarChart):
 
831
 
 
832
    def type_to_url(self):
 
833
        return 'cht=bvs'
 
834
 
 
835
    def annotated_data(self):
 
836
        for dataset in self.data:
 
837
            yield ('y', dataset)
 
838
 
 
839
 
 
840
class GroupedBarChart(BarChart):
 
841
 
 
842
    def __init__(self, *args, **kwargs):
 
843
        if type(self) == GroupedBarChart:
 
844
            raise AbstractClassException('This is an abstract class')
 
845
        BarChart.__init__(self, *args, **kwargs)
 
846
        self.bar_spacing = None
 
847
        self.group_spacing = None
 
848
 
 
849
    def set_bar_spacing(self, spacing):
 
850
        """Set spacing between bars in a group."""
 
851
        self.bar_spacing = spacing
 
852
 
 
853
    def set_group_spacing(self, spacing):
 
854
        """Set spacing between groups of bars."""
 
855
        self.group_spacing = spacing
 
856
 
 
857
    def get_url_bits(self, data_class=None):
 
858
        # Skip 'BarChart.get_url_bits' and call Chart directly so the parent
 
859
        # doesn't add "chbh" before we do.
 
860
        url_bits = BarChart.get_url_bits(self, data_class=data_class,
 
861
            skip_chbh=True)
 
862
        if self.group_spacing is not None:
 
863
            if self.bar_spacing is None:
 
864
                raise InvalidParametersException('Bar spacing is required ' \
 
865
                    'to be set when setting group spacing')
 
866
            if self.bar_width is None:
 
867
                raise InvalidParametersException('Bar width is required to ' \
 
868
                    'be set when setting bar spacing')
 
869
            url_bits.append('chbh=%i,%i,%i'
 
870
                % (self.bar_width, self.bar_spacing, self.group_spacing))
 
871
        elif self.bar_spacing is not None:
 
872
            if self.bar_width is None:
 
873
                raise InvalidParametersException('Bar width is required to ' \
 
874
                    'be set when setting bar spacing')
 
875
            url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing))
 
876
        elif self.bar_width:
 
877
            url_bits.append('chbh=%i' % self.bar_width)
 
878
        return url_bits
 
879
 
 
880
 
 
881
class GroupedHorizontalBarChart(GroupedBarChart):
 
882
 
 
883
    def type_to_url(self):
 
884
        return 'cht=bhg'
 
885
 
 
886
 
 
887
class GroupedVerticalBarChart(GroupedBarChart):
 
888
 
 
889
    def type_to_url(self):
 
890
        return 'cht=bvg'
 
891
 
 
892
    def annotated_data(self):
 
893
        for dataset in self.data:
 
894
            yield ('y', dataset)
 
895
 
 
896
 
 
897
class PieChart(Chart):
 
898
 
 
899
    def __init__(self, *args, **kwargs):
 
900
        if type(self) == PieChart:
 
901
            raise AbstractClassException('This is an abstract class')
 
902
        Chart.__init__(self, *args, **kwargs)
 
903
        self.pie_labels = []
 
904
        if self.y_range:
 
905
            warnings.warn('y_range is not used with %s.' % \
 
906
                (self.__class__.__name__))
 
907
 
 
908
    def set_pie_labels(self, labels):
 
909
        self.pie_labels = [urllib.parse.quote(a) for a in labels]
 
910
 
 
911
    def get_url_bits(self, data_class=None):
 
912
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
913
        if self.pie_labels:
 
914
            url_bits.append('chl=%s' % '%7c'.join(self.pie_labels))
 
915
        return url_bits
 
916
 
 
917
    def annotated_data(self):
 
918
        # Datasets are all y-axis data. However, there should only be
 
919
        # one dataset for pie charts.
 
920
        for dataset in self.data:
 
921
            yield ('x', dataset)
 
922
 
 
923
    def scaled_data(self, data_class, x_range=None, y_range=None):
 
924
        if not x_range:
 
925
            x_range = [0, sum(self.data[0])]
 
926
        return Chart.scaled_data(self, data_class, x_range, self.y_range)
 
927
 
 
928
 
 
929
class PieChart2D(PieChart):
 
930
 
 
931
    def type_to_url(self):
 
932
        return 'cht=p'
 
933
 
 
934
 
 
935
class PieChart3D(PieChart):
 
936
 
 
937
    def type_to_url(self):
 
938
        return 'cht=p3'
 
939
 
 
940
 
 
941
class VennChart(Chart):
 
942
 
 
943
    def type_to_url(self):
 
944
        return 'cht=v'
 
945
 
 
946
    def annotated_data(self):
 
947
        for dataset in self.data:
 
948
            yield ('y', dataset)
 
949
 
 
950
 
 
951
class RadarChart(Chart):
 
952
 
 
953
    def type_to_url(self):
 
954
        return 'cht=r'
 
955
 
 
956
 
 
957
class SplineRadarChart(RadarChart):
 
958
 
 
959
    def type_to_url(self):
 
960
        return 'cht=rs'
 
961
 
 
962
 
 
963
class MapChart(Chart):
 
964
 
 
965
    def __init__(self, *args, **kwargs):
 
966
        Chart.__init__(self, *args, **kwargs)
 
967
        self.geo_area = 'world'
 
968
        self.codes = []
 
969
        self.__areas = ('africa', 'asia', 'europe', 'middle_east',
 
970
            'south_america', 'usa', 'world')
 
971
        self.__ccodes = (
 
972
            'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR',
 
973
            'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF',
 
974
            'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT',
 
975
            'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI',
 
976
            'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ',
 
977
            'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER',
 
978
            'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD',
 
979
            'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR',
 
980
            'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU',
 
981
            'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE',
 
982
            'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR',
 
983
            'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT',
 
984
            'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
 
985
            'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV',
 
986
            'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL',
 
987
            'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH',
 
988
            'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE',
 
989
            'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH',
 
990
            'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY',
 
991
            'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN',
 
992
            'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY',
 
993
            'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE',
 
994
            'YT', 'ZA', 'ZM', 'ZW')
 
995
        
 
996
    def type_to_url(self):
 
997
        return 'cht=t'
 
998
 
 
999
    def set_codes(self, codes):
 
1000
        '''Set the country code map for the data.
 
1001
        Codes given in a list.
 
1002
 
 
1003
        i.e. DE - Germany
 
1004
             AT - Austria
 
1005
             US - United States
 
1006
        '''
 
1007
 
 
1008
        codemap = ''
 
1009
        
 
1010
        for cc in codes:
 
1011
            cc = cc.upper()
 
1012
            if cc in self.__ccodes:
 
1013
                codemap += cc
 
1014
            else:
 
1015
                raise UnknownCountryCodeException(cc)
 
1016
            
 
1017
        self.codes = codemap
 
1018
 
 
1019
    def set_geo_area(self, area):
 
1020
        '''Sets the geo area for the map.
 
1021
 
 
1022
        * africa
 
1023
        * asia
 
1024
        * europe
 
1025
        * middle_east
 
1026
        * south_america
 
1027
        * usa
 
1028
        * world
 
1029
        '''
 
1030
        
 
1031
        if area in self.__areas:
 
1032
            self.geo_area = area
 
1033
        else:
 
1034
            raise UnknownChartType('Unknown chart type for maps: %s' %area)
 
1035
 
 
1036
    def get_url_bits(self, data_class=None):
 
1037
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
1038
        url_bits.append('chtm=%s' % self.geo_area)
 
1039
        if self.codes:
 
1040
            url_bits.append('chld=%s' % ''.join(self.codes))
 
1041
        return url_bits
 
1042
 
 
1043
    def add_data_dict(self, datadict):
 
1044
        '''Sets the data and country codes via a dictionary.
 
1045
 
 
1046
        i.e. {'DE': 50, 'GB': 30, 'AT': 70}
 
1047
        '''
 
1048
 
 
1049
        self.set_codes(datadict.keys())
 
1050
        self.add_data(datadict.values())
 
1051
 
 
1052
 
 
1053
class GoogleOMeterChart(PieChart):
 
1054
    """Inheriting from PieChart because of similar labeling"""
 
1055
 
 
1056
    def __init__(self, *args, **kwargs):
 
1057
        PieChart.__init__(self, *args, **kwargs)
 
1058
        if self.auto_scale and not self.x_range:
 
1059
            warnings.warn('Please specify an x_range with GoogleOMeterChart, '
 
1060
                'otherwise one arrow will always be at the max.')
 
1061
 
 
1062
    def type_to_url(self):
 
1063
        return 'cht=gom'
 
1064
 
 
1065
 
 
1066
class QRChart(Chart):
 
1067
 
 
1068
    def __init__(self, *args, **kwargs):
 
1069
        Chart.__init__(self, *args, **kwargs)
 
1070
        self.encoding = None
 
1071
        self.ec_level = None
 
1072
        self.margin = None
 
1073
 
 
1074
    def type_to_url(self):
 
1075
        return 'cht=qr'
 
1076
 
 
1077
    def data_to_url(self, data_class=None):
 
1078
        if not self.data:
 
1079
            raise NoDataGivenException()
 
1080
        return 'chl=%s' % urllib.parse.quote(self.data[0])
 
1081
 
 
1082
    def get_url_bits(self, data_class=None):
 
1083
        url_bits = Chart.get_url_bits(self, data_class=data_class)
 
1084
        if self.encoding:
 
1085
            url_bits.append('choe=%s' % self.encoding)
 
1086
        if self.ec_level:
 
1087
            url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin))
 
1088
        return url_bits
 
1089
 
 
1090
    def set_encoding(self, encoding):
 
1091
        self.encoding = encoding
 
1092
 
 
1093
    def set_ec(self, level, margin):
 
1094
        self.ec_level = level
 
1095
        self.margin = margin
 
1096
 
 
1097
 
 
1098
class ChartGrammar(object):
 
1099
 
 
1100
    def __init__(self):
 
1101
        self.grammar = None
 
1102
        self.chart = None
 
1103
 
 
1104
    def parse(self, grammar):
 
1105
        self.grammar = grammar
 
1106
        self.chart = self.create_chart_instance()
 
1107
 
 
1108
        for attr in self.grammar:
 
1109
            if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'):
 
1110
                continue  # These are already parsed in create_chart_instance
 
1111
            attr_func = 'parse_' + attr
 
1112
            if not hasattr(self, attr_func):
 
1113
                warnings.warn('No parser for grammar attribute "%s"' % (attr))
 
1114
                continue
 
1115
            getattr(self, attr_func)(grammar[attr])
 
1116
 
 
1117
        return self.chart
 
1118
 
 
1119
    def parse_data(self, data):
 
1120
        self.chart.data = data
 
1121
 
 
1122
    @staticmethod
 
1123
    def get_possible_chart_types():
 
1124
        possible_charts = []
 
1125
        for cls_name in globals().keys():
 
1126
            if not cls_name.endswith('Chart'):
 
1127
                continue
 
1128
            cls = globals()[cls_name]
 
1129
            # Check if it is an abstract class
 
1130
            try:
 
1131
                a = cls(1, 1, auto_scale=False)
 
1132
                del a
 
1133
            except AbstractClassException:
 
1134
                continue
 
1135
            # Strip off "Class"
 
1136
            possible_charts.append(cls_name[:-5])
 
1137
        return possible_charts
 
1138
 
 
1139
    def create_chart_instance(self, grammar=None):
 
1140
        if not grammar:
 
1141
            grammar = self.grammar
 
1142
        assert(isinstance(grammar, dict))  # grammar must be a dict
 
1143
        assert('w' in grammar)  # width is required
 
1144
        assert('h' in grammar)  # height is required
 
1145
        assert('type' in grammar)  # type is required
 
1146
        chart_type = grammar['type']
 
1147
        w = grammar['w']
 
1148
        h = grammar['h']
 
1149
        auto_scale = grammar.get('auto_scale', None)
 
1150
        x_range = grammar.get('x_range', None)
 
1151
        y_range = grammar.get('y_range', None)
 
1152
        types = ChartGrammar.get_possible_chart_types()
 
1153
        if chart_type not in types:
 
1154
            raise UnknownChartType('%s is an unknown chart type. Possible '
 
1155
                'chart types are %s' % (chart_type, ','.join(types)))
 
1156
        return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale,
 
1157
            x_range=x_range, y_range=y_range)
 
1158
 
 
1159
    def download(self):
 
1160
        pass
 
1161