diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..82aa411 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: pipx install poetry + + - name: Install dependencies + run: poetry install --with dev + + - name: Lint + run: poetry run ruff check . + + - name: Check formatting + run: poetry run ruff format --check . + + - name: Run tests + run: poetry run python -m pytest tests.py diff --git a/.gitignore b/.gitignore index 0d7594b..df6a37f 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,8 @@ htmlcov/ .coverage .coverage.* .cache +.pytest_cache/ +.ruff_cache/ nosetests.xml coverage.xml *,cover diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d30c0c9..0000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python -python: - - 3.9 -cache: - pip: true -before_install: - - pip install poetry -install: - - poetry install -script: - - poetry run python tests.py - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bc10635 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## 2.1.0 + +- Add a configurable `timeout` (default 60 seconds) to all network requests so + `get_bytes()`, `get_short_url()`, and `to_file()` no longer hang indefinitely. +- Drop support for end-of-life Python 3.7; the minimum supported version is now + Python 3.8. Tested against Python 3.8 through 3.13. +- Ship inline type hints and a `py.typed` marker (PEP 561). +- Derive the client `User-Agent` version from the installed package metadata so + it stays in sync with the release. +- Modernize packaging: PEP 621 `[project]` metadata, the `poetry.core.masonry.api` + build backend, and PEP 735 dependency groups. +- Replace Travis CI with GitHub Actions and `autopep8` with `ruff` for linting + and formatting. +- Remove leftover Python 2 compatibility code. + +## 2.0.0 + +- Drop support for Python versions earlier than 3.7. +- Set a `User-Agent` header on requests. +- Show a detailed error message when chart creation fails. +- Add support for the `version` parameter. + +## 1.0.1 + +- Last release supporting Python 2 and Python < 3.7. diff --git a/README.md b/README.md index 465d9eb..cd43ace 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # quickchart-python -[![Build Status](https://travis-ci.com/typpo/quickchart-python.svg?branch=master)](https://travis-ci.com/typpo/quickchart-python) +[![CI](https://github.com/typpo/quickchart-python/actions/workflows/ci.yml/badge.svg)](https://github.com/typpo/quickchart-python/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/quickchart.io)](https://pypi.org/project/quickchart-io/) [![PyPI - License](https://img.shields.io/pypi/l/quickchart.io)](https://pypi.org/project/quickchart-io/) @@ -13,7 +13,7 @@ Use the `quickchart` library in this project, or install through [pip](https://p pip install quickchart.io ``` -As of release 2.0, this package requires >= Python 3.7. If you need support for earlier versions of Python, use [version 1.0.1](https://pypi.org/project/quickchart-io/1.0.1/). +This package requires Python 3.8 or later. If you need support for Python 3.7, use [version 2.0.0](https://pypi.org/project/quickchart-io/2.0.0/); for earlier versions of Python, use [version 1.0.1](https://pypi.org/project/quickchart-io/1.0.1/). # Usage @@ -132,6 +132,9 @@ Override the host of the chart render server. Defaults to quickchart.io. ### key: str Set an API key that will be included with the request. +### timeout: float +Timeout in seconds for the network requests made by `get_bytes()`, `get_short_url()`, and `to_file()`. Defaults to 60.0. Set to `None` to disable the timeout. + ## Getting URLs There are two ways to get a URL for your chart object. @@ -159,3 +162,25 @@ Writes the chart image to a file path. ## More examples Checkout the `examples` directory to see other usage. + +# Development + +This project uses [Poetry](https://python-poetry.org/) for packaging and dependency management. + +``` +# Install dependencies (including dev tools) +poetry install --with dev + +# Run the test suite +poetry run python -m pytest tests.py + +# Lint and format +poetry run ruff check . +poetry run ruff format . +``` + +The tests that hit the live quickchart.io service are skipped by default. Set `QUICKCHART_NETWORK_TESTS=1` to run them: + +``` +QUICKCHART_NETWORK_TESTS=1 poetry run python -m pytest tests.py +``` diff --git a/__init__.py b/__init__.py index ba8b5ef..10f147f 100644 --- a/__init__.py +++ b/__init__.py @@ -1 +1 @@ -from quickchart import * +from quickchart import * # noqa: F401,F403 diff --git a/examples/discord_bot.py b/examples/discord_bot.py index 1eb4c30..45710f8 100644 --- a/examples/discord_bot.py +++ b/examples/discord_bot.py @@ -1,20 +1,23 @@ from io import BytesIO import discord -from PIL import Image from discord.ext import commands +from PIL import Image + from quickchart import QuickChart -description = '''An example bot to showcase the use of QuickChart with discord.py module.''' +description = ( + """An example bot to showcase the use of QuickChart with discord.py module.""" +) intents = discord.Intents.default() -bot = commands.Bot(command_prefix='!', description=description, intents=intents) +bot = commands.Bot(command_prefix="!", description=description, intents=intents) @bot.event async def on_ready(): - print(f'Logged in as {bot.user.name}') + print(f"Logged in as {bot.user.name}") @bot.command() @@ -27,21 +30,25 @@ async def graph(ctx): "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] - } + "datasets": [{"label": "Foo", "data": [1, 2]}], + }, } with Image.open(BytesIO(qc.get_bytes())) as chat_sample: - output_buffer = BytesIO() # By using BytesIO we don't have to save the file in our system. + output_buffer = ( + BytesIO() + ) # By using BytesIO we don't have to save the file in our system. chat_sample.save(output_buffer, "png") output_buffer.seek(0) - await ctx.send(file=discord.File(fp=output_buffer, filename="chart_sample.png")) # Change the file name accordingly. + await ctx.send( + file=discord.File(fp=output_buffer, filename="chart_sample.png") + ) # Change the file name accordingly. @graph.before_invoke async def before_test_invoke(ctx): - await ctx.trigger_typing() # Take time to render and send graph so triggering typing to reflect bot action. + await ( + ctx.trigger_typing() + ) # Take time to render and send graph so triggering typing to reflect bot action. + -bot.run('token') +bot.run("token") diff --git a/examples/gradient_fill.py b/examples/gradient_fill.py index 11f3713..8dad7c3 100644 --- a/examples/gradient_fill.py +++ b/examples/gradient_fill.py @@ -8,12 +8,16 @@ "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2], - "backgroundColor": QuickChartFunction("getGradientFillHelper('vertical', ['rgba(63, 100, 249, 0.2)', 'rgba(255, 255, 255, 0.2)'])"), - }] - } + "datasets": [ + { + "label": "Foo", + "data": [1, 2], + "backgroundColor": QuickChartFunction( + "getGradientFillHelper('vertical', ['rgba(63, 100, 249, 0.2)', 'rgba(255, 255, 255, 0.2)'])" + ), + } + ], + }, } print(qc.get_url()) diff --git a/examples/short_url_example.py b/examples/short_url_example.py index d3b41cf..2ee6234 100644 --- a/examples/short_url_example.py +++ b/examples/short_url_example.py @@ -7,11 +7,13 @@ "type": "line", "data": { "labels": list(range(0, 100)), - "datasets": [{ - "label": "Foo", - "data": random.sample(range(0, 100), 100), - }] - } + "datasets": [ + { + "label": "Foo", + "data": random.sample(range(0, 100), 100), + } + ], + }, } print(qc.get_short_url()) diff --git a/examples/short_url_example_with_function.py b/examples/short_url_example_with_function.py index 6835b49..6976f59 100644 --- a/examples/short_url_example_with_function.py +++ b/examples/short_url_example_with_function.py @@ -1,7 +1,7 @@ from quickchart import QuickChart qc = QuickChart() -qc.config = '''{ +qc.config = """{ type: 'bar', data: { labels: ['Q1', 'Q2', 'Q3', 'Q4'], @@ -24,7 +24,7 @@ }] } } -}''' +}""" print(qc.get_short_url()) # diff --git a/examples/simple_example.py b/examples/simple_example.py index 5246766..6c3a41b 100644 --- a/examples/simple_example.py +++ b/examples/simple_example.py @@ -8,11 +8,8 @@ "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] - } + "datasets": [{"label": "Foo", "data": [1, 2]}], + }, } print(qc.get_url()) diff --git a/examples/simple_example_with_function.py b/examples/simple_example_with_function.py index 3f341d4..6cfb773 100644 --- a/examples/simple_example_with_function.py +++ b/examples/simple_example_with_function.py @@ -4,7 +4,7 @@ qc.width = 600 qc.height = 300 qc.device_pixel_ratio = 2.0 -qc.config = '''{ +qc.config = """{ type: 'bar', data: { labels: ['Q1', 'Q2', 'Q3', 'Q4'], @@ -27,6 +27,6 @@ }] } } -}''' +}""" print(qc.get_url()) diff --git a/examples/using_quickchartfunction.py b/examples/using_quickchartfunction.py index 0e3364c..0664ef0 100644 --- a/examples/using_quickchartfunction.py +++ b/examples/using_quickchartfunction.py @@ -10,31 +10,25 @@ "type": "bar", "data": { "labels": [datetime(2020, 1, 15), datetime(2021, 1, 15)], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] + "datasets": [{"label": "Foo", "data": [1, 2]}], }, "options": { "scales": { - "yAxes": [{ - "ticks": { - "callback": QuickChartFunction('(val) => val + "k"') - } - }, { - "ticks": { - "callback": QuickChartFunction('''function(val) { + "yAxes": [ + {"ticks": {"callback": QuickChartFunction('(val) => val + "k"')}}, + { + "ticks": { + "callback": QuickChartFunction("""function(val) { return val + '???'; - }''') - } - }], - "xAxes": [{ - "ticks": { - "callback": QuickChartFunction('(val) => "$" + val') - } - }] + }""") + } + }, + ], + "xAxes": [ + {"ticks": {"callback": QuickChartFunction('(val) => "$" + val')}} + ], } - } + }, } print(qc.get_url()) diff --git a/examples/write_file.py b/examples/write_file.py index 7aa849b..0b4d72f 100644 --- a/examples/write_file.py +++ b/examples/write_file.py @@ -8,13 +8,10 @@ "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] - } + "datasets": [{"label": "Foo", "data": [1, 2]}], + }, } -qc.to_file('/tmp/mychart.png') +qc.to_file("/tmp/mychart.png") -print('Done.') +print("Done.") diff --git a/poetry.lock b/poetry.lock index ce82696..c754ec8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,124 +1,279 @@ -[[package]] -name = "autopep8" -version = "1.7.0" -description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -pycodestyle = ">=2.9.1" -toml = "*" +# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. [[package]] name = "certifi" version = "2022.9.24" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, +] [[package]] name = "charset-normalizer" version = "2.1.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.6.0" +groups = ["main"] +files = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] [package.extras] -unicode_backport = ["unicodedata2"] +unicode-backport = ["unicodedata2"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] [[package]] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] [[package]] -name = "pycodestyle" -version = "2.9.1" -description = "Python style guide checker" -category = "dev" +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "requests" -version = "2.28.1" +version = "2.32.4" description = "Python HTTP for Humans." -category = "main" optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<3" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" +name = "ruff" +version = "0.15.20" +description = "An extremely fast Python linter and code formatter, written in Rust." optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"}, + {file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"}, + {file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"}, + {file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"}, + {file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"}, + {file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"}, + {file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"}, +] + +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] [[package]] name = "urllib3" -version = "1.26.12" +version = "2.2.3" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] [metadata] -lock-version = "1.1" -python-versions = ">=3.7, <4" -content-hash = "1aa6afcebedfcbf9c7ea21f4045e6b59825b1beaadd670bd71db353aac61f2f7" - -[metadata.files] -autopep8 = [ - {file = "autopep8-1.7.0-py2.py3-none-any.whl", hash = "sha256:6f09e90a2be784317e84dc1add17ebfc7abe3924239957a37e5040e27d812087"}, - {file = "autopep8-1.7.0.tar.gz", hash = "sha256:ca9b1a83e53a7fad65d731dc7a2a2d50aa48f43850407c59f6a1a306c4201142"}, -] -certifi = [ - {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, - {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, -] -charset-normalizer = [ - {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, - {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, -] -idna = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] -pycodestyle = [ - {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, - {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, -] -requests = [ - {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, - {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, -] -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.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, - {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, -] +lock-version = "2.1" +python-versions = ">=3.8" +content-hash = "86aae3a2645c58646068eb555318097cd4c09decef7f112953cc0fb2fc7f4dd2" diff --git a/pyproject.toml b/pyproject.toml index fd3c977..3f680ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,57 @@ -[tool.poetry] +[project] name = "quickchart.io" -version = "2.0.0" +version = "2.1.0" description = "A client for quickchart.io, a service that generates static chart images" keywords = ["chart api", "chart image", "charts"] -authors = ["Ian Webster "] -maintainers = ["Ian Webster "] -homepage = "https://quickchart.io/" +authors = [{ name = "Ian Webster", email = "ianw_pypi@ianww.com" }] +maintainers = [{ name = "Ian Webster", email = "ianw_pypi@ianww.com" }] readme = "README.md" -repository = "https://github.com/typpo/quickchart-python" -license = "MIT" -packages = [ - { include = "quickchart" }, +license = { text = "MIT" } +requires-python = ">=3.8" +dependencies = ["requests>=2.28.1"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Typing :: Typed", ] -[tool.poetry.dependencies] -python = ">=3.7, <4" -requests = "^2.28.1" +[project.urls] +Homepage = "https://quickchart.io/" +Repository = "https://github.com/typpo/quickchart-python" + +[dependency-groups] +dev = ["pytest>=8.0", "ruff>=0.6"] + +[tool.poetry] +packages = [{ include = "quickchart" }] + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +# We still support Python 3.8, where `X | Y` union syntax is not natively +# available at runtime, so keep the typing.Optional / typing.Union spellings. +ignore = ["UP007", "UP045"] -[tool.poetry.dev-dependencies] -autopep8 = "^1.5.5" +[tool.ruff.lint.per-file-ignores] +# Assertions on URL-encoded strings cannot be reasonably line-wrapped. +"tests.py" = ["E501"] +# Example scripts contain long chart strings and illustrative comments. +"examples/*" = ["E501"] [build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +requires = ["poetry-core>=2.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/quickchart/__init__.py b/quickchart/__init__.py index 22e8385..1f7bda3 100644 --- a/quickchart/__init__.py +++ b/quickchart/__init__.py @@ -1,122 +1,130 @@ -"""A python client for quickchart.io, a web service that generates static +"""A Python client for quickchart.io, a web service that generates static charts.""" +from __future__ import annotations + import datetime import json import re +from importlib import metadata +from typing import Any, Optional, Union +from urllib.parse import urlencode + +import requests + try: - from urllib import urlencode -except: - # For Python 3 - from urllib.parse import urlencode + __version__ = metadata.version("quickchart.io") +except metadata.PackageNotFoundError: # pragma: no cover + # Package is not installed (e.g. running from a source checkout). + __version__ = "0.0.0" -USER_AGENT = 'quickchart-python (2.0.0)' +USER_AGENT = f"quickchart-python ({__version__})" -FUNCTION_DELIMITER_RE = re.compile('\"__BEGINFUNCTION__(.*?)__ENDFUNCTION__\"') +FUNCTION_DELIMITER_RE = re.compile(r'"__BEGINFUNCTION__(.*?)__ENDFUNCTION__"') class QuickChartFunction: - def __init__(self, script): + def __init__(self, script: str): self.script = script - def __repr__(self): + def __repr__(self) -> str: return self.script -def serialize(obj): +def serialize(obj: Any) -> Any: if isinstance(obj, QuickChartFunction): - return '__BEGINFUNCTION__' + obj.script + '__ENDFUNCTION__' + 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=(',', ':')) +def dump_json(obj: Any) -> str: + ret = json.dumps(obj, default=serialize, separators=(",", ":")) ret = FUNCTION_DELIMITER_RE.sub( - lambda match: json.loads('"' + match.group(1) + '"'), ret) + lambda match: json.loads('"' + match.group(1) + '"'), ret + ) return ret class QuickChart: - def __init__(self): - self.config = None - self.width = 500 - self.height = 300 - self.background_color = '#ffffff' - self.device_pixel_ratio = 1.0 - self.format = 'png' - self.version = '2.9.4' - self.key = None - self.scheme = 'https' - self.host = 'quickchart.io' - - def is_valid(self): + def __init__(self) -> None: + self.config: Optional[Union[dict, str]] = None + self.width: int = 500 + self.height: int = 300 + self.background_color: str = "#ffffff" + self.device_pixel_ratio: float = 1.0 + self.format: str = "png" + self.version: str = "2.9.4" + self.key: Optional[str] = None + self.scheme: str = "https" + self.host: str = "quickchart.io" + self.timeout: Optional[float] = 60.0 + + def is_valid(self) -> bool: return self.config is not None - def get_url_base(self): - return '%s://%s' % (self.scheme, self.host) + def get_url_base(self) -> str: + return f"{self.scheme}://{self.host}" + + def _serialized_config(self) -> str: + return dump_json(self.config) if isinstance(self.config, dict) else self.config - def get_url(self): + def get_url(self) -> str: if not self.is_valid(): raise RuntimeError( - 'You must set the `config` attribute before generating a url') + "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, - 'h': self.height, - 'bkg': self.background_color, - 'devicePixelRatio': self.device_pixel_ratio, - 'f': self.format, - 'v': self.version, + "c": self._serialized_config(), + "w": self.width, + "h": self.height, + "bkg": self.background_color, + "devicePixelRatio": self.device_pixel_ratio, + "f": self.format, + "v": self.version, } if self.key: - params['key'] = self.key - return '%s/chart?%s' % (self.get_url_base(), urlencode(params)) - - def _post(self, url): - try: - import requests - except: - raise RuntimeError('Could not find `requests` dependency') + params["key"] = self.key + return f"{self.get_url_base()}/chart?{urlencode(params)}" + def _post(self, url: str) -> requests.Response: postdata = { - 'chart': dump_json(self.config) if type(self.config) == dict else self.config, - 'width': self.width, - 'height': self.height, - 'backgroundColor': self.background_color, - 'devicePixelRatio': self.device_pixel_ratio, - 'format': self.format, - 'version': self.version, + "chart": self._serialized_config(), + "width": self.width, + "height": self.height, + "backgroundColor": self.background_color, + "devicePixelRatio": self.device_pixel_ratio, + "format": self.format, + "version": self.version, } if self.key: - postdata['key'] = self.key + postdata["key"] = self.key headers = { - 'user-agent': USER_AGENT, + "user-agent": USER_AGENT, } - resp = requests.post(url, json=postdata, headers=headers) + resp = requests.post(url, json=postdata, headers=headers, timeout=self.timeout) if resp.status_code != 200: - err_description = resp.headers.get('x-quickchart-error') + err_description = resp.headers.get("x-quickchart-error") + detail = f"\n{err_description}" if err_description else "" raise RuntimeError( - 'Invalid response code from chart creation endpoint: %d%s' - % (resp.status_code, '\n%s' % err_description if err_description else '') + "Invalid response code from chart creation endpoint: " + f"{resp.status_code}{detail}" ) return resp - - def get_short_url(self): - resp = self._post('%s/chart/create' % self.get_url_base()) + def get_short_url(self) -> str: + resp = self._post(f"{self.get_url_base()}/chart/create") parsed = json.loads(resp.text) - if not parsed['success']: - raise RuntimeError( - 'Chart creation endpoint failed to create chart') - return parsed['url'] + if not parsed["success"]: + raise RuntimeError("Chart creation endpoint failed to create chart") + return parsed["url"] - def get_bytes(self): - resp = self._post('%s/chart' % self.get_url_base()) + def get_bytes(self) -> bytes: + resp = self._post(f"{self.get_url_base()}/chart") return resp.content - def to_file(self, path): + def to_file(self, path: str) -> None: content = self.get_bytes() - with open(path, 'wb') as f: + with open(path, "wb") as f: f.write(content) diff --git a/quickchart/py.typed b/quickchart/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/scripts/format.sh b/scripts/format.sh index 539db9d..59116d3 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -1,3 +1,5 @@ #!/bin/bash -e -poetry run autopep8 --in-place examples/*.py quickchart/*.py +# Format and autofix lint issues across the package, examples, and tests. +poetry run ruff format . +poetry run ruff check --fix . diff --git a/tests.py b/tests.py index 31b6a3f..3d3ba78 100644 --- a/tests.py +++ b/tests.py @@ -1,8 +1,15 @@ +import os import unittest from datetime import datetime from quickchart import QuickChart, QuickChartFunction +# Network-dependent tests hit the live quickchart.io service. They are skipped +# by default so the rest of the suite stays deterministic and offline-friendly. +# Set QUICKCHART_NETWORK_TESTS=1 to opt in. +RUN_NETWORK_TESTS = os.environ.get("QUICKCHART_NETWORK_TESTS") == "1" + + class TestQuickChart(unittest.TestCase): def test_simple(self): qc = QuickChart() @@ -13,35 +20,29 @@ def test_simple(self): "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] - } + "datasets": [{"label": "Foo", "data": [1, 2]}], + }, } url = qc.get_url() - self.assertIn('w=600', url) - self.assertIn('h=300', url) - self.assertIn('devicePixelRatio=2', url) - self.assertIn('Hello+world', url) + self.assertIn("w=600", url) + self.assertIn("h=300", url) + self.assertIn("devicePixelRatio=2", url) + self.assertIn("Hello+world", url) def test_version(self): qc = QuickChart() - qc.version = '3.4.0' + qc.version = "3.4.0" qc.config = { "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] - } + "datasets": [{"label": "Foo", "data": [1, 2]}], + }, } url = qc.get_url() - self.assertIn('v=3.4.0', url) + self.assertIn("v=3.4.0", url) def test_no_chart(self): qc = QuickChart() @@ -51,6 +52,7 @@ def test_no_chart(self): self.assertRaises(RuntimeError, qc.get_url) + @unittest.skipUnless(RUN_NETWORK_TESTS, "set QUICKCHART_NETWORK_TESTS=1 to run") def test_get_bytes(self): qc = QuickChart() qc.width = 600 @@ -59,44 +61,46 @@ def test_get_bytes(self): "type": "bar", "data": { "labels": ["Hello world", "Test"], - "datasets": [{ - "label": "Foo", - "data": [1, 2] - }] - } + "datasets": [{"label": "Foo", "data": [1, 2]}], + }, } 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') + "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) + 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__': +if __name__ == "__main__": unittest.main()