From 29f2146497dcf4120f1ad5f1fbba862a328ad11b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 17:34:02 +0000 Subject: [PATCH 1/4] Modernize packaging, tooling, and client code Bring the library up to date after a long gap: Packaging - Migrate pyproject.toml to PEP 621 [project] metadata with the poetry.core.masonry.api build backend and PEP 735 dependency groups. - Drop end-of-life Python 3.7; minimum is now Python 3.8. Add classifiers for 3.8 through 3.13. - Ship type hints plus a py.typed marker (PEP 561). Client code - Add a configurable request timeout (default 60s) so network calls no longer hang indefinitely. - Remove Python 2 compatibility shims, use isinstance checks and f-strings, import requests at module load, and add type annotations. - Derive the User-Agent version from installed package metadata. CI and tooling - Replace Travis with a GitHub Actions matrix (Python 3.8-3.13). - Replace autopep8 with ruff for linting and formatting. - Make the live-service test opt-in via QUICKCHART_NETWORK_TESTS so the default suite is deterministic and offline-friendly. Docs - Update README badges, version notes, timeout docs, and add a Development section. Add CHANGELOG.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LDLZoKFv4Y4Q6rFYMn4MoL --- .github/workflows/ci.yml | 37 +++ .gitignore | 2 + .travis.yml | 12 - CHANGELOG.md | 27 ++ README.md | 29 +- __init__.py | 2 +- examples/discord_bot.py | 33 ++- examples/gradient_fill.py | 16 +- examples/short_url_example.py | 12 +- examples/short_url_example_with_function.py | 4 +- examples/simple_example.py | 7 +- examples/simple_example_with_function.py | 4 +- examples/using_quickchartfunction.py | 34 +-- examples/write_file.py | 11 +- poetry.lock | 303 +++++++++++++++----- pyproject.toml | 64 +++-- quickchart/__init__.py | 148 +++++----- quickchart/py.typed | 0 scripts/format.sh | 4 +- tests.py | 94 +++--- 20 files changed, 562 insertions(+), 281 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 quickchart/py.typed 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() From 44af2a625d1655d823457dd45cb6a7df1d92213f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 16:45:08 +0000 Subject: [PATCH 2/4] Drop Python 3.8, require Python 3.9+ The CI virtualenv seeder no longer ships pip/setuptools wheels for the end-of-life Python 3.8 (its oldest supported target is now 3.9), which broke the 3.8 matrix job. Python 3.8 reached end of life in October 2024, so drop it rather than pin older tooling. - requires-python is now >=3.9; remove the 3.8 classifier and CI matrix entry. - Bump the ruff target-version to py39. - Update README and CHANGELOG accordingly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LDLZoKFv4Y4Q6rFYMn4MoL --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 ++-- README.md | 2 +- pyproject.toml | 7 +++---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82aa411..ead43aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index bc10635..78e4b8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ - 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. +- Drop support for end-of-life Python 3.7 and 3.8; the minimum supported version + is now Python 3.9. Tested against Python 3.9 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. diff --git a/README.md b/README.md index cd43ace..fa89d6e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Use the `quickchart` library in this project, or install through [pip](https://p pip install quickchart.io ``` -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/). +This package requires Python 3.9 or later. If you need support for Python 3.7 or 3.8, 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 diff --git a/pyproject.toml b/pyproject.toml index 3f680ef..af732c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [{ name = "Ian Webster", email = "ianw_pypi@ianww.com" }] maintainers = [{ name = "Ian Webster", email = "ianw_pypi@ianww.com" }] readme = "README.md" license = { text = "MIT" } -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = ["requests>=2.28.1"] classifiers = [ "Development Status :: 5 - Production/Stable", @@ -16,7 +16,6 @@ classifiers = [ "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", @@ -38,11 +37,11 @@ packages = [{ include = "quickchart" }] [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py39" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] -# We still support Python 3.8, where `X | Y` union syntax is not natively +# We still support Python 3.9, where `X | Y` union syntax is not natively # available at runtime, so keep the typing.Optional / typing.Union spellings. ignore = ["UP007", "UP045"] From 39be4bdc96c59e2487a90c99042aaf2b8e531fe1 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 30 Jun 2026 10:03:50 -0700 Subject: [PATCH 3/4] fix(ci): refresh Poetry lock metadata --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index c754ec8..a0d659a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "certifi" @@ -275,5 +275,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" -python-versions = ">=3.8" -content-hash = "86aae3a2645c58646068eb555318097cd4c09decef7f112953cc0fb2fc7f4dd2" +python-versions = ">=3.9" +content-hash = "07600d81db1b1466017ff88cd24a971e7ab5ddaf6e332b25cd474cd519d74b09" From 26372e3c9074215313a58bc5bdc19046cfc42d29 Mon Sep 17 00:00:00 2001 From: Ian Webster Date: Tue, 30 Jun 2026 10:24:15 -0700 Subject: [PATCH 4/4] chore(release): bump version to 3.0.0 --- CHANGELOG.md | 2 +- README.md | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e4b8c..3de8f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2.1.0 +## 3.0.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. diff --git a/README.md b/README.md index fa89d6e..7bb2c81 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Use the `quickchart` library in this project, or install through [pip](https://p pip install quickchart.io ``` -This package requires Python 3.9 or later. If you need support for Python 3.7 or 3.8, 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/). +Version 3.0.0 and later require Python 3.9 or later. If you need support for Python 3.7 or 3.8, 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 diff --git a/pyproject.toml b/pyproject.toml index af732c6..4db66b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quickchart.io" -version = "2.1.0" +version = "3.0.0" description = "A client for quickchart.io, a service that generates static chart images" keywords = ["chart api", "chart image", "charts"] authors = [{ name = "Ian Webster", email = "ianw_pypi@ianww.com" }]