Source code for pygooglechart

#!/usr/bin/env python
"""
pygooglechart - A complete Python wrapper for the Google Chart API

http://pygooglechart.slowchop.com/

Copyright 2007-2013 Gerald Kaszuba

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""

# unnecessary on Python3, but harmless
from __future__ import division

import os
import math
import random
import re
import warnings
import copy

try:
    # we're on Python3
    from urllib.request import urlopen
    from urllib.parse import quote

except ImportError:
    # we're on Python2.x
    from urllib2 import urlopen
    from urllib import quote


# Helper variables and functions
# -----------------------------------------------------------------------------

__version__ = '0.4.0'
__author__ = 'Gerald Kaszuba'

reo_colour = re.compile('^([A-Fa-f0-9]{2,2}){3,4}$')

def _check_colour(colour):
    if not reo_colour.match(colour):
        raise InvalidParametersException('Colours need to be in ' \
            'RRGGBB or RRGGBBAA format. One of your colours has %s' % \
            colour)


def _reset_warnings():
    """Helper function to reset all warnings. Used by the unit tests."""
    globals()['__warningregistry__'] = None


# Exception Classes
# -----------------------------------------------------------------------------


[docs]class PyGoogleChartException(Exception): pass
[docs]class DataOutOfRangeException(PyGoogleChartException): pass
[docs]class UnknownDataTypeException(PyGoogleChartException): pass
[docs]class NoDataGivenException(PyGoogleChartException): pass
[docs]class InvalidParametersException(PyGoogleChartException): pass
[docs]class BadContentTypeException(PyGoogleChartException): pass
[docs]class AbstractClassException(PyGoogleChartException): pass
[docs]class UnknownChartType(PyGoogleChartException): pass
[docs]class UnknownCountryCodeException(PyGoogleChartException): pass
# Data Classes # -----------------------------------------------------------------------------
[docs]class Data(object): def __init__(self, data): if type(self) == Data: raise AbstractClassException('This is an abstract class') self.data = data @classmethod
[docs] def float_scale_value(cls, value, range): lower, upper = range assert(upper > lower) scaled = (value - lower) * (cls.max_value / (upper - lower)) return scaled
@classmethod
[docs] def clip_value(cls, value): return max(0, min(value, cls.max_value))
@classmethod
[docs] def int_scale_value(cls, value, range): return int(round(cls.float_scale_value(value, range)))
@classmethod
[docs] def scale_value(cls, value, range): scaled = cls.int_scale_value(value, range) clipped = cls.clip_value(scaled) Data.check_clip(scaled, clipped) return clipped
@staticmethod
[docs] def check_clip(scaled, clipped): if clipped != scaled: warnings.warn('One or more of of your data points has been ' 'clipped because it is out of range.')
[docs]class SimpleData(Data): max_value = 61 enc_map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' def __repr__(self): encoded_data = [] for data in self.data: sub_data = [] for value in data: if value is None: sub_data.append('_') elif value >= 0 and value <= self.max_value: sub_data.append(SimpleData.enc_map[value]) else: raise DataOutOfRangeException('cannot encode value: %d' % value) encoded_data.append(''.join(sub_data)) return 'chd=s:' + ','.join(encoded_data)
[docs]class TextData(Data): max_value = 100 def __repr__(self): encoded_data = [] for data in self.data: sub_data = [] for value in data: if value is None: sub_data.append(-1) elif value >= 0 and value <= self.max_value: sub_data.append("%.1f" % float(value)) else: raise DataOutOfRangeException() encoded_data.append(','.join(sub_data)) return 'chd=t:' + '%7c'.join(encoded_data) @classmethod
[docs] def scale_value(cls, value, range): # use float values instead of integers because we don't need an encode # map index scaled = cls.float_scale_value(value, range) clipped = cls.clip_value(scaled) Data.check_clip(scaled, clipped) return clipped
[docs]class ExtendedData(Data): max_value = 4095 enc_map = \ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.' def __repr__(self): encoded_data = [] enc_size = len(ExtendedData.enc_map) for data in self.data: sub_data = [] for value in data: if value is None: sub_data.append('__') elif value >= 0 and value <= self.max_value: first, second = divmod(int(value), enc_size) sub_data.append('%s%s' % ( ExtendedData.enc_map[first], ExtendedData.enc_map[second])) else: raise DataOutOfRangeException( \ 'Item #%i "%s" is out of range' % (data.index(value), \ value)) encoded_data.append(''.join(sub_data)) return 'chd=e:' + ','.join(encoded_data)
# Axis Classes # -----------------------------------------------------------------------------
[docs]class Axis(object): BOTTOM = 'x' TOP = 't' LEFT = 'y' RIGHT = 'r' TYPES = (BOTTOM, TOP, LEFT, RIGHT) def __init__(self, axis_index, axis_type, **kw): assert(axis_type in Axis.TYPES) self.has_style = False self.axis_index = axis_index self.axis_type = axis_type self.positions = None
[docs] def set_index(self, axis_index): self.axis_index = axis_index
[docs] def set_positions(self, positions): self.positions = positions
[docs] def set_style(self, colour, font_size=None, alignment=None): _check_colour(colour) self.colour = colour self.font_size = font_size self.alignment = alignment self.has_style = True
[docs] def style_to_url(self): bits = [] bits.append(str(self.axis_index)) bits.append(self.colour) if self.font_size is not None: bits.append(str(self.font_size)) if self.alignment is not None: bits.append(str(self.alignment)) return ','.join(bits)
[docs] def positions_to_url(self): bits = [] bits.append(str(self.axis_index)) bits += [str(a) for a in self.positions] return ','.join(bits)
[docs]class LabelAxis(Axis): def __init__(self, axis_index, axis_type, values, **kwargs): Axis.__init__(self, axis_index, axis_type, **kwargs) self.values = [str(a) for a in values] def __repr__(self): return '%i:%%7c%s' % (self.axis_index, '%7c'.join(self.values))
[docs]class RangeAxis(Axis): def __init__(self, axis_index, axis_type, low, high, **kwargs): Axis.__init__(self, axis_index, axis_type, **kwargs) self.low = low self.high = high def __repr__(self): return '%i,%s,%s' % (self.axis_index, self.low, self.high)
# Chart Classes # -----------------------------------------------------------------------------
[docs]class Chart(object): """Abstract class for all chart types. width are height specify the dimensions of the image. title sets the title of the chart. legend requires a list that corresponds to datasets. """ BASE_URL = 'http://www.google.com/chart' BACKGROUND = 'bg' CHART = 'c' ALPHA = 'a' VALID_SOLID_FILL_TYPES = (BACKGROUND, CHART, ALPHA) SOLID = 's' LINEAR_GRADIENT = 'lg' LINEAR_STRIPES = 'ls' def __init__(self, width, height, title=None, legend=None, colours=None, auto_scale=True, x_range=None, y_range=None, colours_within_series=None): if type(self) == Chart: raise AbstractClassException('This is an abstract class') assert(isinstance(width, int)) assert(isinstance(height, int)) self.width = width self.height = height self.data = [] self.set_title(title) self.set_title_style(None, None) self.set_legend(legend) self.set_legend_position(None) self.set_colours(colours) self.set_colours_within_series(colours_within_series) # Data for scaling. self.auto_scale = auto_scale # Whether to automatically scale data self.x_range = x_range # (min, max) x-axis range for scaling self.y_range = y_range # (min, max) y-axis range for scaling self.scaled_data_class = None self.scaled_x_range = None self.scaled_y_range = None self.fill_types = { Chart.BACKGROUND: None, Chart.CHART: None, Chart.ALPHA: None, } self.fill_area = { Chart.BACKGROUND: None, Chart.CHART: None, Chart.ALPHA: None, } self.axis = [] self.markers = [] self.line_styles = {} self.grid = None self.title_colour = None self.title_font_size = None # URL generation # -------------------------------------------------------------------------
[docs] def get_url(self, data_class=None): return self.BASE_URL + '?' + self.get_url_extension(data_class)
[docs] def get_url_extension(self, data_class=None): url_bits = self.get_url_bits(data_class=data_class) return '&'.join(url_bits)
[docs] def get_url_bits(self, data_class=None): url_bits = [] # required arguments url_bits.append(self.type_to_url()) url_bits.append('chs=%ix%i' % (self.width, self.height)) url_bits.append(self.data_to_url(data_class=data_class)) # optional arguments if self.title: url_bits.append('chtt=%s' % self.title) if self.title_colour and self.title_font_size: url_bits.append('chts=%s,%s' % (self.title_colour, \ self.title_font_size)) if self.legend: url_bits.append('chdl=%s' % '%7c'.join(self.legend)) if self.legend_position: url_bits.append('chdlp=%s' % (self.legend_position)) if self.colours: url_bits.append('chco=%s' % '|'.join(self.colours)) if self.colours_within_series: url_bits.append('chco=%s' % '%7c'.join(self.colours_within_series)) ret = self.fill_to_url() if ret: url_bits.append(ret) ret = self.axis_to_url() if ret: url_bits.append(ret) if self.markers: url_bits.append(self.markers_to_url()) if self.line_styles: style = [] for index in range(max(self.line_styles) + 1): if index in self.line_styles: values = self.line_styles[index] else: values = ('1', ) style.append(','.join(values)) url_bits.append('chls=%s' % '%7c'.join(style)) if self.grid: url_bits.append('chg=%s' % self.grid) return url_bits
# Downloading # -------------------------------------------------------------------------
[docs] def download(self, file_name=False, use_post=True): if use_post: opener = urlopen(self.BASE_URL, self.get_url_extension().encode('utf-8')) else: opener = urlopen(self.get_url()) if opener.headers['content-type'] != 'image/png': raise BadContentTypeException('Server responded with a ' \ 'content-type of %s' % opener.headers['content-type']) if file_name: open(file_name, 'wb').write(opener.read()) else: return opener.read()
# Simple settings # -------------------------------------------------------------------------
[docs] def set_title(self, title): if title: self.title = quote(title) else: self.title = None
[docs] def set_title_style(self, colour=None, font_size=None): if not colour is None: _check_colour(colour) if not colour and not font_size: return self.title_colour = colour or '333333' self.title_font_size = font_size or 13.5
[docs] def set_legend(self, legend): """legend needs to be a list, tuple or None""" assert(isinstance(legend, list) or isinstance(legend, tuple) or legend is None) if legend: self.legend = [quote(a) for a in legend] else: self.legend = None
[docs] def set_legend_position(self, legend_position): """Sets legend position. Default is 'r'. b - At the bottom of the chart, legend entries in a horizontal row. bv - At the bottom of the chart, legend entries in a vertical column. t - At the top of the chart, legend entries in a horizontal row. tv - At the top of the chart, legend entries in a vertical column. r - To the right of the chart, legend entries in a vertical column. l - To the left of the chart, legend entries in a vertical column. """ if legend_position: self.legend_position = quote(legend_position) else: self.legend_position = None
# Chart colours # -------------------------------------------------------------------------
[docs] def set_colours(self, colours): # colours needs to be a list, tuple or None assert(isinstance(colours, list) or isinstance(colours, tuple) or colours is None) # make sure the colours are in the right format if colours: for col in colours: _check_colour(col) self.colours = colours
[docs] def set_colours_within_series(self, colours): # colours needs to be a list, tuple or None assert(isinstance(colours, list) or isinstance(colours, tuple) or colours is None) # make sure the colours are in the right format if colours: for col in colours: _check_colour(col) self.colours_within_series = colours
# Background/Chart colours # -------------------------------------------------------------------------
[docs] def fill_solid(self, area, colour): assert(area in Chart.VALID_SOLID_FILL_TYPES) _check_colour(colour) self.fill_area[area] = colour self.fill_types[area] = Chart.SOLID
def _check_fill_linear(self, angle, *args): assert(isinstance(args, list) or isinstance(args, tuple)) assert(angle >= 0 and angle <= 90) assert(len(args) % 2 == 0) args = list(args) # args is probably a tuple and we need to mutate for a in range(int(len(args) / 2)): col = args[a * 2] offset = args[a * 2 + 1] _check_colour(col) assert(offset >= 0 and offset <= 1) args[a * 2 + 1] = str(args[a * 2 + 1]) return args
[docs] def fill_linear_gradient(self, area, angle, *args): assert(area in Chart.VALID_SOLID_FILL_TYPES) args = self._check_fill_linear(angle, *args) self.fill_types[area] = Chart.LINEAR_GRADIENT self.fill_area[area] = ','.join([str(angle)] + args)
[docs] def fill_linear_stripes(self, area, angle, *args): assert(area in Chart.VALID_SOLID_FILL_TYPES) args = self._check_fill_linear(angle, *args) self.fill_types[area] = Chart.LINEAR_STRIPES self.fill_area[area] = ','.join([str(angle)] + args)
[docs] def fill_to_url(self): areas = [] for area in (Chart.BACKGROUND, Chart.CHART, Chart.ALPHA): if self.fill_types[area]: areas.append('%s,%s,%s' % (area, self.fill_types[area], \ self.fill_area[area])) if areas: return 'chf=' + '%7c'.join(areas)
# Data # -------------------------------------------------------------------------
[docs] def data_class_detection(self, data): """Determines the appropriate data encoding type to give satisfactory resolution (http://code.google.com/apis/chart/#chart_data). """ assert(isinstance(data, list) or isinstance(data, tuple)) if not isinstance(self, (LineChart, BarChart, ScatterChart)): # From the link above: # Simple encoding is suitable for all other types of chart # regardless of size. return SimpleData elif self.height < 100: # The link above indicates that line and bar charts less # than 300px in size can be suitably represented with the # simple encoding. I've found that this isn't sufficient, # e.g. examples/line-xy-circle.png. Let's try 100px. return SimpleData else: return ExtendedData
def _filter_none(self, data): return [r for r in data if r is not None]
[docs] def data_x_range(self): """Return a 2-tuple giving the minimum and maximum x-axis data range. """ try: lower = min([min(self._filter_none(s)) for type, s in self.annotated_data() if type == 'x']) upper = max([max(self._filter_none(s)) for type, s in self.annotated_data() if type == 'x']) return (lower, upper) except ValueError: return None # no x-axis datasets
[docs] def data_y_range(self): """Return a 2-tuple giving the minimum and maximum y-axis data range. """ try: lower = min([min(self._filter_none(s)) for type, s in self.annotated_data() if type == 'y']) upper = max([max(self._filter_none(s)) + 1 for type, s in self.annotated_data() if type == 'y']) return (lower, upper) except ValueError: return None # no y-axis datasets
[docs] def scaled_data(self, data_class, x_range=None, y_range=None): """Scale `self.data` as appropriate for the given data encoding (data_class) and return it. An optional `y_range` -- a 2-tuple (lower, upper) -- can be given to specify the y-axis bounds. If not given, the range is inferred from the data: (0, <max-value>) presuming no negative values, or (<min-value>, <max-value>) if there are negative values. `self.scaled_y_range` is set to the actual lower and upper scaling range. Ditto for `x_range`. Note that some chart types don't have x-axis data. """ self.scaled_data_class = data_class # Determine the x-axis range for scaling. if x_range is None: x_range = self.data_x_range() if x_range and x_range[0] > 0: x_range = (x_range[0], x_range[1]) self.scaled_x_range = x_range # Determine the y-axis range for scaling. if y_range is None: y_range = self.data_y_range() if y_range and y_range[0] > 0: y_range = (y_range[0], y_range[1]) self.scaled_y_range = y_range scaled_data = [] for type, dataset in self.annotated_data(): if type == 'x': scale_range = x_range elif type == 'y': scale_range = y_range elif type == 'marker-size': scale_range = (0, max(dataset)) scaled_dataset = [] for v in dataset: if v is None: scaled_dataset.append(None) else: scaled_dataset.append( data_class.scale_value(v, scale_range)) scaled_data.append(scaled_dataset) return scaled_data
[docs] def add_data(self, data): self.data.append(data) return len(self.data) - 1 # return the "index" of the data set
[docs] def data_to_url(self, data_class=None): if not data_class: data_class = self.data_class_detection(self.data) if not issubclass(data_class, Data): raise UnknownDataTypeException() if self.auto_scale: data = self.scaled_data(data_class, self.x_range, self.y_range) else: data = self.data return repr(data_class(data))
[docs] def annotated_data(self): for dataset in self.data: yield ('x', dataset)
# Axis Labels # -------------------------------------------------------------------------
[docs] def set_axis_labels(self, axis_type, values): assert(axis_type in Axis.TYPES) values = [quote(str(a)) for a in values] axis_index = len(self.axis) axis = LabelAxis(axis_index, axis_type, values) self.axis.append(axis) return axis_index
[docs] def set_axis_range(self, axis_type, low, high): assert(axis_type in Axis.TYPES) axis_index = len(self.axis) axis = RangeAxis(axis_index, axis_type, low, high) self.axis.append(axis) return axis_index
[docs] def set_axis_positions(self, axis_index, positions): try: self.axis[axis_index].set_positions(positions) except IndexError: raise InvalidParametersException('Axis index %i has not been ' \ 'created' % axis)
[docs] def set_axis_style(self, axis_index, colour, font_size=None, \ alignment=None): try: self.axis[axis_index].set_style(colour, font_size, alignment) except IndexError: raise InvalidParametersException('Axis index %i has not been ' \ 'created' % axis)
[docs] def axis_to_url(self): available_axis = [] label_axis = [] range_axis = [] positions = [] styles = [] index = -1 for axis in self.axis: available_axis.append(axis.axis_type) if isinstance(axis, RangeAxis): range_axis.append(repr(axis)) if isinstance(axis, LabelAxis): label_axis.append(repr(axis)) if axis.positions: positions.append(axis.positions_to_url()) if axis.has_style: styles.append(axis.style_to_url()) if not available_axis: return url_bits = [] url_bits.append('chxt=%s' % ','.join(available_axis)) if label_axis: url_bits.append('chxl=%s' % '%7c'.join(label_axis)) if range_axis: url_bits.append('chxr=%s' % '%7c'.join(range_axis)) if positions: url_bits.append('chxp=%s' % '%7c'.join(positions)) if styles: url_bits.append('chxs=%s' % '%7c'.join(styles)) return '&'.join(url_bits)
# Markers, Ranges and Fill area (chm) # -------------------------------------------------------------------------
[docs] def markers_to_url(self): return 'chm=%s' % '%7c'.join([','.join(a) for a in self.markers])
[docs] def add_marker(self, index, point, marker_type, colour, size, priority=0): self.markers.append((marker_type, colour, str(index), str(point), \ str(size), str(priority)))
[docs] def add_horizontal_range(self, colour, start, stop): self.markers.append(('r', colour, '0', str(start), str(stop)))
[docs] def add_data_line(self, colour, data_set, size, priority=0): self.markers.append(('D', colour, str(data_set), '0', str(size), \ str(priority)))
[docs] def add_marker_text(self, string, colour, data_set, data_point, size, \ priority=0): self.markers.append((str(string), colour, str(data_set), \ str(data_point), str(size), str(priority)))
[docs] def add_vertical_range(self, colour, start, stop): self.markers.append(('R', colour, '0', str(start), str(stop)))
[docs] def add_fill_range(self, colour, index_start, index_end): self.markers.append(('b', colour, str(index_start), str(index_end), \ '1'))
[docs] def add_fill_simple(self, colour): self.markers.append(('B', colour, '1', '1', '1'))
# Line styles # -------------------------------------------------------------------------
[docs] def set_line_style(self, index, thickness=1, line_segment=None, \ blank_segment=None): value = [] value.append(str(thickness)) if line_segment: value.append(str(line_segment)) value.append(str(blank_segment)) self.line_styles[index] = value
# Grid # -------------------------------------------------------------------------
[docs] def set_grid(self, x_step, y_step, line_segment=1, \ blank_segment=0): self.grid = '%s,%s,%s,%s' % (x_step, y_step, line_segment, \ blank_segment)
[docs]class ScatterChart(Chart):
[docs] def type_to_url(self): return 'cht=s'
[docs] def annotated_data(self): yield ('x', self.data[0]) yield ('y', self.data[1]) if len(self.data) > 2: # The optional third dataset is relative sizing for point # markers. yield ('marker-size', self.data[2])
[docs]class LineChart(Chart): def __init__(self, *args, **kwargs): if type(self) == LineChart: raise AbstractClassException('This is an abstract class') Chart.__init__(self, *args, **kwargs)
[docs]class SimpleLineChart(LineChart):
[docs] def type_to_url(self): return 'cht=lc'
[docs] def annotated_data(self): # All datasets are y-axis data. for dataset in self.data: yield ('y', dataset)
[docs]class SparkLineChart(SimpleLineChart):
[docs] def type_to_url(self): return 'cht=ls'
[docs]class XYLineChart(LineChart):
[docs] def type_to_url(self): return 'cht=lxy'
[docs] def annotated_data(self): # Datasets alternate between x-axis, y-axis. for i, dataset in enumerate(self.data): if i % 2 == 0: yield ('x', dataset) else: yield ('y', dataset)
[docs]class BarChart(Chart): def __init__(self, *args, **kwargs): if type(self) == BarChart: raise AbstractClassException('This is an abstract class') Chart.__init__(self, *args, **kwargs) self.bar_width = None self.zero_lines = {}
[docs] def set_bar_width(self, bar_width): self.bar_width = bar_width
[docs] def set_zero_line(self, index, zero_line): self.zero_lines[index] = zero_line
[docs] def get_url_bits(self, data_class=None, skip_chbh=False): url_bits = Chart.get_url_bits(self, data_class=data_class) if not skip_chbh and self.bar_width is not None: url_bits.append('chbh=%i' % self.bar_width) zero_line = [] if self.zero_lines: for index in range(max(self.zero_lines) + 1): if index in self.zero_lines: zero_line.append(str(self.zero_lines[index])) else: zero_line.append('0') url_bits.append('chp=%s' % ','.join(zero_line)) return url_bits
[docs]class StackedHorizontalBarChart(BarChart):
[docs] def type_to_url(self): return 'cht=bhs'
[docs]class StackedVerticalBarChart(BarChart):
[docs] def type_to_url(self): return 'cht=bvs'
[docs] def annotated_data(self): for dataset in self.data: yield ('y', dataset)
[docs]class GroupedBarChart(BarChart): def __init__(self, *args, **kwargs): if type(self) == GroupedBarChart: raise AbstractClassException('This is an abstract class') BarChart.__init__(self, *args, **kwargs) self.bar_spacing = None self.group_spacing = None
[docs] def set_bar_spacing(self, spacing): """Set spacing between bars in a group.""" self.bar_spacing = spacing
[docs] def set_group_spacing(self, spacing): """Set spacing between groups of bars.""" self.group_spacing = spacing
[docs] def get_url_bits(self, data_class=None): # Skip 'BarChart.get_url_bits' and call Chart directly so the parent # doesn't add "chbh" before we do. url_bits = BarChart.get_url_bits(self, data_class=data_class, skip_chbh=True) if self.group_spacing is not None: if self.bar_spacing is None: raise InvalidParametersException('Bar spacing is required ' \ 'to be set when setting group spacing') if self.bar_width is None: raise InvalidParametersException('Bar width is required to ' \ 'be set when setting bar spacing') url_bits.append('chbh=%i,%i,%i' % (self.bar_width, self.bar_spacing, self.group_spacing)) elif self.bar_spacing is not None: if self.bar_width is None: raise InvalidParametersException('Bar width is required to ' \ 'be set when setting bar spacing') url_bits.append('chbh=%i,%i' % (self.bar_width, self.bar_spacing)) elif self.bar_width: url_bits.append('chbh=%i' % self.bar_width) return url_bits
[docs]class GroupedHorizontalBarChart(GroupedBarChart):
[docs] def type_to_url(self): return 'cht=bhg'
[docs]class GroupedVerticalBarChart(GroupedBarChart):
[docs] def type_to_url(self): return 'cht=bvg'
[docs] def annotated_data(self): for dataset in self.data: yield ('y', dataset)
[docs]class PieChart(Chart): def __init__(self, *args, **kwargs): if type(self) == PieChart: raise AbstractClassException('This is an abstract class') Chart.__init__(self, *args, **kwargs) self.pie_labels = [] if self.y_range: warnings.warn('y_range is not used with %s.' % \ (self.__class__.__name__))
[docs] def set_pie_labels(self, labels): self.pie_labels = [quote(a) for a in labels]
[docs] def get_url_bits(self, data_class=None): url_bits = Chart.get_url_bits(self, data_class=data_class) if self.pie_labels: url_bits.append('chl=%s' % '%7c'.join(self.pie_labels)) return url_bits
[docs] def annotated_data(self): # Datasets are all y-axis data. However, there should only be # one dataset for pie charts. for dataset in self.data: yield ('x', dataset)
[docs] def scaled_data(self, data_class, x_range=None, y_range=None): if not x_range: x_range = [0, sum(self.data[0])] return Chart.scaled_data(self, data_class, x_range, self.y_range)
[docs]class PieChart2D(PieChart):
[docs] def type_to_url(self): return 'cht=p'
[docs]class PieChart3D(PieChart):
[docs] def type_to_url(self): return 'cht=p3'
[docs]class VennChart(Chart):
[docs] def type_to_url(self): return 'cht=v'
[docs] def annotated_data(self): for dataset in self.data: yield ('y', dataset)
[docs]class RadarChart(Chart):
[docs] def type_to_url(self): return 'cht=r'
[docs]class SplineRadarChart(RadarChart):
[docs] def type_to_url(self): return 'cht=rs'
[docs]class MapChart(Chart): def __init__(self, *args, **kwargs): Chart.__init__(self, *args, **kwargs) self.geo_area = 'world' self.codes = [] self.__areas = ('africa', 'asia', 'europe', 'middle_east', 'south_america', 'usa', 'world') self.__ccodes = ( 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'ST', 'SV', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW')
[docs] def type_to_url(self): return 'cht=t'
[docs] def set_codes(self, codes): '''Set the country code map for the data. Codes given in a list. i.e. DE - Germany AT - Austria US - United States ''' codemap = '' for cc in codes: cc = cc.upper() if cc in self.__ccodes: codemap += cc else: raise UnknownCountryCodeException(cc) self.codes = codemap
[docs] def set_geo_area(self, area): '''Sets the geo area for the map. * africa * asia * europe * middle_east * south_america * usa * world ''' if area in self.__areas: self.geo_area = area else: raise UnknownChartType('Unknown chart type for maps: %s' %area)
[docs] def get_url_bits(self, data_class=None): url_bits = Chart.get_url_bits(self, data_class=data_class) url_bits.append('chtm=%s' % self.geo_area) if self.codes: url_bits.append('chld=%s' % ''.join(self.codes)) return url_bits
[docs] def add_data_dict(self, datadict): '''Sets the data and country codes via a dictionary. i.e. {'DE': 50, 'GB': 30, 'AT': 70} ''' self.set_codes(list(datadict.keys())) self.add_data(list(datadict.values()))
[docs]class GoogleOMeterChart(PieChart): """Inheriting from PieChart because of similar labeling""" def __init__(self, *args, **kwargs): PieChart.__init__(self, *args, **kwargs) if self.auto_scale and not self.x_range: warnings.warn('Please specify an x_range with GoogleOMeterChart, ' 'otherwise one arrow will always be at the max.')
[docs] def type_to_url(self): return 'cht=gom'
[docs]class QRChart(Chart): def __init__(self, *args, **kwargs): Chart.__init__(self, *args, **kwargs) self.encoding = None self.ec_level = None self.margin = None
[docs] def type_to_url(self): return 'cht=qr'
[docs] def data_to_url(self, data_class=None): if not self.data: raise NoDataGivenException() return 'chl=%s' % quote(self.data[0])
[docs] def get_url_bits(self, data_class=None): url_bits = Chart.get_url_bits(self, data_class=data_class) if self.encoding: url_bits.append('choe=%s' % self.encoding) if self.ec_level: url_bits.append('chld=%s%%7c%s' % (self.ec_level, self.margin)) return url_bits
[docs] def set_encoding(self, encoding): self.encoding = encoding
[docs] def set_ec(self, level, margin): self.ec_level = level self.margin = margin
[docs]class ChartGrammar(object): def __init__(self): self.grammar = None self.chart = None
[docs] def parse(self, grammar): self.grammar = grammar self.chart = self.create_chart_instance() for attr in self.grammar: if attr in ('w', 'h', 'type', 'auto_scale', 'x_range', 'y_range'): continue # These are already parsed in create_chart_instance attr_func = 'parse_' + attr if not hasattr(self, attr_func): warnings.warn('No parser for grammar attribute "%s"' % (attr)) continue getattr(self, attr_func)(grammar[attr]) return self.chart
[docs] def parse_data(self, data): self.chart.data = data
@staticmethod
[docs] def get_possible_chart_types(): possible_charts = [] for cls_name in list(globals().keys()): if not cls_name.endswith('Chart'): continue cls = globals()[cls_name] # Check if it is an abstract class try: a = cls(1, 1, auto_scale=False) del a except AbstractClassException: continue # Strip off "Class" possible_charts.append(cls_name[:-5]) return possible_charts
[docs] def create_chart_instance(self, grammar=None): if not grammar: grammar = self.grammar assert(isinstance(grammar, dict)) # grammar must be a dict assert('w' in grammar) # width is required assert('h' in grammar) # height is required assert('type' in grammar) # type is required chart_type = grammar['type'] w = grammar['w'] h = grammar['h'] auto_scale = grammar.get('auto_scale', None) x_range = grammar.get('x_range', None) y_range = grammar.get('y_range', None) types = ChartGrammar.get_possible_chart_types() if chart_type not in types: raise UnknownChartType('%s is an unknown chart type. Possible ' 'chart types are %s' % (chart_type, ','.join(types))) return globals()[chart_type + 'Chart'](w, h, auto_scale=auto_scale, x_range=x_range, y_range=y_range)
[docs] def download(self): pass