diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..7b31b57 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,21 @@ +name: Changelog + +on: + pull_request: + branches: [ main ] + +jobs: + changelog: + name: "Check changelog update" + runs-on: ubuntu-latest + steps: + - uses: tarides/changelog-check-action@v2 + with: + changelog: CHANGELOG.md + + changelog_success: + name: "Changelog success" + runs-on: ubuntu-latest + needs: [changelog] + steps: + - run: echo "Changelog workflow completed successfully" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f5efd9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + lint: + name: "Code style" + runs-on: ubuntu-latest + + steps: + - name: "Checkout sources" + uses: actions/checkout@v4 + + - name: "Set up Python 3.9" + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip + # tox version: https://github.com/tox-dev/tox/issues/2778 + python -m pip install tox==3.28.0 + + - name: "Test with tox for ubuntu-latest" + run: | + tox -e lint + env: + PLATFORM: ubuntu-latest + + coverage: + name: "Coverage" + runs-on: ubuntu-latest + + steps: + - name: "Checkout sources" + uses: actions/checkout@v4 + + - name: "Set up Python 3.8" + uses: actions/setup-python@v4 + with: + python-version: "3.8" + + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip + # tox version: https://github.com/tox-dev/tox/issues/2778 + python -m pip install coveralls tox==3.28.0 + + - name: "Check code coverage" + run: | + tox -e coverage + + - name: "Coveralls" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + coveralls --service=github + + test: + name: "Test ${{ matrix.os }} with Python ${{ matrix.python-version }}" + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: "Checkout sources" + uses: actions/checkout@v4 + + - name: "Set up Python ${{ matrix.python-version }}" + uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + + - name: "Install dependencies" + run: | + python -m pip install --upgrade pip + # tox version: https://github.com/tox-dev/tox/issues/2778 + python -m pip install tox==3.28.0 tox-gh-actions + + - name: "Test with tox for ${{ matrix.os }}" + run: | + tox + env: + PLATFORM: ${{ matrix.os }} + + ci_success: + name: "CI success" + runs-on: ubuntu-latest + needs: [coverage, lint, test] + steps: + - run: echo "CI workflow completed successfully" diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..fb5fa2d --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,26 @@ +name: PyPI + +on: + push: + tags: + - 'v*' + +jobs: + pip: + name: "PyPI checks" + runs-on: ubuntu-latest + + steps: + - name: "Checkout sources" + uses: actions/checkout@v4 + + - name: "Set up Python 3.9" + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: "Install package" + run: pip install uplot-python + + - name: "Import uplot" + run: python -c "import uplot" diff --git a/CHANGELOG.md b/CHANGELOG.md index de93b01..2f5fce9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- CICD: CI workflow +- CICD: Changelog workflow +- CICD: Update checkout action to v4 +- Complete API documentation +- Set precision of time legends to the millisecond + +### Changed + +- **Breaking:** Rename `timestamped` argument of `plot2` to `time` + +### Fixed + +- Clean up dead code +- Correct type annotations +- Format values in axes to update ticks when zooming +- Update `resources.path` to `resources.files` + +### Removed + +- Intermediate `array2string` utility function + ## [1.0.0] - 2024-10-29 - Extract this project from [foxplot](https://github.com/stephane-caron/foxplot) diff --git a/README.md b/README.md index b4defd8..d61055b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Python wrapper for [μPlot](https://github.com/leeoniya/uPlot) 📈 ## Installation +### From conda-forge + +```console +conda install -c conda-forge uplot-python +``` + ### From PyPI ```console @@ -19,11 +25,11 @@ import numpy as np import uplot t = np.linspace(0.0, 1.0, 10) -data = [t, np.exp(0.42 * t)] +data = [t, np.exp(0.42 * t)] # list of NumPy arrays opts = { "width": 1920, "height": 600, - "title": "Example with plot", + "title": "Example with uplot.plot", "series": [{}, { "stroke": "red", }, ], } @@ -40,7 +46,7 @@ t = np.linspace(0.0, 1.0, 10) uplot.plot2( t, [np.exp(0.1 * t), np.exp(-10.0 * t), np.cos(t)], - title="Example with plot2", + title="Example with uplot.plot2", left_labels=["exp(A t)", "exp(-B t)", "cos(t)"], ) ``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7b3a77e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# This file makes `coverage run -m unittest discover` discover tests. diff --git a/tests/test_color_picker.py b/tests/test_color_picker.py new file mode 100644 index 0000000..2177a3f --- /dev/null +++ b/tests/test_color_picker.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2023 Inria + +import unittest + +from uplot.color_picker import ColorPicker + + +class TestColorPicker(unittest.TestCase): + def test_color_picker(self): + picker = ColorPicker() + first_color = picker.get_next_color() + second_color = picker.get_next_color() + self.assertNotEqual(first_color, second_color) + + picker.reset() + reset_color = picker.get_next_color() + self.assertEqual(first_color, reset_color) diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py new file mode 100644 index 0000000..6087055 --- /dev/null +++ b/tests/test_generate_html.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2023 Inria + +import unittest + +import numpy as np + +from uplot.generate_html import generate_html + + +class TestGenerateHtml(unittest.TestCase): + def setUp(self): + t = np.linspace(0.0, 1.0, 10) + self.data = [t, np.exp(0.42 * t)] + self.t = t + + def test_generate_html(self): + test_title = "Test plot" + opts = { + "width": 1921, + "height": 601, + "title": test_title, + "scales": {"x": {"time": False}}, + "series": [{}, {"stroke": "red"}], + } + html = generate_html( + opts, + self.data, + resize=False, + ) + self.assertIn("1921", html) + self.assertIn("601", html) + self.assertIn(test_title, html) + + def test_resize(self): + opts = {"series": [{}, {"stroke": "red"}]} + html = generate_html(opts, self.data, resize=True) + self.assertIn('window.addEventListener("resize', html) diff --git a/tests/test_plot.py b/tests/test_plot.py new file mode 100644 index 0000000..341c647 --- /dev/null +++ b/tests/test_plot.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2023 Inria + +import unittest + +import numpy as np + +import uplot + + +class TestPlot(unittest.TestCase): + def test_plot(self): + t = np.linspace(0.0, 1.0, 10) + data = [ + t, + np.exp(0.42 * t), + ] + opts = { + "width": 1920, + "height": 600, + "title": "Simple plot", + "scales": { + "x": { + "time": False, + }, + }, + "series": [ + {}, + { + "stroke": "red", + "fill": "rgba(255,0,0,0.1)", + }, + ], + } + uplot.plot(opts, data) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..6a51b24 --- /dev/null +++ b/tox.ini @@ -0,0 +1,43 @@ +[tox] +isolated_build = True +envlist = + coverage + lint + py{38,39,310,311,312}-{linux,macos,windows} + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + +[testenv] +deps = + pytest >=7.1.2 +commands = + pytest tests + +[testenv:coverage] +deps = + coverage >=5.5 +commands = + coverage erase + coverage run -m unittest discover + coverage report --include="uplot/**" + +[testenv:lint] +deps = + black >=22.10.0 + mypy >=1.10.0 + pylint >=2.8.2 + pytype >=2023.5.24 + ruff >=0.4.3 + types-setuptools >=65.6.0.2 +commands = + black --check --diff uplot + mypy uplot --ignore-missing-imports + pylint uplot --exit-zero --rcfile={toxinidir}/tox.ini + pytype uplot + ruff check uplot diff --git a/uplot/generate_html.py b/uplot/generate_html.py index 31dd475..87d9dfb 100644 --- a/uplot/generate_html.py +++ b/uplot/generate_html.py @@ -14,7 +14,13 @@ import numpy as np -from .utils import array2string, js +from .utils import js + +__MAX_INT = np.iinfo(np.int64).max + + +def __float_formatter(x) -> str: + return f"{x}" if not np.isnan(x) else "null" def generate_html(opts: dict, data: List[np.ndarray], resize: bool) -> str: @@ -23,6 +29,7 @@ def generate_html(opts: dict, data: List[np.ndarray], resize: bool) -> str: Args: opts: uPlot option dictionary. data: List of NumPy arrays, one for each series in the plot. + resize: If set, scale plot to page width and height. Returns: HTML contents of the page. @@ -39,8 +46,16 @@ def generate_html(opts: dict, data: List[np.ndarray], resize: bool) -> str: data_string = "" for array in data: + array_string = np.array2string( + array.astype(np.float64), + max_line_width=__MAX_INT, + precision=64, + separator=", ", + threshold=__MAX_INT, + formatter={"all": __float_formatter}, + ) data_string += f""" - {array2string(array)},""" + {array_string},""" if "class" not in opts: opts["class"] = "uplot-chart" diff --git a/uplot/plot.py b/uplot/plot.py index dd0f4ac..8f87434 100644 --- a/uplot/plot.py +++ b/uplot/plot.py @@ -4,16 +4,18 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2024 Inria -"""Main plot function.""" +"""Plot function with the same API as the one in µPlot.""" import webbrowser -from typing import Iterable, List +from typing import List + +import numpy as np from .generate_html import generate_html from .write_html_tempfile import write_html_tempfile -def plot(opts: dict, data: List[Iterable]) -> None: +def plot(opts: dict, data: List[np.ndarray]) -> None: """Plot function with the same API as uPlot's `plot`. Args: diff --git a/uplot/plot2.py b/uplot/plot2.py index 0475a12..a9d731b 100644 --- a/uplot/plot2.py +++ b/uplot/plot2.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2024 Inria -"""Additional plot function.""" +"""Plot function with additional defaults and arguments.""" import webbrowser from typing import List, Optional @@ -18,7 +18,7 @@ from .write_html_tempfile import write_html_tempfile -def prepare_data(x, left, right): +def __prepare_data(x, left, right): if isinstance(left, np.ndarray) and left.ndim == 1: left = [left] data = [x, *left] @@ -27,7 +27,7 @@ def prepare_data(x, left, right): return data -def add_default_options(opts: dict) -> None: +def __add_default_options(opts: dict) -> None: if "cursor" not in opts: opts["cursor"] = { "drag": { @@ -42,12 +42,13 @@ def add_default_options(opts: dict) -> None: ] -def add_series( +def __add_series( opts: dict, data: List[np.ndarray], nb_left: int, left_labels: Optional[List[str]], right_labels: Optional[List[str]], + time: bool, ) -> None: if left_labels is not None and len(left_labels) < nb_left: raise UplotException( @@ -55,9 +56,13 @@ def add_series( f"to label all {nb_left} left-axis series" ) - opts["series"] = [{}] + x_series = {} + if time: # set precision of the legend to the millisecond + x_series["value"] = "{YYYY}-{MM}-{DD} {HH}:{mm}:{ss}.{fff}" + + opts["series"] = [x_series] color_picker = ColorPicker() - for i, series in enumerate(data[1:]): + for i, _ in enumerate(data[1:]): new_series = { "show": True, "spanGaps": False, @@ -65,13 +70,6 @@ def add_series( "width": js("2 / devicePixelRatio"), } - def find_in_lists( - i: int, - left_list: Optional[List[str]], - right_list: Optional[List[str]], - ) -> Optional[str]: - return None - if left_labels is not None and i < nb_left: new_series["label"] = left_labels[i] if i >= nb_left: @@ -94,17 +92,19 @@ def find_in_lists( opts["series"].append(new_series) -def add_axes(opts: dict) -> None: +def __add_axes(opts: dict) -> None: opts["axes"] = [ {}, { "size": 60, + "values": js("(u, vals, space) => vals.map(v => v + '')"), }, { - "side": 1, + "grid": {"show": False}, "scale": "right_axis", + "side": 1, "size": 60, - "grid": {"show": False}, + "values": js("(u, vals, space) => vals.map(v => v + '')"), }, ] @@ -115,7 +115,7 @@ def plot2( right: Optional[List[np.ndarray]] = None, resize: bool = True, title: Optional[str] = None, - timestamped: bool = False, + time: bool = False, width: Optional[int] = None, height: Optional[int] = None, left_labels: Optional[List[str]] = None, @@ -130,30 +130,32 @@ def plot2( right: Values for the (optional) right y-axis. resize: If set (default), scale plot to page width and height. title: Plot title. - timestamped: If set, x-axis values are treated as timestamps. + time: If set, x-axis values are treated as timestamps. width: Plot width in pixels. height: Plot height in pixels. + left_labels: List of labels for left-axis series. + right_labels: List of labels for right-axis series. kwargs: Other keyword arguments are forward to uPlot as options. """ - data = prepare_data(x, left, right) + data = __prepare_data(x, left, right) # Prepare options opts = kwargs.copy() - add_default_options(opts) + __add_default_options(opts) if "id" not in opts: opts["id"] = "chart1" if title is not None: opts["title"] = title - if timestamped is not None: - opts["scales"] = {"x": {"time": timestamped}} + if time is not None: + opts["scales"] = {"": {"time": time}} if width is not None: opts["width"] = width if height is not None: opts["height"] = height if "series" not in opts: - add_series(opts, data, len(left), left_labels, right_labels) + __add_series(opts, data, len(left), left_labels, right_labels, time) if "axes" not in opts: - add_axes(opts) + __add_axes(opts) # Generate and open plot html = generate_html(opts, data, resize=resize) diff --git a/uplot/utils.py b/uplot/utils.py index 4c2c013..4c7ae6a 100644 --- a/uplot/utils.py +++ b/uplot/utils.py @@ -4,25 +4,7 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2024 Inria -import numpy as np - - -def array2string(array: np.ndarray) -> str: - """Get string representation of a NumPy array suitable for uPlot. - - Args: - array: NumPy array to convert to JavaScript. - - Returns: - String representation of the array. - """ - array_str = np.array2string( - array, - precision=64, - separator=",", - threshold=np.inf, - ) - return array_str.replace("nan", "null") +"""Utility functions.""" def js(code: str) -> str: