diff --git a/README.md b/README.md index c916d43..54c10a7 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,48 @@ The URLs will render an image of a chart: -## Customizing your chart +# Using Javascript functions in your chart + +Chart.js sometimes relies on Javascript functions (e.g. for formatting tick labels). There are a couple approaches: + + - Build chart configuration as a string instead of a Python object. See `examples/simple_example_with_function.py`. + - Build chart configuration as a Python object and include a placeholder string for the Javascript function. Then, find and replace it. + - Use the provided `QuickChartFunction` class. See `examples/using_quickchartfunction.py` for a full example. + +A short example using `QuickChartFunction`: +```py +qc = QuickChart() +qc.config = { + "type": "bar", + "data": { + "labels": ["A", "B"], + "datasets": [{ + "label": "Foo", + "data": [1, 2] + }] + }, + "options": { + "scales": { + "yAxes": [{ + "ticks": { + "callback": QuickChartFunction('(val) => val + "k"') + } + }], + "xAxes": [{ + "ticks": { + "callback": QuickChartFunction('''function(val) { + return val + '???'; + }''') + } + }] + } + } +} + +print(qc.get_url()) +``` + +# Customizing your chart You can set the following properties: @@ -75,6 +116,12 @@ The background color of the chart. Any valid HTML color works. Defaults to #ffff ### device_pixel_ratio: float The device pixel ratio of the chart. This will multiply the number of pixels by the value. This is usually used for retina displays. Defaults to 1.0. +### host +Override the host of the chart render server. Defaults to quickchart.io. + +### key +Set an API key that will be included with the request. + ## Getting URLs There are two ways to get a URL for your chart object. diff --git a/examples/using_quickchartfunction.py b/examples/using_quickchartfunction.py new file mode 100644 index 0000000..0e3364c --- /dev/null +++ b/examples/using_quickchartfunction.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from quickchart import QuickChart, QuickChartFunction + +qc = QuickChart() +qc.width = 600 +qc.height = 300 +qc.device_pixel_ratio = 2.0 +qc.config = { + "type": "bar", + "data": { + "labels": [datetime(2020, 1, 15), datetime(2021, 1, 15)], + "datasets": [{ + "label": "Foo", + "data": [1, 2] + }] + }, + "options": { + "scales": { + "yAxes": [{ + "ticks": { + "callback": QuickChartFunction('(val) => val + "k"') + } + }, { + "ticks": { + "callback": QuickChartFunction('''function(val) { + return val + '???'; + }''') + } + }], + "xAxes": [{ + "ticks": { + "callback": QuickChartFunction('(val) => "$" + val') + } + }] + } + } +} + +print(qc.get_url()) diff --git a/poetry.lock b/poetry.lock index a58ce95..0dd080a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,15 @@ +[[package]] +category = "dev" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +name = "autopep8" +optional = false +python-versions = "*" +version = "1.5.5" + +[package.dependencies] +pycodestyle = ">=2.6.0" +toml = "*" + [[package]] category = "main" description = "Python package for providing Mozilla's CA Bundle." @@ -22,6 +34,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.9" +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pycodestyle" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.6.0" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -40,6 +60,14 @@ urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "0.10.2" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -53,10 +81,15 @@ secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "cer socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [metadata] -content-hash = "8723aa442d99899ea654df0b67023a6c95a7dfee20d67cf214b88da8c5f399e9" +content-hash = "810271f04d060914ce66d44fdc2acdbd757749559758e7afb0220abd1cedcfa0" +lock-version = "1.0" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [metadata.files] +autopep8 = [ + {file = "autopep8-1.5.5-py2.py3-none-any.whl", hash = "sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea"}, + {file = "autopep8-1.5.5.tar.gz", hash = "sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443"}, +] certifi = [ {file = "certifi-2020.4.5.1-py2.py3-none-any.whl", hash = "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304"}, {file = "certifi-2020.4.5.1.tar.gz", hash = "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"}, @@ -69,10 +102,19 @@ idna = [ {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, ] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] requests = [ + {file = "requests-2.23.0-py2.7.egg", hash = "sha256:5d2d0ffbb515f39417009a46c14256291061ac01ba8f875b90cad137de83beb4"}, {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] urllib3 = [ {file = "urllib3-1.22-py2.py3-none-any.whl", hash = "sha256:06330f386d6e4b195fbfc736b297f58c5a892e4440e54d294d7004e3a9bbea1b"}, {file = "urllib3-1.22.tar.gz", hash = "sha256:cc44da8e1145637334317feebd728bd869a35285b93cbb4cca2577da7e62db4f"}, diff --git a/pyproject.toml b/pyproject.toml index 0750fe6..b48e6e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "quickchart.io" -version = "0.1.5" +version = "0.2.0" description = "A client for quickchart.io, a service that generates static chart images" keywords = ["chart api", "chart image", "charts"] authors = ["Ian Webster "] @@ -18,6 +18,7 @@ python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" requests = "^2.23.0" [tool.poetry.dev-dependencies] +autopep8 = "^1.5.5" [build-system] requires = ["poetry>=0.12"] diff --git a/quickchart/__init__.py b/quickchart/__init__.py index 0145fb9..80a8cef 100644 --- a/quickchart/__init__.py +++ b/quickchart/__init__.py @@ -1,13 +1,41 @@ """A python client for quickchart.io, a web service that generates static charts.""" +import datetime import json +import re try: from urllib import urlencode except: # For Python 3 from urllib.parse import urlencode +FUNCTION_DELIMITER_RE = re.compile('\"__BEGINFUNCTION__(.*?)__ENDFUNCTION__\"') + + +class QuickChartFunction: + def __init__(self, script): + self.script = script + + def __repr__(self): + return self.script + + +def serialize(obj): + if isinstance(obj, QuickChartFunction): + return '__BEGINFUNCTION__' + obj.script + '__ENDFUNCTION__' + if isinstance(obj, (datetime.date, datetime.datetime)): + return obj.isoformat() + return obj.__dict__ + + +def dump_json(obj): + ret = json.dumps(obj, default=serialize, separators=(',', ':')) + ret = FUNCTION_DELIMITER_RE.sub( + lambda match: json.loads('"' + match.group(1) + '"'), ret) + return ret + + class QuickChart: def __init__(self): self.config = None @@ -17,15 +45,21 @@ def __init__(self): self.device_pixel_ratio = 1.0 self.format = 'png' self.key = None + self.scheme = 'https' + self.host = 'quickchart.io' def is_valid(self): return self.config is not None + def get_url_base(self): + return '%s://%s' % (self.scheme, self.host) + def get_url(self): if not self.is_valid(): - raise RuntimeError('You must set the `config` attribute before generating a url') + raise RuntimeError( + 'You must set the `config` attribute before generating a url') params = { - 'c': json.dumps(self.config) if type(self.config) == dict else self.config, + 'c': dump_json(self.config) if type(self.config) == dict else self.config, 'w': self.width, 'h': self.height, 'bkg': self.background_color, @@ -34,7 +68,7 @@ def get_url(self): } if self.key: params['key'] = self.key - return 'https://quickchart.io/chart?%s' % urlencode(params) + return '%s/chart?%s' % (self.get_url_base(), urlencode(params)) def _post(self, url): try: @@ -43,7 +77,7 @@ def _post(self, url): raise RuntimeError('Could not find `requests` dependency') postdata = { - 'chart': json.dumps(self.config) if type(self.config) == dict else self.config, + 'chart': dump_json(self.config) if type(self.config) == dict else self.config, 'width': self.width, 'height': self.height, 'backgroundColor': self.background_color, @@ -54,22 +88,23 @@ def _post(self, url): postdata['key'] = self.key resp = requests.post(url, json=postdata) if resp.status_code != 200: - raise RuntimeError('Invalid response code from chart creation endpoint') + raise RuntimeError( + 'Invalid response code from chart creation endpoint') return resp def get_short_url(self): - resp = self._post('https://quickchart.io/chart/create') + resp = self._post('%s/chart/create' % self.get_url_base()) parsed = json.loads(resp.text) if not parsed['success']: - raise RuntimeError('Failure response status from chart creation endpoint') + raise RuntimeError( + 'Failure response status from chart creation endpoint') return parsed['url'] def get_bytes(self): - resp = self._post('https://quickchart.io/chart') + resp = self._post('%s/chart' % self.get_url_base()) return resp.content def to_file(self, path): content = self.get_bytes() with open(path, 'wb') as f: f.write(content) - diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..539db9d --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e + +poetry run autopep8 --in-place examples/*.py quickchart/*.py diff --git a/tests.py b/tests.py index 9692dcd..a3d29cf 100644 --- a/tests.py +++ b/tests.py @@ -1,6 +1,7 @@ import unittest +from datetime import datetime -from quickchart import QuickChart +from quickchart import QuickChart, QuickChartFunction class TestQuickChart(unittest.TestCase): def test_simple(self): @@ -49,5 +50,36 @@ def test_get_bytes(self): } self.assertTrue(len(qc.get_bytes()) > 8000) + def test_with_function_and_dates(self): + qc = QuickChart() + qc.config = { + "type": "bar", + "data": { + "labels": [datetime(2020, 1, 15), datetime(2021, 1, 15)], + "datasets": [{ + "label": "Foo", + "data": [1, 2] + }] + }, + "options": { + "scales": { + "yAxes": [{ + "ticks": { + "callback": QuickChartFunction('(val) => val + "k"') + } + }], + "xAxes": [{ + "ticks": { + "callback": QuickChartFunction('(val) => "$" + val') + } + }] + } + } + } + + url = qc.get_url() + self.assertIn('7B%22ticks%22%3A%7B%22callback%22%3A%28val%29+%3D%3E+%22%24%22+%2B+val%7D%7D%5D%7D%7D%7D', url) + self.assertIn('2020-01-15T00%3A00%3A00', url) + if __name__ == '__main__': unittest.main()