/+junk/pygooglechart-py3k

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

« back to all changes in this revision

Viewing changes to pygooglechart.py

  • Committer: gak
  • Date: 2009-03-15 09:25:18 UTC
  • Revision ID: git-v1:be7dd11f604762d1273c3fd6438b97b2c76be1cf
 - Reorganised tests
 - Added test for #16

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