/+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: Gerald Kaszuba
  • Author(s): Stefan Hacker
  • Date: 2010-12-24 05:23:09 UTC
  • Revision ID: git-v1:0b30f3573951bd3ace77802579b631e40c191b31
POST requests. Closes #1

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