diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 9125403..cb6144e 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -12,19 +12,23 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: true matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + os: [ubuntu-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + include: + - os: ubuntu-20.04 + python-version: "3.6" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v2 + - uses: actions/cache@v3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} diff --git a/Makefile b/Makefile index b44b477..fdf981b 100644 --- a/Makefile +++ b/Makefile @@ -98,7 +98,7 @@ reqs: ## output requirements.txt poetry export -f requirements.txt -o requirements.txt --without-hashes release: dist ## package and upload a release - twine upload + twine upload dist/* dist: clean ## builds source and wheel package poetry build diff --git a/docs/04-about/release-notes.md b/docs/04-about/release-notes.md index 0cd321b..2136f88 100644 --- a/docs/04-about/release-notes.md +++ b/docs/04-about/release-notes.md @@ -1,6 +1,12 @@ Release Notes ============= +4.3.0 (2023-05-29) +------------------ + +- Compatibility + - Python 3.11 support added + 4.2.0 (2022-11-10) ------------------ diff --git a/esparto/__init__.py b/esparto/__init__.py index e076c9a..7356d21 100644 --- a/esparto/__init__.py +++ b/esparto/__init__.py @@ -9,30 +9,30 @@ """ +import dataclasses as _dc from importlib.util import find_spec as _find_spec from pathlib import Path as _Path -from typing import Set as _Set __author__ = """Dominic Thorn""" __email__ = "dominic.thorn@gmail.com" -__version__ = "4.2.0" +__version__ = "4.3.0" _MODULE_PATH: _Path = _Path(__file__).parent.absolute() -_OPTIONAL_DEPENDENCIES: _Set[str] = { - "PIL", # Only used for type checking and conversion - "IPython", - "matplotlib", - "pandas", - "bokeh", - "plotly", - "weasyprint", -} +@_dc.dataclass(frozen=True) +class _OptionalDependencies: + PIL: bool = _find_spec("PIL") is not None + IPython: bool = _find_spec("IPython") is not None + matplotlib: bool = _find_spec("matplotlib") is not None + pandas: bool = _find_spec("pandas") is not None + bokeh: bool = _find_spec("bokeh") is not None + plotly: bool = _find_spec("plotly") is not None + weasyprint: bool = _find_spec("weasyprint") is not None + + def all_extras(self) -> bool: + return all(_dc.astuple(self)) -_INSTALLED_MODULES: _Set[str] = { - x.name for x in [_find_spec(dep) for dep in _OPTIONAL_DEPENDENCIES] if x -} from esparto._options import OutputOptions, options from esparto.design.content import ( diff --git a/esparto/_options.py b/esparto/_options.py index 5901729..a1ea7be 100644 --- a/esparto/_options.py +++ b/esparto/_options.py @@ -134,9 +134,9 @@ class OutputOptions(yaml.YAMLObject, ConfigMixin): esparto_js: str = str(_MODULE_PATH / "resources/js/esparto.js") jinja_template: str = str(_MODULE_PATH / "resources/jinja/base.html.jinja") - matplotlib: MatplotlibOptions = MatplotlibOptions() - bokeh: BokehOptions = BokehOptions() - plotly: PlotlyOptions = PlotlyOptions() + matplotlib: MatplotlibOptions = field(default_factory=MatplotlibOptions) + bokeh: BokehOptions = field(default_factory=BokehOptions) + plotly: PlotlyOptions = field(default_factory=PlotlyOptions) _pdf_temp_dir: str = TemporaryDirectory().name diff --git a/esparto/design/adaptors.py b/esparto/design/adaptors.py index c344907..aaddd01 100644 --- a/esparto/design/adaptors.py +++ b/esparto/design/adaptors.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Dict, Union -from esparto import _INSTALLED_MODULES +from esparto import _OptionalDependencies from esparto.design.content import ( Content, DataFramePd, @@ -65,7 +65,7 @@ def content_adaptor_dict(content: Dict[str, Any]) -> Dict[str, Any]: # Function only available if Pandas is installed. -if "pandas" in _INSTALLED_MODULES: +if _OptionalDependencies.pandas: from pandas.core.frame import DataFrame # type: ignore @content_adaptor.register(DataFrame) @@ -75,7 +75,7 @@ def content_adaptor_df(content: DataFrame) -> DataFramePd: # Function only available if Matplotlib is installed. -if "matplotlib" in _INSTALLED_MODULES: +if _OptionalDependencies.matplotlib: from matplotlib.figure import Figure # type: ignore @content_adaptor.register(Figure) @@ -85,7 +85,7 @@ def content_adaptor_mpl(content: Figure) -> FigureMpl: # Function only available if Bokeh is installed. -if "bokeh" in _INSTALLED_MODULES: +if _OptionalDependencies.bokeh: from bokeh.layouts import LayoutDOM as BokehObject # type: ignore @content_adaptor.register(BokehObject) @@ -95,7 +95,7 @@ def content_adaptor_bokeh(content: BokehObject) -> FigureBokeh: # Function only available if Plotly is installed. -if "plotly" in _INSTALLED_MODULES: +if _OptionalDependencies.plotly: from plotly.graph_objs._figure import Figure as PlotlyFigure # type: ignore @content_adaptor.register(PlotlyFigure) diff --git a/esparto/design/content.py b/esparto/design/content.py index 8c6512b..28c96c2 100644 --- a/esparto/design/content.py +++ b/esparto/design/content.py @@ -11,26 +11,26 @@ import markdown as md -from esparto import _INSTALLED_MODULES +from esparto import _OptionalDependencies from esparto._options import options from esparto.design.base import AbstractContent, AbstractLayout, Child from esparto.design.layout import Row from esparto.publish.output import nb_display -if "PIL" in _INSTALLED_MODULES: +if _OptionalDependencies.PIL: from PIL.Image import Image as PILImage # type: ignore -if "pandas" in _INSTALLED_MODULES: +if _OptionalDependencies.pandas: from pandas import DataFrame # type: ignore -if "matplotlib" in _INSTALLED_MODULES: +if _OptionalDependencies.matplotlib: from matplotlib.figure import Figure as MplFigure # type: ignore -if "bokeh" in _INSTALLED_MODULES: +if _OptionalDependencies.bokeh: from bokeh.embed import components # type: ignore from bokeh.models.layouts import LayoutDOM as BokehObject # type: ignore -if "plotly" in _INSTALLED_MODULES: +if _OptionalDependencies.plotly: from plotly.graph_objs._figure import Figure as PlotlyFigure # type: ignore from plotly.io import to_html as plotly_to_html # type: ignore @@ -103,7 +103,6 @@ class RawHTML(Content): content: str def __init__(self, html: str) -> None: - if not isinstance(html, str): raise TypeError(r"HTML must be str") @@ -124,7 +123,6 @@ class Markdown(Content): _dependencies = {"bootstrap"} def __init__(self, text: str) -> None: - if not isinstance(text, str): raise TypeError(r"text must be str") @@ -163,10 +161,9 @@ def __init__( set_width: Optional[int] = None, set_height: Optional[int] = None, ): - valid_types: Tuple[Any, ...] - if "PIL" in _INSTALLED_MODULES: + if _OptionalDependencies.PIL: valid_types = (str, Path, PILImage, BytesIO) else: valid_types = (str, Path, BytesIO) @@ -250,7 +247,6 @@ class DataFramePd(Content): def __init__( self, df: "DataFrame", index: bool = True, col_space: Union[int, str] = 0 ): - if not isinstance(df, DataFrame): raise TypeError(r"df must be Pandas DataFrame") @@ -292,7 +288,6 @@ def __init__( output_format: Optional[str] = None, pdf_figsize: Optional[Union[Tuple[int, int], float]] = None, ) -> None: - if not isinstance(figure, MplFigure): raise TypeError(r"figure must be a Matplotlib Figure") @@ -303,7 +298,6 @@ def __init__( self._original_figsize = figure.get_size_inches() def to_html(self, **kwargs: bool) -> str: - if kwargs.get("notebook_mode"): output_format = options.matplotlib.notebook_format else: @@ -317,7 +311,6 @@ def to_html(self, **kwargs: bool) -> str: self.content.set_size_inches(*figsize) if output_format == "svg": - string_buffer = StringIO() self.content.savefig(string_buffer, format="svg") string_buffer.seek(0) @@ -382,7 +375,6 @@ def __init__( self.layout_attributes = layout_attributes or options.bokeh.layout_attributes def to_html(self, **kwargs: bool) -> str: - if self.layout_attributes: for key, value in self.layout_attributes.items(): setattr(self.content, key, value) @@ -426,7 +418,6 @@ def __init__( figure: "PlotlyFigure", layout_args: Optional[Dict[Any, Any]] = None, ): - if not isinstance(figure, PlotlyFigure): raise TypeError(r"figure must be a Plotly Figure") @@ -436,7 +427,6 @@ def __init__( self._original_layout = figure.layout def to_html(self, **kwargs: bool) -> str: - if self.layout_args: self.content.update_layout(**self.layout_args) @@ -485,7 +475,7 @@ def image_to_bytes(image: Union[str, Path, BytesIO, "PILImage"]) -> BytesIO: """ if isinstance(image, BytesIO): return image - elif "PIL" in _INSTALLED_MODULES and isinstance(image, PILImage): + elif _OptionalDependencies.PIL and isinstance(image, PILImage): return BytesIO(image.tobytes()) elif isinstance(image, (str, Path)): return BytesIO(Path(image).read_bytes()) diff --git a/esparto/publish/contentdeps.py b/esparto/publish/contentdeps.py index c4a04ff..280c13b 100644 --- a/esparto/publish/contentdeps.py +++ b/esparto/publish/contentdeps.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import List, Optional, Set -from esparto import _INSTALLED_MODULES +from esparto import _OptionalDependencies from esparto._options import options @@ -40,7 +40,7 @@ def lazy_content_dependency_dict() -> ContentDependencyDict: "bootstrap", options.bootstrap_cdn, bootstrap_inline, "head" ) - if "bokeh" in _INSTALLED_MODULES: + if _OptionalDependencies.bokeh: import bokeh.resources as bk_resources # type: ignore bokeh_cdn = bk_resources.CDN.render_js() @@ -50,7 +50,7 @@ def lazy_content_dependency_dict() -> ContentDependencyDict: "bokeh", bokeh_cdn, bokeh_inline, "tail" ) - if "plotly" in _INSTALLED_MODULES: + if _OptionalDependencies.plotly: from plotly import offline as plotly_offline # type: ignore plotly_version = "latest" diff --git a/esparto/publish/output.py b/esparto/publish/output.py index e40f728..ceac41b 100644 --- a/esparto/publish/output.py +++ b/esparto/publish/output.py @@ -7,7 +7,7 @@ from bs4 import BeautifulSoup, Tag # type: ignore from jinja2 import Template -from esparto import _INSTALLED_MODULES +from esparto import _OptionalDependencies from esparto._options import options, resolve_config_option from esparto.design.base import AbstractContent, AbstractLayout from esparto.publish.contentdeps import resolve_deps @@ -88,7 +88,7 @@ def publish_pdf( str: HTML string if return_html is True. """ - if "weasyprint" not in _INSTALLED_MODULES: + if not _OptionalDependencies.weasyprint: raise ModuleNotFoundError("Install weasyprint for PDF support") import weasyprint as wp # type: ignore diff --git a/pyproject.toml b/pyproject.toml index ffae28c..a9d4464 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "esparto" -version = "4.2.0" +version = "4.3.0" description = "Data driven report builder for the PyData ecosystem." authors = ["Dominic Thorn "] license = "MIT" @@ -14,6 +14,7 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Intended Audience :: Developers", ] diff --git a/setup.cfg b/setup.cfg index 8e9e028..abd4a8b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,8 +12,10 @@ multi_line_output = 3 max-line-length = 120 exclude = scratch.py, docs/ ignore = - W503, # line break before binary operator - E203, # whitespace before ':' + # line break before binary operator + W503, + # whitespace before ':' + E203, per-file-ignores = __init__.py:E402, F401 cdnlinks.py:E501 diff --git a/tests/conftest.py b/tests/conftest.py index 3410cfe..d1806d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,9 +6,7 @@ import esparto.design.content as co import esparto.design.layout as la -from esparto import _INSTALLED_MODULES, _OPTIONAL_DEPENDENCIES - -_EXTRAS = _OPTIONAL_DEPENDENCIES <= _INSTALLED_MODULES +from esparto import _OptionalDependencies _irises_path = str(Path("tests/resources/irises.jpg").absolute()) _markdown_path = str(Path("tests/resources/markdown.md").absolute()) @@ -43,7 +41,7 @@ (Path(_markdown_path), co.Markdown), ] -if _EXTRAS: +if _OptionalDependencies().all_extras(): import bokeh.layouts as bkl # type: ignore import bokeh.plotting as bkp # type: ignore import matplotlib.pyplot as plt # type: ignore diff --git a/tests/design/test_adaptors.py b/tests/design/test_adaptors.py index 2121420..7d31080 100644 --- a/tests/design/test_adaptors.py +++ b/tests/design/test_adaptors.py @@ -4,9 +4,10 @@ import pytest import esparto.design.adaptors as ad +from esparto import _OptionalDependencies from esparto.design.content import Content, Markdown from esparto.design.layout import Column -from tests.conftest import _EXTRAS, adaptor_list +from tests.conftest import adaptor_list def get_dispatch_type(fn): @@ -20,7 +21,7 @@ def test_all_adaptors_covered(adaptor_list_fn): module_functions = [x[1] for x in getmembers(ad, isfunction)] adaptor_types = {get_dispatch_type(fn) for fn in module_functions} adaptor_types.remove(Content) # Can't use abstract base class in a test - if _EXTRAS: + if _OptionalDependencies.bokeh: adaptor_types.remove(ad.BokehObject) # Can't use abstract base class in a test if PosixPath in test_classes: test_classes.remove(PosixPath) diff --git a/tests/design/test_content.py b/tests/design/test_content.py index 35d789e..0b77dfd 100644 --- a/tests/design/test_content.py +++ b/tests/design/test_content.py @@ -4,7 +4,8 @@ import esparto.design.content as co import esparto.design.layout as la -from tests.conftest import _EXTRAS, content_list +from esparto import _OptionalDependencies +from tests.conftest import content_list @pytest.mark.parametrize("a", content_list) @@ -24,7 +25,7 @@ def test_content_equality(content_list_fn): assert a != b -if _EXTRAS: +if _OptionalDependencies().all_extras(): def test_all_content_classes_covered(content_list_fn): test_classes = {type(c) for c in content_list_fn} @@ -44,7 +45,6 @@ def test_all_content_classes_have_deps(content_list_fn): @pytest.mark.parametrize("a", content_list) def test_incorrect_content_rejected(a): - b = type(a) class FakeClass: diff --git a/tests/publish/test_publish.py b/tests/publish/test_publish.py index 0e71ac9..b78e318 100644 --- a/tests/publish/test_publish.py +++ b/tests/publish/test_publish.py @@ -5,13 +5,19 @@ import esparto as es import esparto.publish.output as pu -from tests.conftest import _EXTRAS, content_list, layout_list +from esparto import _OptionalDependencies +from tests.conftest import content_list, layout_list -def html_is_valid(html: Optional[str], fragment: bool = False): +def html_is_valid( + html: Optional[str], fragment: bool = False, plotly_chars: bool = False +): from html5lib import HTMLParser # type: ignore htmlparser = HTMLParser(strict=True) + if plotly_chars: # Plotly.js includes chars htmlparser considers invalid codepoints + for char in ("\x01", "\x1a", "\x1b", "\x89"): + html = html.replace(char, "") try: if fragment: htmlparser.parseFragment(html) @@ -71,7 +77,7 @@ def test_saved_html_valid_options_inline(page_layout: es.Page, tmp_path, monkeyp path: Path = tmp_path / "my_page.html" page_layout.save_html(str(path)) html = path.read_text() - assert html_is_valid(html) + assert html_is_valid(html, plotly_chars=True) def test_saved_html_valid_bad_source(page_layout: es.Page, tmp_path): @@ -129,7 +135,7 @@ def test_relocate_scripts(): assert output == expected -if _EXTRAS: +if _OptionalDependencies().all_extras(): from tests.conftest import content_pdf def test_notebook_html_valid_cdn(page_layout, monkeypatch): @@ -152,7 +158,7 @@ def test_notebook_html_valid_online(page_layout, monkeypatch): monkeypatch.setattr(es.options.matplotlib, "notebook_format", "png") monkeypatch.setattr(es.options, "dependency_source", "inline") html = pu.nb_display(page_layout, return_html=True) - assert html_is_valid(html) + assert html_is_valid(html, plotly_chars=True) @pytest.mark.parametrize("content", content_pdf) def test_pdf_output(content, tmp_path): diff --git a/tox.ini b/tox.ini index 1935802..2066643 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] skipsdist = true -envlist = setup,codequal,py{36,37,38,39}-{alldeps,mindeps},coverage +envlist = setup,codequal,py{36,37,38,39,310,311}-{alldeps,mindeps},coverage [testenv] allowlist_externals =