From 2a8ab9e969aa2314a6694f57842b2f537b143afb Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:14:42 -0800 Subject: [PATCH 1/9] Add better function support --- examples/using_quickchartfunction.py | 34 ++++++++++++++++++++++++++++ quickchart/__init__.py | 24 ++++++++++++++++++-- tests.py | 33 ++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 examples/using_quickchartfunction.py diff --git a/examples/using_quickchartfunction.py b/examples/using_quickchartfunction.py new file mode 100644 index 0000000..383c097 --- /dev/null +++ b/examples/using_quickchartfunction.py @@ -0,0 +1,34 @@ +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"') + } + }], + "xAxes": [{ + "ticks": { + "callback": QuickChartFunction('(val) => "$" + val') + } + }] + } + } +} + +print(qc.get_url()) diff --git a/quickchart/__init__.py b/quickchart/__init__.py index 0145fb9..4989a89 100644 --- a/quickchart/__init__.py +++ b/quickchart/__init__.py @@ -1,13 +1,33 @@ """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 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 @@ -25,7 +45,7 @@ def get_url(self): if not self.is_valid(): 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, @@ -43,7 +63,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, diff --git a/tests.py b/tests.py index 9692dcd..0c4d1b7 100644 --- a/tests.py +++ b/tests.py @@ -1,6 +1,6 @@ import unittest -from quickchart import QuickChart +from quickchart import QuickChart, QuickChartFunction class TestQuickChart(unittest.TestCase): def test_simple(self): @@ -49,5 +49,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() From 6920d5ab31e7c93cd14580dda6234637bf967980 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:17:10 -0800 Subject: [PATCH 2/9] Add pep8 and format --- examples/using_quickchartfunction.py | 40 ++++++++++++------------- poetry.lock | 44 +++++++++++++++++++++++++++- pyproject.toml | 1 + quickchart/__init__.py | 17 +++++++---- scripts/format.sh | 3 ++ 5 files changed, 79 insertions(+), 26 deletions(-) create mode 100755 scripts/format.sh diff --git a/examples/using_quickchartfunction.py b/examples/using_quickchartfunction.py index 383c097..cb96310 100644 --- a/examples/using_quickchartfunction.py +++ b/examples/using_quickchartfunction.py @@ -7,28 +7,28 @@ 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"') + "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') + } + }] } - }], - "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..3ba5374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 4989a89..aaff898 100644 --- a/quickchart/__init__.py +++ b/quickchart/__init__.py @@ -12,10 +12,12 @@ FUNCTION_DELIMITER_RE = re.compile('\"__BEGINFUNCTION__(.*?)__ENDFUNCTION__\"') + class QuickChartFunction: def __init__(self, script): self.script = script + def serialize(obj): if isinstance(obj, QuickChartFunction): return '__BEGINFUNCTION__' + obj.script + '__ENDFUNCTION__' @@ -23,11 +25,14 @@ def serialize(obj): 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) + ret = FUNCTION_DELIMITER_RE.sub( + lambda match: json.loads('"' + match.group(1) + '"'), ret) return ret + class QuickChart: def __init__(self): self.config = None @@ -43,7 +48,8 @@ def is_valid(self): 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': dump_json(self.config) if type(self.config) == dict else self.config, 'w': self.width, @@ -74,14 +80,16 @@ 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') 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): @@ -92,4 +100,3 @@ 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 From d0ab03cd21668a573d3cb003e3bbf021d1c39007 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:22:18 -0800 Subject: [PATCH 3/9] update readme and function example --- README.md | 43 +++++++++++++++++++++++++++- examples/using_quickchartfunction.py | 6 ++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c916d43..f20a5d1 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,48 @@ The URLs will render an image of a chart: -## Customizing your chart +# Using with functions + +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: diff --git a/examples/using_quickchartfunction.py b/examples/using_quickchartfunction.py index cb96310..0e3364c 100644 --- a/examples/using_quickchartfunction.py +++ b/examples/using_quickchartfunction.py @@ -21,6 +21,12 @@ "ticks": { "callback": QuickChartFunction('(val) => val + "k"') } + }, { + "ticks": { + "callback": QuickChartFunction('''function(val) { + return val + '???'; + }''') + } }], "xAxes": [{ "ticks": { From 0a5c1e948dd920789c6e91ec0ecaf460b978ae0c Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:22:20 -0800 Subject: [PATCH 4/9] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3ba5374..1e5dc67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "quickchart.io" -version = "0.1.5" +version = "0.2.5" description = "A client for quickchart.io, a service that generates static chart images" keywords = ["chart api", "chart image", "charts"] authors = ["Ian Webster "] From ba476bada5a53489b63c24aed93393130a54a36b Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:25:40 -0800 Subject: [PATCH 5/9] add host, scheme, and key --- README.md | 6 ++++++ quickchart/__init__.py | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f20a5d1..a8b2418 100644 --- a/README.md +++ b/README.md @@ -116,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/quickchart/__init__.py b/quickchart/__init__.py index aaff898..71b7218 100644 --- a/quickchart/__init__.py +++ b/quickchart/__init__.py @@ -42,10 +42,15 @@ 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( @@ -60,7 +65,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: @@ -85,7 +90,7 @@ def _post(self, url): 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( @@ -93,7 +98,7 @@ def get_short_url(self): 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): From bf6d56e06e17087fd6a6a1c6119b245984dd555c Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:26:36 -0800 Subject: [PATCH 6/9] clarify heading --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a8b2418..54c10a7 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ The URLs will render an image of a chart: -# Using with functions +# Using Javascript functions in your chart Chart.js sometimes relies on Javascript functions (e.g. for formatting tick labels). There are a couple approaches: From 2f45dace830fd03293b352b58524b76d61a0ab5e Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:27:26 -0800 Subject: [PATCH 7/9] add missing import --- tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests.py b/tests.py index 0c4d1b7..a3d29cf 100644 --- a/tests.py +++ b/tests.py @@ -1,4 +1,5 @@ import unittest +from datetime import datetime from quickchart import QuickChart, QuickChartFunction From 3d405887d32350884b7ce48b0787ceb73d9f7abf Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 2 Mar 2021 23:54:14 -0800 Subject: [PATCH 8/9] add repr for QuickChartFunction --- quickchart/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/quickchart/__init__.py b/quickchart/__init__.py index 71b7218..80a8cef 100644 --- a/quickchart/__init__.py +++ b/quickchart/__init__.py @@ -17,6 +17,9 @@ class QuickChartFunction: def __init__(self, script): self.script = script + def __repr__(self): + return self.script + def serialize(obj): if isinstance(obj, QuickChartFunction): From b1d2a30a1773987c60a751e9f493d7afd8204774 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Wed, 3 Mar 2021 10:07:46 -0800 Subject: [PATCH 9/9] fix version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1e5dc67..b48e6e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "quickchart.io" -version = "0.2.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 "]