├── docs ├── index.md ├── stylesheets │ └── extra.css ├── examples │ ├── my-image.png │ └── iris-report.pdf ├── images │ └── iris-report-compressed.png ├── 04-about │ ├── authors.md │ ├── license.md │ └── release-notes.md ├── 03-api-reference │ ├── options.md │ ├── publish.md │ ├── adaptors.md │ ├── layout.md │ └── content.md └── 02-user-guide │ ├── tutorial-notebooks.md │ ├── report-style.md │ └── quick-start.md ├── esparto ├── design │ ├── __init__.py │ ├── base.py │ ├── adaptors.py │ ├── content.py │ └── layout.py ├── publish │ ├── __init__.py │ ├── contentdeps.py │ └── output.py ├── __main__.py ├── resources │ ├── js │ │ └── esparto.js │ ├── jinja │ │ └── base.html.jinja │ └── css │ │ └── esparto.css ├── __init__.py ├── _cli.py └── _options.py ├── devdocs └── classes.png ├── tests ├── __init__.py ├── resources │ ├── irises.jpg │ └── markdown.md ├── check_package_version.py ├── test_options.py ├── test_cli.py ├── design │ ├── test_adaptors.py │ ├── test_content.py │ └── test_layout.py ├── conftest.py ├── example_pages.py └── publish │ └── test_publish.py ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ ├── build.yml │ └── lint-and-test.yml ├── setup.cfg ├── sonar-project.properties ├── .editorconfig ├── codecov.yml ├── .coveragerc ├── tox.ini ├── LICENSE ├── .pre-commit-config.yaml ├── mkdocs.yml ├── .gitignore ├── pyproject.toml ├── Makefile ├── README.md └── logo └── logo.svg /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /esparto/design/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /esparto/publish/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .md-grid { 2 | max-width: 1100px; 3 | } 4 | -------------------------------------------------------------------------------- /devdocs/classes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domvwt/esparto/HEAD/devdocs/classes.png -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Unit test package for esparto.""" 4 | -------------------------------------------------------------------------------- /docs/examples/my-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domvwt/esparto/HEAD/docs/examples/my-image.png -------------------------------------------------------------------------------- /tests/resources/irises.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domvwt/esparto/HEAD/tests/resources/irises.jpg -------------------------------------------------------------------------------- /docs/examples/iris-report.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domvwt/esparto/HEAD/docs/examples/iris-report.pdf -------------------------------------------------------------------------------- /esparto/__main__.py: -------------------------------------------------------------------------------- 1 | from esparto import _cli 2 | 3 | if __name__ == "__main__": 4 | _cli.main() 5 | -------------------------------------------------------------------------------- /docs/images/iris-report-compressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/domvwt/esparto/HEAD/docs/images/iris-report-compressed.png -------------------------------------------------------------------------------- /docs/04-about/authors.md: -------------------------------------------------------------------------------- 1 | Credits 2 | ======= 3 | 4 | ## Lead Developer 5 | Dominic Thorn 6 | 7 | - [dominic.thorn@gmail.com](mailto:dominic.thorn@gmail.com) 8 | - [domvwt.github.io](https://domvwt.github.io/) 9 | 10 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * esparto version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [black] 2 | max-line-length = 120 3 | 4 | [mypy] 5 | strict = true 6 | 7 | [isort] 8 | profile = black 9 | multi_line_output = 3 10 | 11 | [flake8] 12 | max-line-length = 120 13 | exclude = scratch.py, docs/ 14 | ignore = 15 | # line break before binary operator 16 | W503, 17 | # whitespace before ':' 18 | E203, 19 | per-file-ignores = 20 | __init__.py:E402, F401 21 | cdnlinks.py:E501 22 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=domvwt_esparto 2 | sonar.organization=domvwt 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=esparto 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | sonar.sources=./esparto 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | sonar.sourceEncoding=UTF-8 13 | -------------------------------------------------------------------------------- /docs/03-api-reference/options.md: -------------------------------------------------------------------------------- 1 | # esparto._options 2 | 3 | !!! info 4 | Default options are configured through `es.options`, page level options can 5 | be passed to the `Page` constructor. 6 | 7 | Please read [the user guide](/02-user-guide/report-style) for more details. 8 | 9 | ## ::: esparto._options.OutputOptions 10 | 11 | ## ::: esparto._options.MatplotlibOptions 12 | 13 | ## ::: esparto._options.PlotlyOptions 14 | 15 | ## ::: esparto._options.BokehOptions 16 | 17 |
18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.yaml] 14 | indent_size = 2 15 | 16 | [*.html*] 17 | indent_size = 2 18 | 19 | [*.js] 20 | indent_size = 2 21 | 22 | [*.bat] 23 | indent_style = tab 24 | end_of_line = crlf 25 | 26 | [LICENSE] 27 | insert_final_newline = false 28 | 29 | [Makefile] 30 | indent_style = tab 31 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: false # always post 4 | after_n_builds: 1 5 | 6 | coverage: 7 | precision: 2 # 2 decimals of precision 8 | round: nearest # Round to nearest precision point 9 | range: "70...90" # red -> yellow -> green 10 | 11 | status: 12 | project: 13 | default: 14 | target: 90% 15 | threshold: 5% # allow 5% coverage variance 16 | 17 | patch: no 18 | changes: no 19 | 20 | comment: 21 | require_changes: yes # only post when coverage changes 22 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = esparto 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | def __repr__ 12 | def _repr_html_ 13 | def __str__ 14 | def display 15 | return self._ 16 | def set_ 17 | def _render_title 18 | def _parent_class 19 | def _child_class 20 | if TYPE_CHECKING 21 | if kwargs.get("pdf_mode"): 22 | if output_format == "svg": 23 | ignore_errors = True 24 | omit = 25 | tests/* 26 | _cli.py 27 | -------------------------------------------------------------------------------- /docs/03-api-reference/publish.md: -------------------------------------------------------------------------------- 1 | # esparto.publish.output 2 | 3 | !!! info 4 | Publishing methods are accessed via Layout classes. 5 | 6 | ``` python 7 | import esparto as es 8 | 9 | # Create a new Page 10 | page = es.Page(title="My New Page") 11 | 12 | # Publish the page to an HTML file: 13 | page.save_html("my-page.html") 14 | 15 | # Or as a PDF: 16 | page.save_pdf("my-page.pdf") 17 | 18 | ``` 19 | 20 | ## ::: esparto.publish.output.publish_html 21 | 22 | ## ::: esparto.publish.output.publish_pdf 23 | 24 | ## ::: esparto.publish.output.nb_display 25 | 26 |
27 | -------------------------------------------------------------------------------- /tests/check_package_version.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import esparto as es 4 | 5 | 6 | def check_package_version(): 7 | pyproject_file = Path("pyproject.toml").read_text() 8 | pyproject_version = pyproject_file.split("version", 1)[1].split('"')[1] 9 | module_version = es.__version__ 10 | 11 | if module_version == pyproject_version: 12 | print("Version number up to date!") 13 | exit(0) 14 | else: 15 | print("Please bump version number!") 16 | print("pyproject.toml:", pyproject_version) 17 | print("esparto.__version__:", module_version) 18 | exit(1) 19 | 20 | 21 | if __name__ == "__main__": 22 | check_package_version() 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CodeScan 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - develop 7 | - mvp 8 | pull_request: 9 | types: [opened, synchronize, reopened] 10 | jobs: 11 | sonarcloud: 12 | name: SonarCloud 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 18 | - name: SonarCloud Scan 19 | uses: SonarSource/sonarcloud-github-action@master 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 22 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 23 | -------------------------------------------------------------------------------- /docs/03-api-reference/adaptors.md: -------------------------------------------------------------------------------- 1 | # esparto.design.adaptors 2 | 3 | !!! info 4 | The `content_adaptor` function is called internally when an explicit `Content` class is not provided. 5 | 6 | Objects are matched to a suitable `Content` class through [_single dispatch_](https://docs.python.org/3/library/functools.html#functools.singledispatch). 7 | 8 | ``` python 9 | import esparto as es 10 | 11 | # Text automatically converted to Markdown content. 12 | page = es.Page(title="New Page") 13 | page["New Section"] = "Example _markdown_ text." 14 | page.tree() 15 | ``` 16 | ``` 17 | {'New Page': [{'New Section': [{'Row 0': [{'Column 0': ['Markdown']}]}]}]} 18 | ``` 19 | 20 | ## ::: esparto.design.adaptors 21 | 22 |
23 | -------------------------------------------------------------------------------- /docs/03-api-reference/layout.md: -------------------------------------------------------------------------------- 1 | # esparto.design.layout 2 | 3 | !!! info 4 | `Layout` classes are accessed from the top level module. 5 | 6 | ``` python 7 | import esparto as es 8 | 9 | # Create a new Page 10 | page = es.Page() 11 | 12 | ``` 13 | 14 | ## ::: esparto.design.layout.Layout 15 | 16 |
17 | 18 | ## ::: esparto.design.layout.Page 19 | 20 | ## ::: esparto.design.layout.Section 21 | 22 | ## ::: esparto.design.layout.CardSection 23 | 24 | ## ::: esparto.design.layout.Row 25 | 26 | ## ::: esparto.design.layout.CardRow 27 | 28 | ## ::: esparto.design.layout.CardRowEqual 29 | 30 | ## ::: esparto.design.layout.Column 31 | 32 | ## ::: esparto.design.layout.Card 33 | 34 | ## ::: esparto.design.layout.Spacer 35 | 36 | ## ::: esparto.design.layout.PageBreak 37 | 38 |
39 | -------------------------------------------------------------------------------- /docs/03-api-reference/content.md: -------------------------------------------------------------------------------- 1 | # esparto.design.content 2 | 3 | !!! info 4 | `Content` classes will usually be inferred from the content object type. 5 | They may be accessed via the top level module if required. 6 | 7 | ``` python 8 | import esparto as es 9 | 10 | # Create some new Markdown text 11 | markdown = es.Markdown("Example _markdown_ text.") 12 | 13 | ``` 14 | 15 | ## ::: esparto.design.content.Content 16 | 17 |
18 | 19 | ## ::: esparto.design.content.Markdown 20 | 21 | ## ::: esparto.design.content.DataFramePd 22 | 23 | ## ::: esparto.design.content.FigureMpl 24 | 25 | ## ::: esparto.design.content.FigureBokeh 26 | 27 | ## ::: esparto.design.content.FigurePlotly 28 | 29 | ## ::: esparto.design.content.Image 30 | 31 | ## ::: esparto.design.content.RawHTML 32 | 33 |
34 | -------------------------------------------------------------------------------- /docs/02-user-guide/tutorial-notebooks.md: -------------------------------------------------------------------------------- 1 | # Tutorial Notebooks 2 | 3 | ## Data Analysis 4 | 5 | In this example we put together a simple data analysis report using: 6 | 7 | * Text content with markdown formatting 8 | * Pandas DataFrames 9 | * Plots from Matplotlib 10 | 11 | [Webpage](../examples/iris-report.html) | [PDF](../examples/iris-report.pdf) 12 | 13 | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/domvwt/esparto/blob/main/docs/examples/iris-report.ipynb) 14 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/domvwt/esparto/main?filepath=docs%2Fexamples%2Firis-report.ipynb) 15 | [![GitHub](https://img.shields.io/badge/view%20on-GitHub-lightgrey)](https://github.com/domvwt/esparto/blob/main/docs/examples/iris-report.ipynb) 16 | 17 | ---- 18 | 19 |
20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = true 3 | envlist = setup,codequal,py{36,37,38,39,310,311}-{alldeps,mindeps},coverage 4 | 5 | [testenv] 6 | allowlist_externals = 7 | poetry 8 | deps = 9 | pytest 10 | coverage 11 | html5lib 12 | commands = 13 | alldeps: poetry install -v --no-root 14 | mindeps: pip install . 15 | coverage run -am pytest -v 16 | py38-alldeps: poetry run python -m tests.check_package_version 17 | 18 | [testenv:setup] 19 | deps = 20 | coverage 21 | commands = 22 | coverage erase 23 | 24 | [testenv:codequal] 25 | basepython = python 26 | allowlist_externals = mypy 27 | deps = 28 | black 29 | flake8 30 | commands = 31 | black --check esparto tests 32 | flake8 esparto tests 33 | mypy esparto tests 34 | 35 | [testenv:coverage] 36 | deps = 37 | coverage 38 | commands = 39 | coverage html 40 | coverage report 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, Dominic Thorn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/04-about/license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | =========== 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | 22 | _Copyright (c) 2021 - Dominic Thorn_ 23 | 24 |
25 | 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: local 12 | hooks: 13 | - id: isort 14 | name: Isort 15 | language: system 16 | entry: poetry run isort 17 | types: [python] 18 | args: [--filter-files, --profile, black] 19 | - id: black 20 | name: Black 21 | language: system 22 | entry: poetry run black 23 | types: [python] 24 | - id: flake8 25 | name: Flake8 26 | language: system 27 | entry: poetry run flake8 28 | types: [python] 29 | - id: mypy 30 | name: MyPy 31 | language: system 32 | entry: poetry run mypy 33 | types: [python] 34 | exclude: tests/ 35 | - id: version 36 | name: Version 37 | language: system 38 | entry: poetry run python -m tests.check_package_version 39 | types: [python] 40 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | import pytest 4 | 5 | import esparto._options as opt 6 | 7 | 8 | def test_options_context(): 9 | default_options = copy.deepcopy(opt.options) 10 | updated_options = opt.OutputOptions( 11 | dependency_source="XXX", 12 | matplotlib=opt.MatplotlibOptions( 13 | html_output_format="svg", notebook_format="XXX", pdf_figsize=1.0 14 | ), 15 | ) 16 | new_options = opt.OutputOptions( 17 | dependency_source="XXX", matplotlib=opt.MatplotlibOptions(notebook_format="XXX") 18 | ) 19 | with opt.options_context(new_options): 20 | context_options = copy.deepcopy(opt.options) 21 | 22 | assert opt.options == default_options 23 | assert context_options == updated_options 24 | 25 | 26 | update_recursive_cases = [ 27 | ({"a": 1, "b": 2}, {"b": 3}, {"a": 1, "b": 3}), 28 | ( 29 | {"a": 1, "b": 2, "c": {"d": 4, "e": 5}}, 30 | {"b": 3, "c": {"e": 6}}, 31 | {"a": 1, "b": 3, "c": {"d": 4, "e": 6}}, 32 | ), 33 | ] 34 | 35 | 36 | @pytest.mark.parametrize("input1,input2,expected", update_recursive_cases) 37 | def test_update_recursive(input1, input2, expected): 38 | assert opt.update_recursive(input1, input2) == expected 39 | -------------------------------------------------------------------------------- /esparto/resources/js/esparto.js: -------------------------------------------------------------------------------- 1 | // Convert document to text file. 2 | const getDocumentFile = function () { 3 | let filename = document.title + ".html" 4 | let docString = new XMLSerializer().serializeToString(document) 5 | return new File([docString], filename, { type: "text/html" }) 6 | } 7 | 8 | async function shareDocument() { 9 | try { 10 | let docFile = getDocumentFile() 11 | let shareData = { files: [docFile] } 12 | await navigator.share(shareData) 13 | } catch (err) { 14 | alert(`Error while sharing document:\n${err}`) 15 | } 16 | } 17 | 18 | const share_button = document.getElementById('share-button'); 19 | 20 | // Only show share button if web share API is supported. 21 | if (share_button) { 22 | if (navigator.canShare) { 23 | share_button.style.display = "block"; 24 | share_button.addEventListener('click', () => { 25 | shareDocument().catch(err => { 26 | console.error(`Error while sharing document:\n${err}`); 27 | }); 28 | }); 29 | } else { 30 | console.log("Web Share API not supported.") 31 | } 32 | } else { 33 | console.log("Share button not found.") 34 | } 35 | 36 | const print_button = document.getElementById('print-button') 37 | 38 | if (print_button) { 39 | print_button.addEventListener('click', () => { 40 | window.print() 41 | }); 42 | } else { 43 | console.log("Print button not found.") 44 | } 45 | -------------------------------------------------------------------------------- /esparto/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | esparto 4 | ======= 5 | 6 | Data driven report builder for the PyData ecosystem. 7 | 8 | Please visit https://domvwt.github.io/esparto/ for documentation and examples. 9 | 10 | """ 11 | 12 | import dataclasses as _dc 13 | from importlib.util import find_spec as _find_spec 14 | from pathlib import Path as _Path 15 | 16 | __author__ = """Dominic Thorn""" 17 | __email__ = "dominic.thorn@gmail.com" 18 | __version__ = "4.3.1" 19 | 20 | _MODULE_PATH: _Path = _Path(__file__).parent.absolute() 21 | 22 | 23 | @_dc.dataclass(frozen=True) 24 | class _OptionalDependencies: 25 | PIL: bool = _find_spec("PIL") is not None 26 | IPython: bool = _find_spec("IPython") is not None 27 | matplotlib: bool = _find_spec("matplotlib") is not None 28 | pandas: bool = _find_spec("pandas") is not None 29 | bokeh: bool = _find_spec("bokeh") is not None 30 | plotly: bool = _find_spec("plotly") is not None 31 | weasyprint: bool = _find_spec("weasyprint") is not None 32 | 33 | def all_extras(self) -> bool: 34 | return all(_dc.astuple(self)) 35 | 36 | 37 | from esparto._options import OutputOptions, options 38 | from esparto.design.content import ( 39 | DataFramePd, 40 | FigureBokeh, 41 | FigureMpl, 42 | FigurePlotly, 43 | Image, 44 | Markdown, 45 | RawHTML, 46 | ) 47 | from esparto.design.layout import ( 48 | Card, 49 | CardRow, 50 | CardRowEqual, 51 | CardSection, 52 | Column, 53 | Page, 54 | PageBreak, 55 | Row, 56 | Section, 57 | Spacer, 58 | ) 59 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | import esparto._cli as cli 7 | import esparto._options as opt 8 | 9 | arg_test_values = [ 10 | (("--version", "-v"), {"verbose": True}, (["--version", "-v"], {"verbose": True})), 11 | (("positional_arg",), {}, (["positional_arg"], {})), 12 | ] 13 | 14 | 15 | @pytest.mark.parametrize("name_or_flags,kwargs,expected", arg_test_values) 16 | def test_argument(expected, name_or_flags, kwargs): 17 | output = cli.argument(*name_or_flags, **kwargs) 18 | assert output == expected 19 | 20 | 21 | def test_subcommand(): 22 | parser = argparse.ArgumentParser() 23 | subparsers = parser.add_subparsers(dest="subcommand") 24 | 25 | @cli.subcommand(cli.argument("--message"), parent=subparsers) 26 | def my_subcommand(args): 27 | return args.message 28 | 29 | args = parser.parse_args(["my_subcommand", "--message", "test"]) 30 | assert args.func(args) == "test" 31 | 32 | 33 | def test_print_esparto_css(capsys): 34 | cli.print_esparto_css() 35 | captured = capsys.readouterr() 36 | expected = Path(opt.OutputOptions.esparto_css).read_text().strip() 37 | assert captured.out.strip() == expected 38 | 39 | 40 | def test_print_bootstrap_css(capsys): 41 | cli.print_bootstrap_css() 42 | captured = capsys.readouterr() 43 | expected = Path(opt.OutputOptions.bootstrap_css).read_text().strip() 44 | assert captured.out.strip() == expected 45 | 46 | 47 | def test_print_jinja_template(capsys): 48 | cli.print_jinja_template() 49 | captured = capsys.readouterr() 50 | expected = Path(opt.OutputOptions.jinja_template).read_text().strip() 51 | assert captured.out.strip() == expected 52 | 53 | 54 | def test_print_default_options(capsys): 55 | cli.print_default_options() 56 | captured = capsys.readouterr() 57 | expected = opt.OutputOptions()._to_yaml_str().strip() 58 | assert captured.out.strip() == expected 59 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: esparto 2 | theme: 3 | name: material 4 | icon: 5 | logo: 6 | material/poll 7 | palette: 8 | primary: 'teal' 9 | accent: 'cyan' 10 | features: 11 | - 'navigation.tabs' 12 | - 'navigation.sections' 13 | - 'toc.integrate' 14 | 15 | repo_url: 'https://github.com/domvwt/esparto/' 16 | 17 | markdown_extensions: 18 | - admonition 19 | - pymdownx.superfences 20 | - toc: 21 | toc_depth: 2 22 | 23 | nav: 24 | - Overview: 'index.md' 25 | - User Guide: 26 | - 'Quick Start': '02-user-guide/quick-start.md' 27 | - 'Report Style': '02-user-guide/report-style.md' 28 | - 'Tutorial Notebooks': '02-user-guide/tutorial-notebooks.md' 29 | - API Reference: 30 | - 'Layout': '03-api-reference/layout.md' 31 | - 'Content': '03-api-reference/content.md' 32 | - 'Options': '03-api-reference/options.md' 33 | - 'Publish': '03-api-reference/publish.md' 34 | - 'Adaptors': '03-api-reference/adaptors.md' 35 | - About: 36 | - 'Authors': '04-about/authors.md' 37 | # - 'Contributing': '04-about/contributing.md' 38 | - 'Release Notes': '04-about/release-notes.md' 39 | # - 'Roadmap': '04-about/roadmap.md' 40 | - 'License': '04-about/license.md' 41 | 42 | extra_css: 43 | - stylesheets/extra.css 44 | 45 | plugins: 46 | - search 47 | - mkdocstrings: 48 | handlers: 49 | python: 50 | rendering: 51 | show_root_heading: True 52 | show_root_toc_entry: False 53 | show_root_full_path: False 54 | selection: 55 | inherited_members: False 56 | 57 | extra: 58 | social: 59 | - icon: fontawesome/brands/linkedin 60 | link: https://www.linkedin.com/in/dominic-thorn/ 61 | name: LinkedIn 62 | - icon: fontawesome/solid/address-card 63 | link: https://domvwt.github.io/ 64 | name: domvwt | portfolio 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Scratch files for testing 2 | /scratch 3 | *scratch*.ipynb 4 | *scratch*.py 5 | image.jpg 6 | esparto-doc.html 7 | esparto-quick.html 8 | esparto-doc.pdf 9 | docs/examples/*.html 10 | /*.html 11 | /*.pdf 12 | 13 | # IDE files 14 | .vscode 15 | .idea 16 | 17 | # Byte-compiled / optimized / DLL files 18 | __pycache__/ 19 | *.py[cod] 20 | *$py.class 21 | 22 | # C extensions 23 | *.so 24 | 25 | # Distribution / packaging 26 | .Python 27 | env/ 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | *.egg-info/ 41 | .installed.cfg 42 | *.egg 43 | 44 | # PyInstaller 45 | # Usually these files are written by a python script from a template 46 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 47 | *.manifest 48 | *.spec 49 | 50 | # Installer logs 51 | pip-log.txt 52 | pip-delete-this-directory.txt 53 | 54 | # Unit test / coverage reports 55 | htmlcov/ 56 | .tox/ 57 | .coverage 58 | .coverage.* 59 | .cache 60 | nosetests.xml 61 | coverage.xml 62 | *.cover 63 | .hypothesis/ 64 | .pytest_cache/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # dotenv 100 | .env 101 | 102 | # virtualenv 103 | .venv 104 | venv/ 105 | ENV/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .markdownlint.json 120 | my-report.html 121 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "esparto" 3 | version = "4.3.1" 4 | description = "Data driven report builder for the PyData ecosystem." 5 | authors = ["Dominic Thorn "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://domvwt.github.io/esparto" 9 | repository = "https://github.com/domvwt/esparto" 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Programming Language :: Python :: 3.6", 13 | "Programming Language :: Python :: 3.7", 14 | "Programming Language :: Python :: 3.8", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Intended Audience :: Developers", 19 | ] 20 | 21 | [tool.poetry.dependencies] 22 | python = ">=3.6.1" 23 | jinja2 = ">=2.10.1" 24 | markdown = ">=3.1" 25 | pyyaml = ">=5.1" 26 | beautifulsoup4 = ">=4.7" 27 | dataclasses = {version = "*", python = "<3.7"} 28 | 29 | # Optional dependencies 30 | weasyprint = {version = ">=51", optional = true} 31 | 32 | [tool.poetry.dev-dependencies] 33 | black = {version = "^22.0", python = ">3.8"} 34 | pytest = "^6.2.2" 35 | rope = "^0.19.0" 36 | IPython = "^7.3" 37 | mkdocs = "^1.1.2" 38 | matplotlib = {version = ">=3.4.0", python = ">=3.8"} 39 | html5lib = "^1.1" 40 | numpy = {version = "^1.22", python = ">=3.8"} 41 | pandas = {version = "^1.5.0", python = ">=3.8"} 42 | coverage = {version = "^5.5", python = "<4"} 43 | mkdocs-material = "^7.1.0" 44 | mkdocstrings = {version = "^0.15.0", python = "<4"} 45 | pre-commit = "^2.12.0" 46 | tox = "^3.23.0" 47 | mypy = "^0.902" 48 | isort = {version = "^5.8.0", python = "<4"} 49 | flake8 = "^3.9.0" 50 | bokeh = "^2.3.1" 51 | plotly = "^4.14.3" 52 | pandas-bokeh = "^0.5.5" 53 | weasyprint = "52.5" 54 | kaleido = "^0.2.1,!=0.2.1.post1" 55 | types-Markdown = "^3.3.0" 56 | pylint = {version = "^2.12.2", python = ">=3.6.2"} 57 | types-PyYAML = "^6.0.4" 58 | jupyter = "^1.0.0" 59 | 60 | [tool.poetry.extras] 61 | extras = ["weasyprint", "Pillow"] 62 | 63 | [build-system] 64 | requires = ["poetry>=0.12"] 65 | build-backend = "poetry.masonry.api" 66 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: tests 5 | 6 | on: 7 | push: 8 | branches: [ main, develop ] 9 | pull_request: 10 | branches: [ main, develop ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: true 18 | matrix: 19 | os: [ubuntu-latest] 20 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 21 | include: 22 | - os: ubuntu-20.04 23 | python-version: "3.6" 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - uses: actions/cache@v3 32 | with: 33 | path: ~/.cache/pip 34 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 35 | restore-keys: | 36 | ${{ runner.os }}-pip- 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | python -m pip install flake8 pytest coverage html5lib 41 | python -m pip install . 42 | - name: Lint with flake8 43 | run: | 44 | # stop the build if there are Python syntax errors or undefined names 45 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 46 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 47 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 48 | - name: Test minimal install with pytest 49 | run: | 50 | coverage run --append --source esparto -m pytest 51 | - name: Install optional dependencies 52 | run: | 53 | python -m pip install ipython pandas matplotlib bokeh plotly kaleido "weasyprint<53" html5lib 54 | - name: Test full install with pytest 55 | run: | 56 | coverage run --append --source esparto -m pytest 57 | - name: Upload coverage to codecov.io 58 | run: | 59 | bash <(curl -s https://codecov.io/bash) 60 | -------------------------------------------------------------------------------- /docs/02-user-guide/report-style.md: -------------------------------------------------------------------------------- 1 | # Report Style 2 | 3 | ## Output Options 4 | 5 | Customising the look and feel of **esparto** pages is best achieved through 6 | modifying the default Jinja template and CSS style sheet. 7 | New templates and styles can be passed to `es.options` to replace the global 8 | defaults, or passed to the `es.Page` constructor using the `es.OutputOptions` class. 9 | 10 | ```python 11 | # Updating global defaults. 12 | es.options.esparto_css = "./esparto.css" 13 | es.options.jinja_template = "./esparto.html.jinja" 14 | es.options.matplotlib.notebook_format = "png" 15 | ``` 16 | 17 | ```python 18 | # Using page level options. 19 | output_options = es.OutputOptions() 20 | 21 | output_options.esparto_css = "./esparto.css" 22 | output_options.jinja_template = "./esparto.html.jinja" 23 | output_options.matplotlib.notebook_format = "png" 24 | 25 | page = es.Page(output_options=output_options) 26 | ``` 27 | 28 | Options can be saved and loaded from disk using class methods. 29 | If an `esparto-config.yaml` file is found in the working directory, or at 30 | `~/esparto-data/esparto-config.yaml`, it will be loaded automatically when 31 | **esparto** is imported. 32 | 33 | ```python 34 | # These options will be loaded automatically for sessions in the same directory. 35 | output_options.save("./esparto-config.yaml") 36 | 37 | # These will be loaded only if no yaml file is found in the working directory. 38 | output_options.save("~/esparto-data/esparto-config.yaml") 39 | ``` 40 | 41 | ## Printing Default Resources 42 | 43 | It's recommended to use the standard CSS and Jinja template files as a starting 44 | point for any changes. 45 | A command line interface is provided for printing the default resources. 46 | 47 | ```bash 48 | # Print default esparto.css to `esparto.css`. 49 | python -m esparto print_esparto_css > esparto.css 50 | ``` 51 | 52 | ```bash 53 | # Print default jinja template to `esparto.html.jinja`. 54 | python -m esparto print_jinja_template > esparto.html.jinja 55 | ``` 56 | 57 | ```bash 58 | # Print default Bootstrap CSS to `bootstrap.css`. 59 | python -m esparto print_bootstrap_css > bootstrap.css 60 | ``` 61 | 62 | ```bash 63 | # Print default output options to `esparto-config.yaml`. 64 | python -m esparto print_default_options > esparto-config.yaml 65 | ``` 66 | 67 | ## More Options 68 | 69 | For details on additional options please read the 70 | [documentation for the Options module.](/03-api-reference/options/) 71 | 72 |
73 | -------------------------------------------------------------------------------- /docs/02-user-guide/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | A brief overview of the main API features. 4 | 5 | ## Create a Page 6 | 7 | ```python 8 | import esparto as es 9 | 10 | page = es.Page(title="Page Title") 11 | page["Section Title"] += "Some text" 12 | page["Section Title"] += "More text" 13 | 14 | page.tree() 15 | ``` 16 | 17 | ``` 18 | {'Page Title': [{'Section Title': [{'Row 0': [{'Column 0': ['Markdown']}]}, 19 | {'Row 1': [{'Column 0': ['Markdown']}]}]}]} 20 | ``` 21 | 22 | ## Add Content 23 | 24 | ### Define Rows and Columns 25 | 26 | ```python 27 | page["Section Title"]["Row Title"][0] = "Some content" 28 | page["Section Title"]["Row Title"][1] = "More content" 29 | ``` 30 | 31 | ``` 32 | {'Page Title': [{'Section Title': [{'Row Title': [{'Column 0': ['Markdown']}, 33 | {'Column 1': ['Markdown']}]}]}]} 34 | ``` 35 | 36 | ### Define multiple Columns 37 | 38 | ```python 39 | page["Section Title"]["Row Title"] = ( 40 | {"Column Title": "Some content"}, 41 | {"Column Two": "More content"} 42 | ) 43 | ``` 44 | 45 | ``` 46 | {'Page Title': [{'Section Title': [{'Row Title': [{'Column Title': ['Markdown']}, 47 | {'Column Two': ['Markdown']}]}]}]} 48 | ``` 49 | 50 | ## Update Content 51 | 52 | ### Access existing content via Indexing or as Attributes 53 | 54 | ```python 55 | page["Section Title"]["Row Title"]["Column Title"] = image_01 56 | page.section_title.row_title.column_two = image_02 57 | ``` 58 | 59 | ``` 60 | {'Page Title': [{'Section Title': [{'Row Title': [{'Column Title': ['Image']}, 61 | {'Column Two': ['Image']}]}]}]} 62 | ``` 63 | 64 | ## Delete Content 65 | 66 | ### Delete the last Column 67 | 68 | ```python 69 | del page.section_title.row_title[-1] 70 | ``` 71 | 72 | ``` 73 | {'Page Title': [{'Section Title': [{'Row Title': [{'Column Title': ['Image']}]}]}]} 74 | ``` 75 | 76 | ### Delete a named Column 77 | 78 | ```python 79 | del page.section_title.row_title.column_two 80 | ``` 81 | 82 | ``` 83 | {'Page Title': [{'Section Title': [{'Row Title': [{'Column Title': ['Image']}]}]}]} 84 | ``` 85 | 86 | ## Save the Document 87 | 88 | ### As a webpage 89 | 90 | ```python 91 | my_page.save_html("my-esparto-doc.html") 92 | ``` 93 | 94 | ### As a PDF 95 | 96 | ```python 97 | my_page.save_pdf("my-esparto-doc.pdf") 98 | ``` 99 | 100 |
101 | -------------------------------------------------------------------------------- /tests/resources/markdown.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Heading 1 4 | 5 | ## Heading 2 6 | 7 | ### Heading 3 8 | 9 | #### Heading 4 10 | 11 | ##### Heading 5 12 | 13 | ###### Heading 6 14 | 15 | --- 16 | 17 | Paragraph 18 | 19 | text `Inline Code` text 20 | 21 | Mistaken text. 22 | 23 | *Italics* 24 | 25 | **Bold** 26 | 27 | --- 28 | 29 | Tasks 30 | 31 | - [ ] a task list item 32 | - [ ] list syntax required 33 | - [ ] normal **formatting** 34 | - [ ] incomplete 35 | - [x] completed 36 | 37 | --- 38 | 39 | Code Blocks 40 | 41 | 4 space indention 42 | makes full-width 43 | standard code blocks 44 | 45 | ```js 46 | var now = new Date(); 47 | 48 | var days = new Array('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'); 49 | 50 | var months = new Array('January','February','March','April','May','June','July','August','September','October','November','December'); 51 | 52 | var date = ((now.getDate()<10) ? "0" : "")+ now.getDate(); 53 | 54 | function fourdigits(number) { 55 | return (number < 1000) ? number + 1900 : number; 56 | } 57 | today = days[now.getDay()] + ", " + 58 | months[now.getMonth()] + " " + 59 | date + ", " + 60 | (fourdigits(now.getYear())) ; 61 | 62 | document.write(today); 63 | ``` 64 | 65 | ```css 66 | #sc_drag_area { 67 | height:100px; 68 | left:150px; 69 | position: absolute; 70 | top:100px; 71 | width:250px; 72 | z-index: 9999; 73 | } 74 | ``` 75 | 76 | --- 77 | 78 | - List item one 79 | - List item two 80 | - A nested item 81 | 82 | --- 83 | 84 | 1. Number list item one 85 | 1.1. A nested item 86 | 2. Number list item two 87 | 3. Number list item three 88 | 89 | --- 90 | 91 | > Quote 92 | > 93 | > Second line Quote 94 | 95 | --- 96 | 97 | Standard link = 98 | 99 | [Custom Text Link](http://ghost.org) 100 | 101 | --- 102 | 103 | ![Image](https://unsplash.com/photos/phIFdC6lA4E/download?ixid=MnwxMjA3fDB8MXxzZWFyY2h8Mnx8bW91bnRhaW58fDB8fHx8MTY0MzM1NTM4OQ&force=true&w=256) 104 | 105 | --- 106 | 107 | Table 108 | 109 | | Left-Aligned | Center Aligned | Right Aligned | 110 | | :------------ |:---------------:| -----:| 111 | | col 3 is | some wordy text | $1600 | 112 | | col 2 is | centered | $12 | 113 | | zebra stripes | are neat | $1 | 114 | 115 | ---- 116 | 117 | *From Wikipedia:* [*Markdown*](https://en.wikipedia.org/wiki/Markdown) 118 | -------------------------------------------------------------------------------- /esparto/publish/contentdeps.py: -------------------------------------------------------------------------------- 1 | """Content dependency management.""" 2 | 3 | from dataclasses import dataclass, field 4 | from pathlib import Path 5 | from typing import List, Optional, Set 6 | 7 | from esparto import _OptionalDependencies 8 | from esparto._options import options 9 | 10 | 11 | @dataclass 12 | class ContentDependency: 13 | name: str 14 | cdn: str 15 | inline: str 16 | target: str 17 | 18 | 19 | @dataclass 20 | class ResolvedDeps: 21 | head: List[str] = field(default_factory=list) 22 | tail: List[str] = field(default_factory=list) 23 | 24 | 25 | class ContentDependencyDict(dict): # type: ignore 26 | def __add__(self, item: ContentDependency) -> "ContentDependencyDict": 27 | super().__setitem__(item.name, item) 28 | return self 29 | 30 | 31 | JS_DEPS = {"bokeh", "plotly"} 32 | 33 | 34 | def lazy_content_dependency_dict() -> ContentDependencyDict: 35 | bootstrap_inline = Path(options.bootstrap_css).read_text() 36 | bootstrap_inline = f"" 37 | 38 | content_dependency_dict = ContentDependencyDict() 39 | content_dependency_dict += ContentDependency( 40 | "bootstrap", options.bootstrap_cdn, bootstrap_inline, "head" 41 | ) 42 | 43 | if _OptionalDependencies.bokeh: 44 | import bokeh.resources as bk_resources # type: ignore 45 | 46 | bokeh_cdn = bk_resources.CDN.render_js() 47 | bokeh_inline = bk_resources.INLINE.render_js() 48 | 49 | content_dependency_dict += ContentDependency( 50 | "bokeh", bokeh_cdn, bokeh_inline, "tail" 51 | ) 52 | 53 | if _OptionalDependencies.plotly: 54 | from plotly import offline as plotly_offline # type: ignore 55 | 56 | plotly_version = "latest" 57 | plotly_cdn = f"" 58 | plotly_inline = plotly_offline.get_plotlyjs() 59 | plotly_inline = f"" 60 | 61 | content_dependency_dict += ContentDependency( 62 | "plotly", plotly_cdn, plotly_inline, "head" 63 | ) 64 | 65 | return content_dependency_dict 66 | 67 | 68 | def resolve_deps(required_deps: Set[str], source: Optional[str]) -> ResolvedDeps: 69 | resolved_deps = ResolvedDeps() 70 | 71 | if source not in {"cdn", "inline"}: 72 | raise ValueError("Dependency source must be one of {'cdn', 'inline'}") 73 | 74 | source = options.dependency_source 75 | 76 | for dep in required_deps: 77 | dep_details: ContentDependency = lazy_content_dependency_dict()[dep] 78 | getattr(resolved_deps, dep_details.target).append(getattr(dep_details, source)) 79 | 80 | return resolved_deps 81 | -------------------------------------------------------------------------------- /tests/design/test_adaptors.py: -------------------------------------------------------------------------------- 1 | from inspect import getmembers, isfunction, signature 2 | from pathlib import Path, PosixPath 3 | 4 | import pytest 5 | 6 | import esparto.design.adaptors as ad 7 | from esparto import _OptionalDependencies 8 | from esparto.design.content import Content, Markdown 9 | from esparto.design.layout import Column 10 | from tests.conftest import adaptor_list 11 | 12 | 13 | def get_dispatch_type(fn): 14 | sig = signature(fn) 15 | if "content" in sig.parameters: 16 | return sig.parameters["content"].annotation 17 | 18 | 19 | def test_all_adaptors_covered(adaptor_list_fn): 20 | test_classes = {type(item[0]) for item in adaptor_list_fn} 21 | module_functions = [x[1] for x in getmembers(ad, isfunction)] 22 | adaptor_types = {get_dispatch_type(fn) for fn in module_functions} 23 | adaptor_types.remove(Content) # Can't use abstract base class in a test 24 | if _OptionalDependencies.bokeh: 25 | adaptor_types.remove(ad.BokehObject) # Can't use abstract base class in a test 26 | if PosixPath in test_classes: 27 | test_classes.remove(PosixPath) 28 | test_classes = adaptor_types | {Path} 29 | if None in adaptor_types: 30 | adaptor_types.remove(None) 31 | missing = adaptor_types.difference(test_classes) 32 | assert not missing, missing 33 | 34 | 35 | @pytest.mark.parametrize("input_,expected", adaptor_list) 36 | def test_adaptor_text(input_, expected): 37 | output = ad.content_adaptor(input_) 38 | assert isinstance(output, expected) 39 | 40 | 41 | def test_adaptor_layout(): 42 | input_col = Column(title="title", children=["a", "b"]) 43 | output_col = ad.content_adaptor(input_col) 44 | assert input_col == output_col 45 | 46 | 47 | def test_adapator_textfile(tmp_path): 48 | d = tmp_path / "sub" 49 | d.mkdir() 50 | p = d / "hello.exe" 51 | CONTENT = "# This is some Markdown content" 52 | p.write_text(CONTENT) 53 | with pytest.raises(TypeError): 54 | ad.content_adaptor(Path(p)) 55 | 56 | 57 | def test_adapator_bad_file(tmp_path): 58 | d = tmp_path / "sub" 59 | d.mkdir() 60 | p = d / "hello.txt" 61 | CONTENT = "# This is some Markdown content" 62 | p.write_text(CONTENT) 63 | assert ad.content_adaptor(Path(p)) == Markdown(CONTENT) 64 | assert ad.content_adaptor(str(p)) == Markdown(CONTENT) 65 | 66 | 67 | def test_incorrect_content_rejected(): 68 | class FakeClass: 69 | def __call__(self): 70 | return "I'm not supported" 71 | 72 | fake = FakeClass() 73 | 74 | with pytest.raises(TypeError): 75 | ad.content_adaptor(fake) 76 | 77 | 78 | def test_adaptor_dict_bad(): 79 | bad_dict = {"key1": "val1", "key2": "val2"} 80 | with pytest.raises(ValueError): 81 | ad.content_adaptor(bad_dict) 82 | -------------------------------------------------------------------------------- /esparto/design/base.py: -------------------------------------------------------------------------------- 1 | """Abstract design classes to help decoupling of domain from implementation.""" 2 | 3 | from abc import ABC 4 | from typing import Any, List, Set, TypeVar, Union 5 | 6 | T = TypeVar("T", bound="AbstractLayout") 7 | 8 | Child = Union["AbstractLayout", "AbstractContent", Any] 9 | 10 | 11 | class AbstractLayout(ABC): 12 | """Class Template for Layout elements. 13 | 14 | Layout class hierarchy: 15 | `Page -> Section -> Row -> Column -> Content` 16 | 17 | Attributes: 18 | title (str): Object title. Used as a title within the page and as a key value. 19 | children (list): Child items defining the page layout and content. 20 | title_classes (list): Additional CSS classes to apply to title. 21 | title_styles (dict): Additional CSS styles to apply to title. 22 | body_classes (list): Additional CSS classes to apply to body. 23 | body_styles (dict): Additional CSS styles to apply to body. 24 | 25 | """ 26 | 27 | # ------------------------------------------------------------------------+ 28 | # Public Methods | 29 | # ------------------------------------------------------------------------+ 30 | 31 | def display(self) -> None: 32 | """Render content in a Notebook environment.""" 33 | raise NotImplementedError 34 | 35 | def get_identifier(self) -> str: 36 | """Get the HTML element ID for the current object.""" 37 | raise NotImplementedError 38 | 39 | def get_title_identifier(self) -> str: 40 | """Get the HTML element ID for the current object title.""" 41 | raise NotImplementedError 42 | 43 | def set_children(self, other: Union[List[Child], Child]) -> None: 44 | """Set children as `other`.""" 45 | raise NotImplementedError 46 | 47 | def to_html(self, **kwargs: bool) -> str: 48 | """Render object as HTML string. 49 | 50 | Returns: 51 | html (str): HTML string. 52 | 53 | """ 54 | raise NotImplementedError 55 | 56 | def tree(self) -> None: 57 | """Display page tree.""" 58 | raise NotImplementedError 59 | 60 | 61 | class AbstractContent(ABC): 62 | """Template for Content elements. 63 | 64 | Attributes: 65 | content (Any): Item to be included in the page - should match the encompassing Content class. 66 | 67 | """ 68 | 69 | content: Any 70 | _dependencies: Set[str] 71 | 72 | def to_html(self, **kwargs: bool) -> str: 73 | """Convert content to HTML string. 74 | 75 | Returns: 76 | str: HTML string. 77 | 78 | """ 79 | raise NotImplementedError 80 | 81 | def display(self) -> None: 82 | """Display rendered content in a Jupyter Notebook cell.""" 83 | raise NotImplementedError 84 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | format: ## apply black code formatter 54 | black . 55 | 56 | lint: ## check style with flake8 57 | flake8 esparto tests 58 | 59 | mypy: ## check type hints 60 | mypy esparto 61 | 62 | isort: ## sort imports 63 | isort esparto tests 64 | 65 | cqa: format isort lint mypy ## run all cqa tools 66 | 67 | test: ## run tests quickly with the default Python 68 | pytest 69 | 70 | test-all: ## run tests on every Python version with tox 71 | tox --skip-missing-interpreters 72 | python -m tests.check_package_version 73 | 74 | coverage: ## check code coverage quickly with the default Python 75 | -coverage run --source esparto -m pytest 76 | coverage report -m 77 | coverage html 78 | # $(BROWSER) htmlcov/index.html 79 | 80 | docstrings: ## generate google format docstrings 81 | pyment esparto -o google -w 82 | 83 | docs: class-diagram ## generate documentation, including API docs 84 | mkdocs build --clean 85 | 86 | servedocs: ## compile the docs watching for changes 87 | mkdocs serve 88 | 89 | deploydocs: ## deploy docs to github pages 90 | mkdocs gh-deploy 91 | 92 | class-diagram: ## make UML class diagram 93 | pyreverse esparto -o png --ignore cdnlinks.py,contentdeps.py,_options.py, 94 | mv classes.png devdocs/classes.png 95 | rm packages.png 96 | 97 | reqs: ## output requirements.txt 98 | poetry export -f requirements.txt -o requirements.txt --without-hashes 99 | 100 | release: dist ## package and upload a release 101 | twine upload dist/* 102 | 103 | dist: clean ## builds source and wheel package 104 | poetry build 105 | ls -l dist 106 | 107 | hooks: ## run pre-commit hooks on all files 108 | pre-commit run -a 109 | 110 | install: clean ## install the package to the active Python's site-packages 111 | poetry install 112 | 113 | example_pages: ## make example pages 114 | python tests/example_pages.py 115 | -------------------------------------------------------------------------------- /tests/design/test_content.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | import pytest 4 | 5 | import esparto.design.content as co 6 | import esparto.design.layout as la 7 | from esparto import _OptionalDependencies 8 | from tests.conftest import content_list 9 | 10 | 11 | @pytest.mark.parametrize("a", content_list) 12 | @pytest.mark.parametrize("b", content_list) 13 | def test_content_add(a, b): 14 | output = a + b 15 | expected = la.Row(children=[a, b]) 16 | assert output == expected 17 | 18 | 19 | def test_content_equality(content_list_fn): 20 | for i, a in enumerate(content_list_fn): 21 | for j, b in enumerate(content_list_fn): 22 | if i == j: 23 | assert a == b 24 | else: 25 | assert a != b 26 | 27 | 28 | if _OptionalDependencies().all_extras(): 29 | 30 | def test_all_content_classes_covered(content_list_fn): 31 | test_classes = {type(c) for c in content_list_fn} 32 | module_classes = {c for c in co.Content.__subclasses__()} 33 | module_subclasses = [d.__subclasses__() for d in module_classes] 34 | module_all = set(chain.from_iterable(module_subclasses)) | module_classes 35 | missing = module_all.difference(test_classes) 36 | assert not missing, missing 37 | 38 | def test_all_content_classes_have_deps(content_list_fn): 39 | # RawHTML has no dependencies 40 | deps = [ 41 | c._dependencies for c in content_list_fn if not isinstance(c, co.RawHTML) 42 | ] 43 | assert all(deps) 44 | 45 | 46 | @pytest.mark.parametrize("a", content_list) 47 | def test_incorrect_content_rejected(a): 48 | b = type(a) 49 | 50 | class FakeClass: 51 | def __init__(self): 52 | self.supported = False 53 | 54 | fake = FakeClass() 55 | 56 | with pytest.raises(TypeError): 57 | b(fake) 58 | 59 | 60 | def test_table_of_contents(): 61 | input_page = la.Page(title="My Page") 62 | input_page["Section One"]["Item A"] = "some text" 63 | input_page["Section One"]["Item B"] = "more text" 64 | input_page["Section Two"]["Item C"]["Item D"] = "and more text" 65 | input_page["Section Two"]["Item C"]["Item E"] = "even more text" 66 | 67 | output_toc = co.table_of_contents(input_page, numbered=False) 68 | expected_toc = co.Markdown( 69 | " * [Section One](#section_one-title)\n\t" 70 | " * [Item A](#item_a-title)\n\t" 71 | " * [Item B](#item_b-title)\n" 72 | " * [Section Two](#section_two-title)\n\t" 73 | " * [Item C](#item_c-title)\n\t\t" 74 | " * [Item D](#item_d-title)\n\t\t" 75 | " * [Item E](#item_e-title)" 76 | ) 77 | assert output_toc == expected_toc 78 | 79 | 80 | def test_table_of_contents_numbered(): 81 | input_page = la.Page(title="My Page") 82 | input_page["Section One"]["Item A"] = "some text" 83 | input_page["Section One"]["Item B"] = "more text" 84 | input_page["Section Two"]["Item C"]["Item D"] = "and more text" 85 | input_page["Section Two"]["Item C"]["Item E"] = "even more text" 86 | 87 | output_toc = co.table_of_contents(input_page, numbered=True) 88 | expected_toc = co.Markdown( 89 | " 1. [Section One](#section_one-title)\n\t" 90 | " 1. [Item A](#item_a-title)\n\t" 91 | " 1. [Item B](#item_b-title)\n" 92 | " 1. [Section Two](#section_two-title)\n\t" 93 | " 1. [Item C](#item_c-title)\n\t\t" 94 | " 1. [Item D](#item_d-title)\n\t\t" 95 | " 1. [Item E](#item_e-title)" 96 | ) 97 | assert output_toc == expected_toc 98 | -------------------------------------------------------------------------------- /esparto/_cli.py: -------------------------------------------------------------------------------- 1 | """Command line utilities for esparto.""" 2 | 3 | from argparse import SUPPRESS, ArgumentParser, _SubParsersAction 4 | from pathlib import Path 5 | from typing import Any, Callable, Dict, List, Tuple 6 | 7 | import esparto._options as opt 8 | from esparto import __version__ 9 | 10 | PROG = "esparto" 11 | DESCRIPTION = "Command line utilities for esparto." 12 | EPILOG = "Run program with no arguments for subcommand help." 13 | 14 | parser = ArgumentParser(prog=PROG, usage=None, description=DESCRIPTION, epilog=EPILOG) 15 | subparsers = parser.add_subparsers(dest="subcommand") 16 | 17 | parser.add_argument("-v", "--version", action="version", version=__version__) 18 | 19 | 20 | CliArg = Tuple[List[str], Dict[str, Any]] 21 | 22 | 23 | def argument(*name_or_flags: str, **kwargs: Dict[str, Any]) -> CliArg: 24 | """Convenience function to properly format arguments to pass to the 25 | subcommand decorator. 26 | """ 27 | return (list(name_or_flags), kwargs) 28 | 29 | 30 | def subcommand( 31 | *subparser_args: CliArg, parent: _SubParsersAction = subparsers 32 | ) -> Callable[..., Any]: 33 | """Decorator to define a new subcommand in a sanity-preserving way. 34 | The function will be stored in the ``func`` variable when the parser 35 | parses arguments so that it can be called directly like so:: 36 | args = cli.parse_args() 37 | args.func(args) 38 | Usage example:: 39 | @subcommand([argument("-d", help="Enable debug mode", action="store_true")]) 40 | def subcommand(args): 41 | print(args) 42 | Then on the command line:: 43 | $ python cli.py subcommand -d 44 | """ 45 | 46 | def decorator(func: Callable[..., Any]) -> Callable[..., Any]: 47 | parser_ = parent.add_parser( 48 | func.__name__, description=func.__doc__, add_help=False, usage=SUPPRESS 49 | ) 50 | for args, kwargs in subparser_args: 51 | parser_.add_argument(*args, **kwargs) 52 | parser_.set_defaults(func=func) 53 | return func 54 | 55 | return decorator 56 | 57 | 58 | @subcommand() 59 | def print_esparto_css(*args: Any) -> None: 60 | """print default esparto CSS""" 61 | css = Path(opt.OutputOptions.esparto_css).read_text() 62 | print(css) 63 | 64 | 65 | @subcommand() 66 | def print_bootstrap_css(*args: Any) -> None: 67 | """print default Bootstrap CSS""" 68 | css = Path(opt.OutputOptions.bootstrap_css).read_text() 69 | print(css) 70 | 71 | 72 | @subcommand() 73 | def print_jinja_template(*args: Any) -> None: 74 | """print default jinja template""" 75 | css = Path(opt.OutputOptions.jinja_template).read_text() 76 | print(css) 77 | 78 | 79 | @subcommand() 80 | def print_default_options(*args: Any) -> None: 81 | """print default output options""" 82 | print(opt.OutputOptions()._to_yaml_str()) 83 | 84 | 85 | def print_subcommand_help() -> None: 86 | """Print help for subcommands.""" 87 | subparser_actions = [ 88 | action for action in parser._actions if isinstance(action, _SubParsersAction) 89 | ] 90 | print("subcommands:") 91 | for subparsers_action in subparser_actions: 92 | left_pad_size = max(len(x) for x in subparsers_action.choices.keys()) + 2 93 | left_pad_size = max(left_pad_size, 22) 94 | for choice, subparser in subparsers_action.choices.items(): 95 | print(f" {choice:<{left_pad_size}}{subparser.format_help().strip()}") 96 | 97 | 98 | def main() -> None: 99 | args = parser.parse_args() 100 | if args.subcommand is None: 101 | parser.print_help() 102 | print() 103 | print_subcommand_help() 104 | else: 105 | args.func(args) 106 | -------------------------------------------------------------------------------- /esparto/design/adaptors.py: -------------------------------------------------------------------------------- 1 | import functools as ft 2 | import mimetypes as mt 3 | from pathlib import Path 4 | from typing import Any, Dict, Union 5 | 6 | from esparto import _OptionalDependencies 7 | from esparto.design.content import ( 8 | Content, 9 | DataFramePd, 10 | FigureBokeh, 11 | FigureMpl, 12 | FigurePlotly, 13 | Image, 14 | Markdown, 15 | ) 16 | from esparto.design.layout import Layout 17 | 18 | 19 | @ft.singledispatch 20 | def content_adaptor(content: Content) -> Union[Content, Layout, Dict[str, Any]]: 21 | """ 22 | Wrap content in the required class. If Layout object is passed, return unchanged. 23 | 24 | Args: 25 | content (Any): Any content to be added to the document. 26 | 27 | Returns: 28 | Content: Appropriately wrapped content. 29 | 30 | """ 31 | if not issubclass(type(content), Content): 32 | raise TypeError(f"Unsupported content type: {type(content)}") 33 | return content 34 | 35 | 36 | @content_adaptor.register(str) 37 | @content_adaptor.register(Path) 38 | def content_adaptor_core(content: Union[str, Path]) -> Content: 39 | """Convert text or image to Markdown or Image content.""" 40 | content = str(content) 41 | guess = mt.guess_type(content) 42 | if guess and isinstance(guess[0], str): 43 | file_type = guess[0].split("/")[0] 44 | if file_type == "image": 45 | return Image(content) 46 | elif file_type == "text": 47 | content = Path(content).read_text() 48 | else: 49 | raise TypeError(f"{content}: {file_type}") 50 | return Markdown(content) 51 | 52 | 53 | @content_adaptor.register(Layout) 54 | def content_adaptor_layout(content: Layout) -> Layout: 55 | """If Layout object is passed, return unchanged.""" 56 | return content 57 | 58 | 59 | @content_adaptor.register(dict) 60 | def content_adaptor_dict(content: Dict[str, Any]) -> Dict[str, Any]: 61 | """Pass through dict of `{"title": content}`.""" 62 | if not (len(content) == 1 and isinstance(list(content.keys())[0], str)): 63 | raise ValueError("Content dict must be passed as {'title': content}") 64 | return content 65 | 66 | 67 | # Function only available if Pandas is installed. 68 | if _OptionalDependencies.pandas: 69 | from pandas.core.frame import DataFrame # type: ignore 70 | 71 | @content_adaptor.register(DataFrame) 72 | def content_adaptor_df(content: DataFrame) -> DataFramePd: 73 | """Convert Pandas DataFrame to DataFramePD content.""" 74 | return DataFramePd(content) 75 | 76 | 77 | # Function only available if Matplotlib is installed. 78 | if _OptionalDependencies.matplotlib: 79 | from matplotlib.figure import Figure # type: ignore 80 | 81 | @content_adaptor.register(Figure) 82 | def content_adaptor_mpl(content: Figure) -> FigureMpl: 83 | """Convert Matplotlib Figure to FigureMpl content.""" 84 | return FigureMpl(content) 85 | 86 | 87 | # Function only available if Bokeh is installed. 88 | if _OptionalDependencies.bokeh: 89 | from bokeh.layouts import LayoutDOM as BokehObject # type: ignore 90 | 91 | @content_adaptor.register(BokehObject) 92 | def content_adaptor_bokeh(content: BokehObject) -> FigureBokeh: 93 | """Convert Bokeh Layout to FigureBokeh content.""" 94 | return FigureBokeh(content) 95 | 96 | 97 | # Function only available if Plotly is installed. 98 | if _OptionalDependencies.plotly: 99 | from plotly.graph_objs._figure import Figure as PlotlyFigure # type: ignore 100 | 101 | @content_adaptor.register(PlotlyFigure) 102 | def content_adaptor_plotly(content: PlotlyFigure) -> FigurePlotly: 103 | """Convert Plotly Figure to FigurePlotly content.""" 104 | return FigurePlotly(content) 105 | -------------------------------------------------------------------------------- /esparto/resources/jinja/base.html.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ doc_title | default("esparto-doc", true) }} 6 | 8 | 9 | 10 | 11 | 12 | {% if head_deps is defined and head_deps|length %} 13 | 14 | {% for dep in head_deps %} 15 | {{ dep }} 16 | {% endfor %} 17 | {% endif %} 18 | 19 | {% if esparto_css %} 20 | 21 | 24 | {% endif %} 25 | 26 | 27 | 28 | 29 | 33 | 34 | 35 |
36 | {{ content }} 37 |
38 | 39 | 40 | 65 | 66 | 67 | {% if tail_deps is defined and tail_deps|length %} 68 | {% for dep in tail_deps %} 69 | {{ dep }} 70 | {% endfor %} 71 | {% endif %} 72 | 73 | {% if esparto_js %} 74 | 77 | {% endif %} 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /esparto/resources/css/esparto.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, system-ui, -apple-system, BlinkMacSystemFont; 3 | } 4 | 5 | h1, 6 | h2, 7 | h3, 8 | h4, 9 | h5, 10 | h6, 11 | p, 12 | .es-page-title, 13 | .es-section-title, 14 | .es-row-title, 15 | .es-column-title, 16 | .es-card-title { 17 | margin-top: 0.75em; 18 | margin-bottom: 0; 19 | padding-top: 0; 20 | padding-bottom: 0.5em; 21 | } 22 | 23 | .es-page-title { 24 | font-size: 3.5em; 25 | font-weight: 300; 26 | margin-left: -0.1em; 27 | } 28 | 29 | h1 { 30 | font-size: 2em; 31 | } 32 | 33 | h2, 34 | .es-section-title { 35 | font-size: 1.5em; 36 | } 37 | 38 | h3, 39 | .es-row-title, 40 | .es-column-title, 41 | .es-card-title { 42 | font-size: 1.2em; 43 | } 44 | 45 | h4 { 46 | font-size: 1em; 47 | } 48 | 49 | h5 { 50 | font-size: 1em; 51 | } 52 | 53 | h6 { 54 | font-size: 0.9em; 55 | } 56 | 57 | p { 58 | margin-top: 0.25em; 59 | margin-bottom: 0.25em; 60 | } 61 | 62 | a:link { 63 | text-decoration: none; 64 | } 65 | 66 | a:visited { 67 | text-decoration: none; 68 | } 69 | 70 | a:hover { 71 | text-decoration: none; 72 | } 73 | 74 | a:active { 75 | text-decoration: none; 76 | } 77 | 78 | .es-figure { 79 | text-align: center; 80 | margin: 0; 81 | padding: 0.5rem; 82 | padding-bottom: 1rem; 83 | } 84 | 85 | .es-matplotlib-figure, 86 | .es-bokeh-figure, 87 | .es-plotly-figure { 88 | display: flex; 89 | justify-content: center; 90 | width: 100%; 91 | height: auto; 92 | margin: auto; 93 | margin-bottom: 1rem; 94 | } 95 | 96 | .es-page-body { 97 | margin: auto; 98 | padding-left: 0.5rem; 99 | padding-right: 0.5rem; 100 | } 101 | 102 | .es-section-body { 103 | margin-bottom: 1rem; 104 | padding-left: 1px; 105 | padding-right: 1px; 106 | align-items: flex-start; 107 | } 108 | 109 | .es-row-body { 110 | align-items: flex-start; 111 | } 112 | 113 | .es-column-body { 114 | margin-right: 1rem; 115 | margin-bottom: 1rem; 116 | } 117 | 118 | .es-card { 119 | margin-top: 0.5rem; 120 | margin-bottom: 0.5rem; 121 | padding-left: 0.5rem !important; 122 | padding-right: 0.5rem !important; 123 | } 124 | 125 | .es-card-title { 126 | margin: 0; 127 | } 128 | 129 | .es-card-body { 130 | border: 1px solid #dee2e6; 131 | border-radius: 0.25rem; 132 | margin: 0; 133 | padding: 1.25rem; 134 | padding-left: 1.5rem; 135 | padding-right: 1.5rem; 136 | min-height: 100%; 137 | } 138 | 139 | .es-page-break { 140 | page-break-after: always; 141 | } 142 | 143 | .es-table { 144 | padding-top: 0.25rem; 145 | padding-bottom: 0.25rem; 146 | } 147 | 148 | .es-table td, 149 | .es-table th { 150 | padding: 0 !important; 151 | padding-left: 0.3rem !important; 152 | padding-right: 0.3rem !important; 153 | text-align: right; 154 | } 155 | 156 | .svg-content-mpl { 157 | width: 100%; 158 | height: 100%; 159 | top: 0px; 160 | bottom: 0px; 161 | margin: auto; 162 | } 163 | 164 | .es-icon { 165 | margin: 0.5em; 166 | margin-left: 1em; 167 | margin-right: 1em; 168 | font-size: 1.8em; 169 | color: var(--bs-gray-700) !important; 170 | fill: var(--bs-gray-700) !important; 171 | height: 0.9em; 172 | width: 0.9em; 173 | } 174 | 175 | .es-icon:hover, 176 | .es-icon:focus { 177 | cursor: pointer; 178 | color: var(--bs-primary) !important; 179 | fill: var(--bs-primary) !important; 180 | } 181 | 182 | @media print { 183 | 184 | .es-page-title, 185 | .es-section-title, 186 | .es-row-title, 187 | .es-column-title { 188 | page-break-after: avoid; 189 | } 190 | 191 | .es-row-body, 192 | .es-column-body, 193 | .es-card { 194 | page-break-inside: avoid; 195 | } 196 | 197 | .es-section-body { 198 | page-break-after: always; 199 | } 200 | 201 | .es-column-body, 202 | .es-card, 203 | .es-card-body { 204 | flex: 1 !important; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from io import BytesIO 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | import esparto.design.content as co 8 | import esparto.design.layout as la 9 | from esparto import _OptionalDependencies 10 | 11 | _irises_path = str(Path("tests/resources/irises.jpg").absolute()) 12 | _markdown_path = str(Path("tests/resources/markdown.md").absolute()) 13 | 14 | with Path(_irises_path).open("rb") as f: 15 | _irises_binary = BytesIO(f.read()) 16 | 17 | 18 | # Add new content classes here 19 | content_list = [ 20 | (co.Markdown("this _is_ some **markdown**")), 21 | (co.RawHTML("

Raw HTML

")), 22 | ] 23 | 24 | # Add new layout classes here 25 | layout_list = [ 26 | (la.Page(children=[*content_list])), 27 | (la.Section(children=[*content_list])), 28 | (la.Row(children=[*content_list])), 29 | (la.Column(children=[*content_list])), 30 | (la.Card(children=[*content_list])), 31 | (la.CardSection(children=[*content_list])), 32 | (la.CardRow(children=[*content_list])), 33 | (la.Spacer()), 34 | (la.PageBreak()), 35 | ] 36 | 37 | # Add new adaptor types here 38 | adaptor_list = [ 39 | ("this is markdown", co.Markdown), 40 | ({"key": "value"}, dict), 41 | (Path(_markdown_path), co.Markdown), 42 | ] 43 | 44 | if _OptionalDependencies().all_extras(): 45 | import bokeh.layouts as bkl # type: ignore 46 | import bokeh.plotting as bkp # type: ignore 47 | import matplotlib.pyplot as plt # type: ignore 48 | import pandas as pd # type: ignore 49 | import plotly.express as px # type: ignore 50 | 51 | if sys.version.startswith("3.6."): 52 | import matplotlib as mpl # type: ignore 53 | 54 | mpl.use("Agg") 55 | 56 | # svg output format cannot be parsed in testing 57 | content_extra = [ 58 | (co.Image(_irises_path)), 59 | (co.DataFramePd(pd.DataFrame({"a": range(1, 11), "b": range(11, 21)}))), 60 | (co.FigureMpl(plt.Figure(), output_format="png")), 61 | (co.FigureBokeh(bkp.figure())), 62 | (co.FigureBokeh(bkl.column(bkp.figure()))), 63 | (co.FigurePlotly(px.line(x=range(10), y=range(10)))), # type: ignore 64 | ] 65 | 66 | content_list += content_extra 67 | 68 | content_pdf = content_list + [ 69 | (co.FigureMpl(plt.Figure())), 70 | (co.FigureMpl(plt.Figure(), output_format="svg")), 71 | (co.FigureMpl(plt.Figure(), output_format="svg", pdf_figsize=0.9)), 72 | (co.FigureMpl(plt.Figure(), output_format="svg", pdf_figsize=(8, 5))), 73 | ] 74 | 75 | adaptors_extra = [ 76 | (_irises_path, co.Image), 77 | (Path(_irises_path), co.Image), 78 | (pd.DataFrame({"a": range(1, 11), "b": range(11, 21)}), co.DataFramePd), 79 | (plt.figure(), co.FigureMpl), 80 | (bkp.figure(), co.FigureBokeh), 81 | (bkl.column(bkp.figure()), co.FigureBokeh), 82 | (px.line(x=range(10), y=range(10)), co.FigurePlotly), 83 | ] 84 | 85 | adaptor_list += adaptors_extra 86 | 87 | 88 | @pytest.fixture 89 | def content_list_fn(): 90 | return content_list 91 | 92 | 93 | @pytest.fixture 94 | def layout_list_fn(): 95 | return layout_list 96 | 97 | 98 | @pytest.fixture 99 | def adaptor_list_fn(): 100 | return adaptor_list 101 | 102 | 103 | @pytest.fixture 104 | def page_layout(content_list_fn) -> la.Page: 105 | return la.Page( 106 | title="jazz", 107 | children=la.Section( 108 | children=la.Row(children=[la.Column(children=[x]) for x in content_list_fn]) 109 | ), 110 | ) 111 | 112 | 113 | @pytest.fixture 114 | def page_basic_layout() -> la.Page: 115 | page = la.Page( 116 | title="Test Page", 117 | children=la.Section( 118 | title="Section One", 119 | children=la.Row( 120 | title="Row One", 121 | children=la.Column(children=co.Markdown("markdown content")), 122 | ), 123 | ), 124 | ) 125 | return page 126 | 127 | 128 | @pytest.fixture 129 | def section_layout(image_content) -> la.Section: 130 | return la.Section(image_content) 131 | 132 | 133 | @pytest.fixture 134 | def markdown_content() -> co.Markdown: 135 | return co.Markdown("A") 136 | 137 | 138 | @pytest.fixture 139 | def image_content() -> co.Image: 140 | return co.Image(_irises_path) 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 2 | 3 |
4 |
5 | 6 |
7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 | **esparto** is a Python library for building data driven reports with content 17 | from popular analytics packages. 18 | 19 | - [Documentation][ProjectHome] 20 | - [Source Code][GitHub] 21 | - [Contributing](#contributions-issues-and-requests) 22 | - [Bug Reports][Issues] 23 | 24 | Main Features 25 | ------------- 26 | 27 | - Create beautiful analytical reports using idiomatic Python 28 | - Generate content from: 29 | - [Markdown][Markdown] 30 | - [Pandas DataFrames][Pandas] 31 | - [Matplotlib][Matplotlib] 32 | - [Bokeh][Bokeh] 33 | - [Plotly][Plotly] 34 | - Develop interactively with [Jupyter Notebooks][Jupyter] 35 | - Share documents as a self-contained webpage or PDF 36 | - Customise with [CSS][CSS] and [Jinja][Jinja] 37 | - Responsive [Bootstrap][Bootstrap] layout 38 | 39 | Basic Usage 40 | ----------- 41 | 42 | ```python 43 | import esparto as es 44 | 45 | # Do some analysis 46 | pandas_df = ... 47 | plot_fig = ... 48 | markdown_str = ... 49 | 50 | # Create a page 51 | page = es.Page(title="My Report") 52 | 53 | # Add content 54 | page["Data Analysis"]["Plot"] = plot_fig 55 | page["Data Analysis"]["Data"] = pandas_df 56 | page["Data Analysis"]["Notes"] = markdown_str 57 | 58 | # Save to HTML or PDF 59 | page.save_html("my-report.html") 60 | page.save_pdf("my-report.pdf") 61 | 62 | ``` 63 | 64 | Installation 65 | ------------ 66 | 67 | **esparto** is available from [PyPI][PyPI] and [Conda][Conda]: 68 | 69 | ```bash 70 | pip install esparto 71 | ``` 72 | 73 | ```bash 74 | conda install esparto -c conda-forge 75 | ``` 76 | 77 | ```bash 78 | poetry add esparto 79 | ``` 80 | 81 | Dependencies 82 | ------------ 83 | 84 | - [python](https://python.org/) >= 3.6 85 | - [jinja2](https://palletsprojects.com/p/jinja/) 86 | - [markdown](https://python-markdown.github.io/) 87 | - [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/) 88 | - [PyYAML](https://pyyaml.org/) 89 | 90 | #### Optional 91 | 92 | - [weasyprint](https://weasyprint.org/) *(for PDF output)* 93 | 94 | License 95 | ------- 96 | 97 | [MIT](https://opensource.org/licenses/MIT) 98 | 99 | Documentation 100 | ------------- 101 | 102 | User guides, documentation, and examples are available on the [project home page][ProjectHome]. 103 | 104 | Contributions, Issues, and Requests 105 | ----------------------------------- 106 | 107 | Feedback and contributions are welcome - please raise an issue or pull request 108 | on [GitHub][GitHub]. 109 | 110 | Examples 111 | -------- 112 | 113 | Iris Report - [Webpage](https://domvwt.github.io/esparto/examples/iris-report.html) | 114 | [PDF](https://domvwt.github.io/esparto/examples/iris-report.pdf) | [Notebook](https://github.com/domvwt/esparto/blob/main/docs/examples/iris-report.ipynb) 115 | 116 |
117 | 118 |

119 | example page 120 |

121 | 122 | 123 | [ProjectHome]: https://domvwt.github.io/esparto/ 124 | [PyPI]: https://pypi.org/project/esparto/ 125 | [Conda]: https://anaconda.org/conda-forge/esparto 126 | [Bootstrap]: https://getbootstrap.com/ 127 | [Jinja]: https://jinja.palletsprojects.com/ 128 | [CSS]: https://developer.mozilla.org/en-US/docs/Web/CSS 129 | [Markdown]: https://www.markdownguide.org/ 130 | [Pandas]: https://pandas.pydata.org/ 131 | [Matplotlib]: https://matplotlib.org/ 132 | [Bokeh]: https://bokeh.org/ 133 | [Plotly]: https://plotly.com/ 134 | [Jupyter]: https://jupyter.org/ 135 | [GitHub]: https://github.com/domvwt/esparto 136 | [Issues]: https://github.com/domvwt/esparto/issues 137 | -------------------------------------------------------------------------------- /docs/04-about/release-notes.md: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | 4.3.1 (2023-05-29) 5 | ------------------ 6 | 7 | - Fixes 8 | - Fix potential bug in `esparto.js` 9 | 10 | 4.3.0 (2023-05-29) 11 | ------------------ 12 | 13 | - Compatibility 14 | - Python 3.11 support added 15 | 16 | 4.2.0 (2022-11-10) 17 | ------------------ 18 | 19 | - Fixes 20 | - Fix bug preventing some Matplotlib figures from being converted to SVG and HTML [(#127)](https://github.com/domvwt/esparto/issues/127) 21 | 22 | 4.1.0 (2022-06-29) 23 | ------------------ 24 | 25 | - Page Style 26 | - Replace footer with icon buttons 27 | - Esparto home 28 | - Share document (for compatible systems) 29 | - Print page 30 | - Add CSS style for icon buttons 31 | - Prefer fully specified fonts to system fonts 32 | - Remove underline from URLs 33 | 34 | 4.0.0 (2022-04-19) 35 | ------------------ 36 | 37 | - New Features 38 | - Command line tools for printing default Jinja and CSS files 39 | - Fixes 40 | - Config YAML now read with safe loader 41 | - Deprecations 42 | - Removed CDN theme links 43 | - Dependencies 44 | - Image content now supported without PIL 45 | 46 | 3.0.2 (2022-02-28) 47 | ------------------ 48 | 49 | - Fixes 50 | - Set minimum page height to fill device screen 51 | - Read page additional output options when publishing HTML and PDF 52 | 53 | 3.0.1 (2022-02-24) 54 | ------------------ 55 | 56 | - Fixes 57 | - Remove call to `fig.tight_layout()` when converting Matplotlib figure to SVG for PDF 58 | 59 | 3.0.0 (2022-02-23) 60 | ------------------ 61 | 62 | - API 63 | - Page now accepts `max_width` and `output_options` arguments 64 | - Content classes no longer accept `width` or `height` arguments 65 | - New Features 66 | - Output rendering options can now be configured at page level 67 | - Additional markdown features recognised 68 | - Fixes 69 | - JavaScript is now placed at end of page so HTML is loaded first 70 | - Page Style 71 | - Removed unnecessary indentation 72 | - Tables now render in minimal, clean style 73 | - Improvements to sizing and centring of plots 74 | - Cleaned up unnecessary HTML and CSS 75 | - Majority of CSS attributes moved to `esparto.css` 76 | - Dependencies 77 | - Pillow is now optional 78 | - BeautifulSoup4 is now required 79 | - Upper version limits removed from all 80 | - Other 81 | - Type hints implemented with ~100% coverage 82 | 83 | 2.0.0 (2021-09-19) 84 | ------------------ 85 | 86 | - New Features 87 | - Links to Bootswatch CDN for page themes 88 | - Reorganise and add options to `esparto.options` 89 | - Table of Contents generator for Page element 90 | - Save and Load config options 91 | - Define Columns and Cards as dict of {"title": content} 92 | - Add or replace Content by positional index 93 | - New Layout Classes 94 | - CardSection: Section with Cards as the default Content container 95 | - CardRow: Row of Cards 96 | - CardRowEqual: Row of equal width cards 97 | 98 | 1.3.0 (2021-07-19) 99 | ------------------ 100 | 101 | - New Layout class 102 | - Card: Bordered container for grouping content 103 | - Updated Content class 104 | - FigureMpl: SVG rendered plots now flex up to 150% of original size 105 | - Other 106 | - Defined string and repr representations for current settings 107 | - Updated CSS so maintain distance from header if main title is not defined 108 | - Updated content adaptor to allow other Layout objects as valid children for Column 109 | 110 | 1.2.0 (2021-06-28) 111 | ------------------ 112 | 113 | - Implicitly read Markdown text files 114 | 115 | 1.1.0 (2021-06-18) 116 | ------------------ 117 | 118 | - New Layout classes 119 | - Spacer: make an empty column within a Row 120 | - PageBreak: enforce a page break in printed / PDF documents 121 | - New Content class 122 | - RawHTML: place raw HTML code in the page 123 | - Updated Content classes 124 | - DataFramePd: add new CSS style to minimise row height 125 | - FigureMpl: SVG rendered plots are now responsive and horizontally centred 126 | - New publishing features 127 | - CSS stylesheet path can be passed to options.css_styles 128 | - Jinja template path can be passed to options.jinja_template 129 | 130 | 1.0.1 (2021-06-01) 131 | ------------------ 132 | 133 | - Update dependencies 134 | - Fix SVG rendering in PDF 135 | - Update docs and examples 136 | 137 | 1.0.0 (2021-05-31) 138 | ------------------ 139 | 140 | - Improve API 141 | - Responsive SVG plots 142 | - Update Jinja template to remove branding 143 | - Refactor codebase 144 | 145 | 0.2.5 (2021-05-06) 146 | ------------------ 147 | 148 | - Fix linting errors 149 | - Add dataclasses dependency for Python < 3.7 150 | 151 | 0.2.4 (2021-05-04) 152 | ------------------ 153 | 154 | - Fix bug corrupting page titles 155 | - Lazy load the content dependency dict 156 | - Remove unused code 157 | 158 | 0.2.3 (2021-05-03) 159 | ------------------ 160 | 161 | - Make documents 'print friendly' 162 | - Output to PDF with weasyprint 163 | - Export matplotlib plots as SVG by default 164 | - Use `esparto.options` for configuring behaviour 165 | 166 | 0.2.2 (2021-04-24) 167 | ------------------ 168 | 169 | - Fix notebook display for Colab 170 | 171 | 0.2.1 (2021-04-24) 172 | ------------------ 173 | 174 | - Add Bootstrap dependencies for relevant content classes 175 | - Inherit FigureBokeh height from Bokeh object 176 | - Fix issues with in-notebook content rendering 177 | 178 | 0.2.0 (2021-04-23) 179 | ------------------ 180 | 181 | - Add support for Bokeh and Plotly 182 | 183 | 0.1.2 (2021-04-09) 184 | ------------------ 185 | 186 | - Relax dependency on Pillow to allow versions >=7.0.0 and <9.0.0 187 | 188 | 0.1.1 (2021-04-08) 189 | ------------------ 190 | 191 | - Update package metadata for pypi 192 | 193 | 0.1.0 (2021-04-07) 194 | ------------------ 195 | 196 | - First public release 197 | 198 |
199 | -------------------------------------------------------------------------------- /tests/example_pages.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from pathlib import Path 3 | 4 | import bokeh.plotting as bkp # type: ignore 5 | import matplotlib.pyplot as plt # type: ignore 6 | import numpy as np # type: ignore 7 | import pandas as pd # type: ignore 8 | import plotly.express as px # type: ignore 9 | 10 | import esparto as es 11 | 12 | lorem = ( 13 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore " 14 | "magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " 15 | "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " 16 | "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." 17 | ) 18 | 19 | image_path = Path("docs/examples/my-image.png") 20 | image = es.Image(str(image_path), caption="Photo by Benjamin Voros on Unsplash") 21 | 22 | df = pd.DataFrame() 23 | df["A"] = np.random.normal(10, 4, 1000) 24 | df["B"] = np.random.normal(-10, 6, 1000) 25 | df["C"] = np.random.normal(0, 3, 1000) 26 | df["D"] = np.random.normal(-5, 8, 1000) 27 | df = df.round(3) 28 | 29 | 30 | def example_01(pdf: bool = False): 31 | page = es.Page(title="Columns Page", navbrand="esparto", table_of_contents=2) 32 | 33 | page["Section One"]["Row One"]["Column One"] = lorem 34 | page["Section Two"] = es.Section() 35 | page["Section Three"] = es.CardSection() 36 | page["Section Four"] = es.Section() 37 | page["Section Five"] = es.CardSection() 38 | 39 | for i in range(1, 5): 40 | page["Section Two"][f"Row {i}"] = [(image, lorem) for _ in range(1, i + 1)] 41 | page["Section Three"][f"Row {i}"] += [ 42 | lorem if j % 2 == 0 else {"Card Title": image} for j in range(1, i + 1) 43 | ] 44 | page["Section Four"][f"Row {i}"] += [ 45 | {"Column Title": lorem} for _ in range(1, i + 1) 46 | ] 47 | page["Section Five"][f"Row {i}"] += [ 48 | {"Card Title": lorem} for _ in range(1, i + 1) 49 | ] 50 | 51 | page.save_html("page01.html") 52 | 53 | if pdf: 54 | page.save_pdf("page01.pdf") 55 | 56 | 57 | def example_02(pdf: bool = False): 58 | page = es.Page(title="Matplotlib Page") 59 | 60 | _ = df.plot.hist(alpha=0.4, bins=30) 61 | fig = plt.gcf() 62 | fig.tight_layout() 63 | 64 | _ = df.plot.hist(alpha=0.4, bins=30) 65 | fig2 = plt.gcf() 66 | fig2.tight_layout() 67 | fig2.set_size_inches(4, 3) 68 | 69 | page[0][0] = df[:10], fig 70 | page[0][1] = ({"fig": fig}, {"table": df[:10]}) 71 | page[0][2] = es.CardRowEqual(children=[{"fig": fig}, {"table": df[:10]}][::-1]) 72 | page[0][3] = {"text": lorem}, {"table": df[:10]} 73 | page[0][4] = fig 74 | page[0][5] = fig2 75 | 76 | page.save_html("page02-mpl.html") 77 | 78 | if pdf: 79 | page.save_pdf("page02-mpl.pdf") 80 | 81 | 82 | def example_03(pdf: bool = False): 83 | page = es.Page(title="Markdown Page", max_width=600) 84 | page += "tests/resources/markdown.md" 85 | page.save_html("page03-markdown.html") 86 | 87 | if pdf: 88 | page.save_pdf("page03-markdown.pdf") 89 | 90 | 91 | def example_04(pdf: bool = False): 92 | page = es.Page(title="Plotly Page") 93 | 94 | fig = px.scatter(data_frame=df, x="A", y="B") 95 | fig2 = px.scatter(data_frame=df, x="A", y="B", width=400, height=300) 96 | 97 | page[0][0] = df[:10], fig 98 | page[0][1] = ({"fig": fig}, {"table": df[:10]}) 99 | page[0][2] = es.CardRowEqual(children=[{"fig": fig}, {"table": df[:10]}][::-1]) 100 | page[0][3] = {"text": lorem}, {"table": df[:10]} 101 | page[0][4] = ({"fig": fig}, {"fig": fig}) 102 | page[0][5] = fig 103 | page[0][6] = fig2 104 | 105 | page.save_html("page04-plotly.html") 106 | 107 | if pdf: 108 | page.save_pdf("page04-plotly.pdf") 109 | 110 | 111 | def example_05(pdf: bool = False): 112 | page = es.Page( 113 | title="Slim Page", 114 | navbrand="esparto", 115 | table_of_contents=2, 116 | body_styles={"max-width": "700px"}, 117 | ) 118 | 119 | page["Section One"]["Row One"]["Column One"] = lorem 120 | page["Section Two"] = es.Section() 121 | page["Section Three"] = es.CardSection() 122 | page["Section Four"] = es.Section() 123 | page["Section Five"] = es.CardSection() 124 | 125 | for i in range(1, 5): 126 | page["Section Two"][f"Row {i}"] = [(image, lorem) for _ in range(1, i + 1)] 127 | page["Section Three"][f"Row {i}"] += [ 128 | lorem if j % 2 == 0 else {"Card Title": image} for j in range(1, i + 1) 129 | ] 130 | page["Section Four"][f"Row {i}"] += [ 131 | {"Column Title": lorem} for _ in range(1, i + 1) 132 | ] 133 | page["Section Five"][f"Row {i}"] += [ 134 | {"Card Title": lorem} for _ in range(1, i + 1) 135 | ] 136 | 137 | page.save_html("page05.html") 138 | 139 | if pdf: 140 | page.save_pdf("page05.pdf") 141 | 142 | 143 | def example_06(pdf: bool = False): 144 | page = es.Page(title="Bokeh Page") 145 | 146 | fig = bkp.figure(title="One") 147 | fig.circle("A", "B", source=df) # type: ignore 148 | 149 | page[0][0] = df[:10], fig 150 | page[0][1] = ({"fig": fig}, {"table": df[:10]}) 151 | page[0][2] = es.CardRowEqual(children=[{"fig": fig}, {"table": df[:10]}][::-1]) 152 | page[0][3] = {"text": lorem}, {"table": df[:10]} 153 | page[0][4] = ({"fig": fig}, {"fig": fig}) 154 | 155 | fig2 = bkp.figure(title="Two", width=400, height=300) 156 | fig2.circle("A", "B", source=df) # type: ignore 157 | page[0][5] = fig2 158 | 159 | page.save_html("page06-bokeh.html") 160 | 161 | 162 | def main(): 163 | parser = argparse.ArgumentParser() 164 | parser.add_argument("--pdf", action="store_true") 165 | 166 | args = parser.parse_args() 167 | 168 | pdf = args.pdf 169 | 170 | print("Producing example output...", flush=True) 171 | 172 | if pdf: 173 | print("Producing PDFs...") 174 | else: 175 | print("Skipping PDFs (pass '--pdf' to include)...") 176 | 177 | example_01(pdf) 178 | example_02(pdf) 179 | example_03(pdf) 180 | example_04(pdf) 181 | example_05(pdf) 182 | example_06(pdf) 183 | 184 | print("Done.") 185 | 186 | 187 | if __name__ == "__main__": 188 | main() 189 | -------------------------------------------------------------------------------- /tests/publish/test_publish.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Optional 3 | 4 | import pytest 5 | 6 | import esparto as es 7 | import esparto.publish.output as pu 8 | from esparto import _OptionalDependencies 9 | from tests.conftest import content_list, layout_list 10 | 11 | 12 | def html_is_valid( 13 | html: Optional[str], fragment: bool = False, plotly_chars: bool = False 14 | ): 15 | from html5lib import HTMLParser # type: ignore 16 | 17 | htmlparser = HTMLParser(strict=True) 18 | if plotly_chars: # Plotly.js includes chars htmlparser considers invalid codepoints 19 | for char in ("\x01", "\x1a", "\x1b", "\x89"): 20 | html = html.replace(char, "") 21 | try: 22 | if fragment: 23 | htmlparser.parseFragment(html) 24 | else: 25 | htmlparser.parse(html) 26 | success = True 27 | except Exception as e: 28 | print(e) 29 | success = False 30 | return success 31 | 32 | 33 | @pytest.mark.parametrize("content", (*content_list, *layout_list)) 34 | def test_content_html_valid(content): 35 | html = content.to_html() 36 | assert html_is_valid(html, fragment=True) 37 | 38 | 39 | def test_rendered_html_valid_cdn(page_layout: es.Page, tmp_path): 40 | path = str(tmp_path / "my_page.html") 41 | html = pu.publish_html(page_layout, path, return_html=True, dependency_source="cdn") 42 | assert html_is_valid(html) 43 | 44 | 45 | def test_rendered_html_valid_inline(page_layout: es.Page, tmp_path): 46 | path = str(tmp_path / "my_page.html") 47 | html = pu.publish_html( 48 | page_layout, path, return_html=True, dependency_source="inline" 49 | ) 50 | assert html_is_valid(html) 51 | 52 | 53 | def test_saved_html_valid_cdn(page_layout: es.Page, tmp_path): 54 | path: Path = tmp_path / "my_page.html" 55 | page_layout.save_html(str(path), dependency_source="cdn") 56 | html = path.read_text() 57 | assert html_is_valid(html) 58 | 59 | 60 | def test_saved_html_valid_inline(page_layout: es.Page, tmp_path): 61 | path: Path = tmp_path / "my_page.html" 62 | page_layout.save_html(str(path), dependency_source="inline") 63 | html = path.read_text() 64 | assert html_is_valid(html) 65 | 66 | 67 | def test_saved_html_valid_options_cdn(page_layout: es.Page, tmp_path, monkeypatch): 68 | monkeypatch.setattr(es.options, "dependency_source", "cdn") 69 | path: Path = tmp_path / "my_page.html" 70 | page_layout.save_html(str(path)) 71 | html = path.read_text() 72 | assert html_is_valid(html) 73 | 74 | 75 | def test_saved_html_valid_options_inline(page_layout: es.Page, tmp_path, monkeypatch): 76 | monkeypatch.setattr(es.options, "dependency_source", "inline") 77 | path: Path = tmp_path / "my_page.html" 78 | page_layout.save_html(str(path)) 79 | html = path.read_text() 80 | assert html_is_valid(html, plotly_chars=True) 81 | 82 | 83 | def test_saved_html_valid_bad_source(page_layout: es.Page, tmp_path): 84 | path: Path = tmp_path / "my_page.html" 85 | with pytest.raises(ValueError): 86 | page_layout.save_html(str(path), dependency_source="flapjack") 87 | 88 | 89 | def test_rendered_html_valid_toc(page_layout: es.Page, tmp_path): 90 | path = str(tmp_path / "my_page.html") 91 | page_layout.table_of_contents = True 92 | html = pu.publish_html(page_layout, path, return_html=True, dependency_source="cdn") 93 | assert html_is_valid(html) 94 | 95 | 96 | def test_saved_html_valid_toc(page_layout: es.Page, tmp_path): 97 | path: Path = tmp_path / "my_page.html" 98 | page_layout.table_of_contents = True 99 | page_layout.save_html(str(path), dependency_source="cdn") 100 | html = path.read_text() 101 | assert html_is_valid(html) 102 | 103 | 104 | def test_relocate_scripts(): 105 | html = "".join( 106 | """ 107 | 108 | 109 | 110 | 111 |
here is some content
112 |
113 | 114 |
115 |
here is some more content
116 | 117 | """.split() 118 | ) 119 | 120 | expected = "".join( 121 | """ 122 | 123 | 124 | 125 | 126 |
here is some content
127 |
128 |
129 |
here is some more content
130 | 131 | 132 | """.split() 133 | ) 134 | output = pu.relocate_scripts(html) 135 | assert output == expected 136 | 137 | 138 | if _OptionalDependencies().all_extras(): 139 | from tests.conftest import content_pdf 140 | 141 | def test_notebook_html_valid_cdn(page_layout, monkeypatch): 142 | monkeypatch.setattr(es.options.matplotlib, "notebook_format", "png") 143 | html = pu.nb_display(page_layout, return_html=True, dependency_source="cdn") 144 | assert html_is_valid(html) 145 | 146 | def test_notebook_html_valid_inline(page_layout, monkeypatch): 147 | monkeypatch.setattr(es.options.matplotlib, "notebook_format", "png") 148 | html = pu.nb_display(page_layout, return_html=True, dependency_source="inline") 149 | assert html_is_valid(html) 150 | 151 | def test_notebook_html_valid_options_cdn(page_layout, monkeypatch): 152 | monkeypatch.setattr(es.options.matplotlib, "notebook_format", "png") 153 | monkeypatch.setattr(es.options, "dependency_source", "cdn") 154 | html = pu.nb_display(page_layout, return_html=True) 155 | assert html_is_valid(html) 156 | 157 | def test_notebook_html_valid_online(page_layout, monkeypatch): 158 | monkeypatch.setattr(es.options.matplotlib, "notebook_format", "png") 159 | monkeypatch.setattr(es.options, "dependency_source", "inline") 160 | html = pu.nb_display(page_layout, return_html=True) 161 | assert html_is_valid(html, plotly_chars=True) 162 | 163 | @pytest.mark.parametrize("content", content_pdf) 164 | def test_pdf_output(content, tmp_path): 165 | if "bokeh" not in content._dependencies: 166 | page = es.Page(children=[content]) 167 | path: Path = tmp_path / "my_page.pdf" 168 | page.save_pdf(str(path)) 169 | size = path.stat().st_size 170 | assert size > 1000 171 | -------------------------------------------------------------------------------- /esparto/publish/output.py: -------------------------------------------------------------------------------- 1 | """Functions that render and save documents.""" 2 | 3 | import time 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING, Optional, Union 6 | 7 | from bs4 import BeautifulSoup, Tag # type: ignore 8 | from jinja2 import Template 9 | 10 | from esparto import _OptionalDependencies 11 | from esparto._options import options, resolve_config_option 12 | from esparto.design.base import AbstractContent, AbstractLayout 13 | from esparto.publish.contentdeps import resolve_deps 14 | 15 | if TYPE_CHECKING: 16 | from esparto.design.layout import Page 17 | 18 | 19 | def publish_html( 20 | page: "Page", 21 | filepath: Optional[str] = "./esparto-doc.html", 22 | return_html: bool = False, 23 | dependency_source: Optional[str] = None, 24 | esparto_css: Optional[str] = None, 25 | esparto_js: Optional[str] = None, 26 | jinja_template: Optional[str] = None, 27 | **kwargs: bool, 28 | ) -> Optional[str]: 29 | """Save page to HTML. 30 | 31 | Args: 32 | page (Page): A Page object. 33 | filepath (str): Filepath to write to. 34 | return_html (bool): Returns HTML string if True. 35 | dependency_source (str): One of 'cdn' or 'inline' (default = None). 36 | esparto_css (str): Path to CSS stylesheet. (default = None). 37 | esparto_js (str): Path to JavaScript code. (default = None). 38 | jinja_template (str): Path to Jinja template. (default = None). 39 | **kwargs (Dict[str, Any]): Arguments passed to `page.to_html()`. 40 | 41 | Returns: 42 | str: HTML string if return_html is True. 43 | 44 | """ 45 | 46 | required_deps = page._required_dependencies() 47 | dependency_source = dependency_source or options.dependency_source 48 | resolved_deps = resolve_deps(required_deps, source=dependency_source) 49 | 50 | esparto_css = Path(resolve_config_option("esparto_css", esparto_css)).read_text() 51 | esparto_js = Path(resolve_config_option("esparto_js", esparto_js)).read_text() 52 | 53 | page_html = page.to_html(**kwargs) 54 | jinja_template_object = Template( 55 | Path(resolve_config_option("jinja_template", jinja_template)).read_text() 56 | ) 57 | html_rendered: str = jinja_template_object.render( 58 | navbrand=page.navbrand, 59 | doc_title=page.title, 60 | esparto_css=esparto_css, 61 | esparto_js=esparto_js, 62 | content=page_html, 63 | head_deps=resolved_deps.head, 64 | tail_deps=resolved_deps.tail, 65 | ) 66 | html_rendered = prettify_html(html_rendered) 67 | html_rendered = relocate_scripts(html_rendered) 68 | 69 | if filepath: 70 | Path(filepath).write_text(html_rendered, encoding="utf-8") 71 | 72 | if return_html: 73 | return html_rendered 74 | return None 75 | 76 | 77 | def publish_pdf( 78 | page: "Page", filepath: str = "./esparto-doc.pdf", return_html: bool = False 79 | ) -> Optional[str]: 80 | """Save page to PDF. 81 | 82 | Args: 83 | page (Layout): A Page object. 84 | filepath (str): Filepath to write to. 85 | return_html (bool): Returns HTML string if True. 86 | 87 | Returns: 88 | str: HTML string if return_html is True. 89 | 90 | """ 91 | if not _OptionalDependencies.weasyprint: 92 | raise ModuleNotFoundError("Install weasyprint for PDF support") 93 | import weasyprint as wp # type: ignore 94 | 95 | temp_dir = Path(options._pdf_temp_dir) 96 | temp_dir.mkdir(parents=True, exist_ok=True) 97 | 98 | html_rendered = publish_html( 99 | page=page, 100 | filepath=None, 101 | return_html=True, 102 | dependency_source="inline", 103 | pdf_mode=True, 104 | ) 105 | pdf_doc = wp.HTML(string=html_rendered, base_url=options._pdf_temp_dir).render() 106 | pdf_doc.metadata.title = page.title 107 | pdf_doc.write_pdf(filepath) 108 | 109 | for f in temp_dir.iterdir(): 110 | f.unlink() 111 | temp_dir.rmdir() 112 | 113 | html_prettified = prettify_html(html_rendered) 114 | 115 | if return_html: 116 | return html_prettified 117 | return None 118 | 119 | 120 | def nb_display( 121 | item: Union["AbstractLayout", "AbstractContent"], 122 | return_html: bool = False, 123 | dependency_source: Optional[str] = None, 124 | ) -> Optional[str]: 125 | """Display Layout or Content to Jupyter Notebook cell. 126 | 127 | Args: 128 | item (Layout, Content): A Layout or Content item. 129 | return_html (bool): Returns HTML string if True. 130 | dependency_source (str): One of 'cdn', 'inline', or 'esparto.options'. 131 | 132 | Returns: 133 | str: HTML string if return_html is True. 134 | 135 | """ 136 | from IPython.display import HTML, display # type: ignore 137 | 138 | from esparto.design.layout import Layout 139 | 140 | if isinstance(item, Layout): 141 | required_deps = item._required_dependencies() 142 | else: 143 | required_deps = getattr(item, "_dependencies", set()) 144 | 145 | dependency_source = dependency_source or options.dependency_source 146 | resolved_deps = resolve_deps(required_deps, source=dependency_source) 147 | esparto_css = Path(options.esparto_css).read_text() 148 | head_deps = "\n".join(resolved_deps.head) 149 | tail_deps = "\n".join(resolved_deps.tail) 150 | html = item.to_html(notebook_mode=True) 151 | html_rendered = ( 152 | f"
\n{html}\n
\n" 153 | ) 154 | html_rendered += f"\n" 155 | 156 | html_rendered = ( 157 | f"\n\n{head_deps}\n" 158 | f"\n{html_rendered}\n{tail_deps}\n\n\n" 159 | ) 160 | html_rendered = relocate_scripts(html_rendered) 161 | print() 162 | 163 | # This allows time to download plotly.js from the CDN - otherwise cell can render empty 164 | if "plotly" in required_deps and dependency_source == "cdn": 165 | display(HTML(f"\n{head_deps}\n\n"), metadata=dict(isolated=True)) 166 | time.sleep(2) 167 | 168 | # Temporary solution to prevent Jupyter Notebook cell fully collapsing before content renders 169 | if "bokeh" in required_deps: 170 | extra_css = "" 171 | else: 172 | extra_css = "" 173 | 174 | display(HTML(extra_css + html_rendered), metadata=dict(isolated=True)) 175 | print() 176 | 177 | if return_html: 178 | return html_rendered 179 | return None 180 | 181 | 182 | def prettify_html(html: Optional[str]) -> str: 183 | """Prettify HTML.""" 184 | html = html or "" 185 | html = str(BeautifulSoup(html, features="html.parser").prettify()) 186 | 187 | return html 188 | 189 | 190 | def relocate_scripts(html: str) -> str: 191 | """Move all JavaScript in page body to end of section.""" 192 | soup = BeautifulSoup(html, "html.parser") 193 | body = soup.find("body") 194 | 195 | if isinstance(body, Tag): 196 | script_list = body.find_all("script") 197 | for script in script_list: 198 | body.insert(len(body), script) 199 | 200 | html = str(soup) 201 | 202 | return html 203 | -------------------------------------------------------------------------------- /esparto/_options.py: -------------------------------------------------------------------------------- 1 | """Esparto configuration options.""" 2 | 3 | import collections.abc 4 | import copy 5 | import pprint 6 | import traceback 7 | from contextlib import ContextDecorator 8 | from dataclasses import dataclass, field 9 | from pathlib import Path 10 | from tempfile import TemporaryDirectory 11 | from types import TracebackType 12 | from typing import Any, Dict, Mapping, Optional, Tuple, Type, Union 13 | 14 | import yaml 15 | 16 | from esparto import _MODULE_PATH 17 | 18 | 19 | class ConfigMixin(object): 20 | _options_source: str 21 | 22 | def _to_dict(self) -> Dict[str, Any]: 23 | return public_dict(self.__dict__) 24 | 25 | def __repr__(self) -> str: 26 | return str(self) 27 | 28 | def __str__(self) -> str: 29 | string = f"{pprint.pformat(self._to_dict(), sort_dicts=False)}" 30 | if hasattr(self, "_options_source"): 31 | string += ( 32 | f"\nSource: {self._options_source}" if self._options_source else "" 33 | ) 34 | return string 35 | 36 | 37 | @dataclass(repr=False) 38 | class MatplotlibOptions(yaml.YAMLObject, ConfigMixin): 39 | """Options for Matplotlib output. 40 | 41 | Attributes: 42 | html_output_format (str): 43 | How plots are rendered in HTML: 'png' or 'svg'. 44 | notebook_format (str): 45 | How plots are rendered in Jupyter Notebooks: 'png' or 'svg'. 46 | pdf_figsize (tuple or int): 47 | Specify size of Matplotlib figures in PDF output. 48 | An integer tuple can be passed as: (height, width). 49 | A float can be passed as a scaling factor. 50 | 51 | """ 52 | 53 | yaml_loader = yaml.SafeLoader 54 | yaml_tag = "!MatplotlibOptions" 55 | 56 | html_output_format: str = "svg" 57 | notebook_format: str = "svg" 58 | pdf_figsize: Optional[Union[Tuple[int, int], float]] = 1.0 59 | 60 | 61 | @dataclass(repr=False) 62 | class PlotlyOptions(yaml.YAMLObject, ConfigMixin): 63 | """Options for Plotly output. 64 | 65 | Attributes: 66 | layout_args (dict): 67 | Arguments passed to `figure.update_layout()` at rendering time. 68 | 69 | """ 70 | 71 | yaml_loader = yaml.SafeLoader 72 | yaml_tag = "!PlotlyOptions" 73 | 74 | layout_args: Dict[str, Any] = field(default_factory=lambda: {}) 75 | 76 | 77 | @dataclass(repr=False) 78 | class BokehOptions(yaml.YAMLObject, ConfigMixin): 79 | """Options for Bokeh output. 80 | 81 | Attributes: 82 | layout_attributes (dict): 83 | Bokeh layout object attributes to set at rendering time. 84 | 85 | """ 86 | 87 | yaml_loader = yaml.SafeLoader 88 | yaml_tag = "!BokehOptions" 89 | 90 | layout_attributes: Dict[str, Any] = field( 91 | default_factory=lambda: {"sizing_mode": "scale_width"} 92 | ) 93 | 94 | 95 | @dataclass(repr=False) 96 | class OutputOptions(yaml.YAMLObject, ConfigMixin): 97 | """Options for configuring page rendering and output. 98 | 99 | Config options will automatically be loaded if a yaml file is found at 100 | either `./esparto-config.yaml` or `~/esparto-data/esparto-config.yaml`. 101 | 102 | Attributes: 103 | dependency_source (str): 104 | How dependencies should be provisioned: 'cdn' or 'inline'. 105 | bootstrap_cdn (str): 106 | Link to Bootstrap CDN. Used if dependency source is 'cdn'. 107 | bootstrap_css (str): 108 | Path to Bootstrap CSS file. Used if dependency source is 'inline'. 109 | esparto_css (str): 110 | Path to additional CSS file with esparto specific styles. 111 | esparto_js (str): 112 | Path to JavaScript file for interactive page elements. 113 | jinja_template (str): 114 | Path to Jinja HTML page template. 115 | 116 | matplotlib: Additional config options for Matplotlib. 117 | plotly: Additional config options for Plotly. 118 | bokeh: Additional config options for Bokeh. 119 | 120 | """ 121 | 122 | yaml_loader = yaml.SafeLoader 123 | yaml_tag = "!OutputOptions" 124 | 125 | dependency_source: str = "cdn" 126 | bootstrap_cdn: str = ( 127 | "" 131 | ) 132 | bootstrap_css: str = str(_MODULE_PATH / "resources/css/bootstrap.min.css") 133 | esparto_css: str = str(_MODULE_PATH / "resources/css/esparto.css") 134 | esparto_js: str = str(_MODULE_PATH / "resources/js/esparto.js") 135 | jinja_template: str = str(_MODULE_PATH / "resources/jinja/base.html.jinja") 136 | 137 | matplotlib: MatplotlibOptions = field(default_factory=MatplotlibOptions) 138 | bokeh: BokehOptions = field(default_factory=BokehOptions) 139 | plotly: PlotlyOptions = field(default_factory=PlotlyOptions) 140 | 141 | _pdf_temp_dir: str = TemporaryDirectory().name 142 | 143 | _options_source: str = "" 144 | 145 | def save(self, path: Union[str, Path] = "./esparto-config.yaml") -> None: 146 | """Save config to yaml file at `path`.""" 147 | Path(path).write_text(self._to_yaml_str()) 148 | 149 | @classmethod 150 | def load(cls, path: Union[str, Path]) -> "OutputOptions": 151 | """Load config from yaml file at `path`.""" 152 | yaml_str = Path(path).read_text() 153 | opts: OutputOptions = yaml.safe_load(yaml_str) 154 | opts._options_source = str(path) 155 | return opts 156 | 157 | def _to_yaml_str(self) -> str: 158 | self_copy = copy.copy(self) 159 | del self_copy._options_source 160 | return str(yaml.dump(self_copy, default_flow_style=False, sort_keys=False)) 161 | 162 | @classmethod 163 | def _autoload(cls) -> "OutputOptions": 164 | config_paths = [ 165 | Path("./esparto-config.yaml"), 166 | Path.home() / "esparto-data/esparto-config.yaml", 167 | ] 168 | 169 | for p in config_paths: 170 | if p.is_file(): 171 | opts = cls.load(p) 172 | opts._options_source = str(p) 173 | print("esparto config loaded from:", p) 174 | return opts 175 | return cls() 176 | 177 | 178 | options = OutputOptions._autoload() 179 | 180 | 181 | def update_recursive( 182 | source_dict: Dict[Any, Any], update_map: Mapping[Any, Any] 183 | ) -> Dict[Any, Any]: 184 | """Recursively update nested dictionaries. 185 | https://stackoverflow.com/a/3233356/8065696 186 | """ 187 | for k, v in update_map.items(): 188 | if isinstance(v, collections.abc.Mapping): 189 | source_dict[k] = update_recursive(source_dict.get(k, {}), v) 190 | else: 191 | source_dict[k] = v 192 | return source_dict 193 | 194 | 195 | class options_context(ContextDecorator): 196 | def __init__(self, page_options: OutputOptions): 197 | self.page_options = page_options 198 | self.default_options = copy.copy(options) 199 | 200 | def __enter__(self) -> None: 201 | update_recursive(options.__dict__, self.page_options.__dict__) 202 | 203 | def __exit__( 204 | self, exc_type: Type[BaseException], exc_value: BaseException, tb: TracebackType 205 | ) -> None: 206 | if exc_type is not None: # pragma: no cover 207 | traceback.print_exception(exc_type, exc_value, tb) 208 | update_recursive(options.__dict__, self.default_options.__dict__) 209 | 210 | 211 | def resolve_config_option(config_option: str, value: Optional[str]) -> Any: 212 | if value is None: 213 | return getattr(options, config_option) 214 | else: 215 | return value 216 | 217 | 218 | def public_dict(d: Dict[str, Any]) -> Dict[str, Any]: 219 | """Remove keys starting with '_' from dict.""" 220 | return { 221 | k: v for k, v in d.items() if not (isinstance(k, str) and k.startswith("_")) 222 | } 223 | -------------------------------------------------------------------------------- /tests/design/test_layout.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from itertools import chain 3 | 4 | import pytest 5 | 6 | import esparto.design.content as co 7 | import esparto.design.layout as la 8 | 9 | 10 | def test_all_layout_classes_covered(layout_list_fn): 11 | test_classes = [type(c) for c in layout_list_fn] 12 | module_classes = [c for c in la.Layout.__subclasses__()] 13 | module_subclasses = [d.__subclasses__() for d in module_classes] 14 | module_all = set(list(chain.from_iterable(module_subclasses)) + module_classes) 15 | missing = module_all.difference(test_classes) 16 | assert not missing, missing 17 | 18 | 19 | def test_layout_smart_wrapping(page_layout): 20 | strings = ["first", "second", "third"] 21 | output = page_layout + la.Section( 22 | children=[ 23 | la.Row(children=[*strings, "fourth"]), 24 | "another bit of content", 25 | la.Row(children=[la.Column(children=["fifth"]), "sixth"]), 26 | ] 27 | ) 28 | expected = page_layout 29 | expected += la.Section( 30 | children=[ 31 | la.Row(children=[*[co.Markdown(x) for x in strings], "fourth"]), 32 | co.Markdown("another bit of content"), 33 | la.Row( 34 | children=[ 35 | la.Column(children=[co.Markdown("fifth")]), 36 | la.Column(children=[co.Markdown("sixth")]), 37 | ] 38 | ), 39 | ] 40 | ) 41 | print(output) 42 | print() 43 | print(expected) 44 | assert output == expected 45 | 46 | 47 | def test_layout_call_many(page_layout, content_list_fn): 48 | a = la.Page(title="jazz", children=content_list_fn) 49 | assert a == page_layout 50 | 51 | 52 | def test_layout_call_list(page_layout, content_list_fn): 53 | a = la.Page(title="jazz", children=content_list_fn) 54 | assert a == page_layout 55 | 56 | 57 | def test_layout_equality(layout_list_fn): 58 | for i, a in enumerate(layout_list_fn): 59 | for j, b in enumerate(layout_list_fn): 60 | if i == j: 61 | assert a == b 62 | else: 63 | assert a != b 64 | 65 | 66 | layout_add_list = [ 67 | ( 68 | la.Column(), 69 | "miles davis", 70 | la.Column(children=co.Markdown("miles davis")), 71 | ), 72 | ( 73 | la.Row(), 74 | co.Markdown("ornette coleman"), 75 | la.Row(children=la.Column(children=co.Markdown("ornette coleman"))), 76 | ), 77 | ( 78 | la.Page(children=["charles mingus"]), 79 | la.Section(children=["thelonious monk"]), 80 | la.Page( 81 | children=[ 82 | la.Section(children=["charles mingus"]), 83 | la.Section(children=["thelonious monk"]), 84 | ] 85 | ), 86 | ), 87 | ( 88 | la.Section(title="jazz"), 89 | la.Row( 90 | children=[ 91 | la.Column(children=["john coltrane"]), 92 | la.Column(children=["wayne shorter"]), 93 | ] 94 | ), 95 | la.Section( 96 | title="jazz", 97 | children=[ 98 | la.Row( 99 | children=[ 100 | la.Column(children=["john coltrane"]), 101 | la.Column(children=["wayne shorter"]), 102 | ] 103 | ) 104 | ], 105 | ), 106 | ), 107 | ( 108 | la.Page(title="piano"), 109 | "bill evans", 110 | la.Page(title="piano", children=[co.Markdown("bill evans")]), 111 | ), 112 | ( 113 | la.Page(), 114 | "chet baker", 115 | la.Page( 116 | children=la.Section( 117 | children=la.Row(children=la.Column(children=co.Markdown("chet baker"))) 118 | ) 119 | ), 120 | ), 121 | ] 122 | 123 | 124 | @pytest.mark.parametrize("a,b,expected", layout_add_list) 125 | def test_layout_add(a, b, expected): 126 | output = a + b 127 | assert output == expected 128 | 129 | 130 | def test_get_item(page_basic_layout): 131 | page = la.Page(title="Test Page") 132 | page["Section One"]["Row One"] = "markdown content" 133 | assert page.section_one.row_one[0] == page_basic_layout[0][0][0] 134 | 135 | 136 | def test_get_item_bad_key(page_basic_layout): 137 | bad_key = 1.5 138 | with pytest.raises(KeyError): 139 | page_basic_layout[bad_key] 140 | 141 | 142 | def test_get_item_no_key(page_basic_layout): 143 | result = page_basic_layout[""] 144 | expected = page_basic_layout.children[-1] 145 | assert result is expected 146 | 147 | 148 | def test_set_item_new_str(page_basic_layout): 149 | page = la.Page(title="Test Page") 150 | page["Section One"]["Row One"] = "markdown content" 151 | assert page == page_basic_layout 152 | 153 | 154 | def test_set_column_extra_children(): 155 | expected = la.Page( 156 | children=la.Section( 157 | children=la.Row( 158 | children=la.Column(children=["markdown", "content", "tuple"]), 159 | ), 160 | ), 161 | ) 162 | output_page = la.Page() 163 | output_page[0][0][0] = ("markdown", "content", "tuple") 164 | print(output_page) 165 | assert output_page == expected 166 | 167 | 168 | def test_set_item_existing_str(page_basic_layout): 169 | page = la.Page(title="Test Page") 170 | page["Section One"]["Row One"] = "different content" 171 | page["Section One"]["Row One"] = "markdown content" 172 | assert page == page_basic_layout 173 | 174 | 175 | def test_set_item_existing_int(page_basic_layout): 176 | page = la.Page(title="Test Page") 177 | page["Section One"]["Row One"] = "different content" 178 | page[0][0] = "markdown content" 179 | assert page == page_basic_layout 180 | 181 | 182 | def test_set_item_existing_attr(page_basic_layout): 183 | page = la.Page(title="Test Page") 184 | page["Section One"]["Row One"] = "different content" 185 | page.section_one.row_one = "markdown content" 186 | assert page == page_basic_layout 187 | 188 | 189 | def test_set_column_as_dict(page_basic_layout): 190 | page = la.Page(title="Test Page") 191 | 192 | expected = page_basic_layout 193 | expected[0][0][0] = la.Column(title="Column One", children=["markdown content"]) 194 | 195 | page["Section One"]["Row One"]["Wrong Title"] = {"Column One": "markdown content"} 196 | 197 | assert page == expected 198 | 199 | 200 | def test_set_column_as_dict_tuple(): 201 | page = la.Page(title="Test Page") 202 | with pytest.raises(TypeError): 203 | page["Section One"]["Row One"]["Column One"] = { 204 | "Column One": "markdown content" 205 | }, {"Column Two": "markdown content"} 206 | 207 | 208 | def test_set_row_as_dict_tuple(): 209 | page = la.Page(title="Test Page") 210 | expected = copy(page) 211 | expected["Section One"]["Row One"]["Column One"] = "markdown content" 212 | expected["Section One"]["Row One"]["Column Two"] = "markdown content" 213 | page["Section One"]["Row One"] = {"Column One": "markdown content"}, { 214 | "Column Two": "markdown content" 215 | } 216 | assert page == expected 217 | 218 | 219 | def test_delitem_str(page_basic_layout): 220 | page = la.Page(title="Test Page") 221 | page["Section One"]["Row One"] = "markdown content" 222 | page["Section One"]["Row Two"] = "different content" 223 | del page["Section One"]["Row Two"] 224 | assert page == page_basic_layout 225 | 226 | 227 | def test_delitem_int(page_basic_layout): 228 | page = la.Page(title="Test Page") 229 | page["Section One"]["Row One"] = "markdown content" 230 | page["Section One"]["Row Two"] = "different content" 231 | del page["Section One"][1] 232 | assert page == page_basic_layout 233 | 234 | 235 | def test_delattr(page_basic_layout): 236 | page = la.Page(title="Test Page") 237 | page["Section One"]["Row One"] = "markdown content" 238 | page["Section One"]["Row Two"]["Markdown"] = "different content" 239 | del page.section_one.row_two 240 | assert page == page_basic_layout 241 | 242 | 243 | def test_delitem_key_int_error(): 244 | page = la.Page(title="Test Page") 245 | page["Section One"]["Row One"] = "markdown content" 246 | page["Section One"]["Row Two"] = "different content" 247 | with pytest.raises(KeyError): 248 | del page["Section One"][3] 249 | 250 | 251 | def test_delitem_key_str_error(): 252 | page = la.Page(title="Test Page") 253 | page["Section One"]["Row One"] = "markdown content" 254 | page["Section One"]["Row Two"] = "different content" 255 | with pytest.raises(KeyError): 256 | del page["Section One"]["Row Three"] 257 | 258 | 259 | def test_child_id_maps_to_child(): 260 | page = la.Page() 261 | page["Section One"]["Row One"] = "markdown content" 262 | assert page.section_one is page.children[0] 263 | 264 | 265 | def test_lshift(page_basic_layout): 266 | page = la.Page(title="Test Page") 267 | content = "markdown content" 268 | passthrough = page["Section One"]["Row One"] << content 269 | assert passthrough == content 270 | assert page.children == page_basic_layout.children 271 | assert page == page_basic_layout 272 | 273 | 274 | def test_lshift_tuple(): 275 | col = la.Column(title="title") 276 | content = ("a", "b") 277 | passthrough = col << ("a", "b") 278 | assert passthrough == content 279 | assert col == la.Column(title="title", children=["a", "b"]) 280 | 281 | 282 | def test_rshift(page_basic_layout): 283 | page = la.Page(title="Test Page") 284 | content = "markdown content" 285 | passthrough = page["Section One"]["Row One"] >> content 286 | assert passthrough == page_basic_layout["Section One"]["Row One"] 287 | assert page == page_basic_layout 288 | 289 | 290 | def test_rshift_tuple(): 291 | col = la.Column(title="title") 292 | passthrough = col >> ("a", "b") 293 | assert passthrough == la.Column(title="title", children=["a", "b"]) 294 | 295 | 296 | def test_render_html(): 297 | tag = "div" 298 | classes = ["row", "row-es"] 299 | styles = {"color": "red", "border": "blue"} 300 | children = "some text" 301 | identifier = "row-one" 302 | expected = "
\n some text\n
" 303 | output = la.render_html(tag, classes, styles, children, identifier) 304 | print(output) 305 | assert output == expected 306 | -------------------------------------------------------------------------------- /esparto/design/content.py: -------------------------------------------------------------------------------- 1 | """Content classes for rendering objects and markdown to HTML.""" 2 | 3 | import base64 4 | import re 5 | from abc import ABC, abstractmethod 6 | from collections import namedtuple 7 | from io import BytesIO, StringIO 8 | from pathlib import Path 9 | from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, TypeVar, Union 10 | from uuid import uuid4 11 | 12 | import markdown as md 13 | 14 | from esparto import _OptionalDependencies 15 | from esparto._options import options 16 | from esparto.design.base import AbstractContent, AbstractLayout, Child 17 | from esparto.design.layout import Row 18 | from esparto.publish.output import nb_display 19 | 20 | if _OptionalDependencies.PIL: 21 | from PIL.Image import Image as PILImage # type: ignore 22 | 23 | if _OptionalDependencies.pandas: 24 | from pandas import DataFrame # type: ignore 25 | 26 | if _OptionalDependencies.matplotlib: 27 | from matplotlib.figure import Figure as MplFigure # type: ignore 28 | 29 | if _OptionalDependencies.bokeh: 30 | from bokeh.embed import components # type: ignore 31 | from bokeh.models.layouts import LayoutDOM as BokehObject # type: ignore 32 | 33 | if _OptionalDependencies.plotly: 34 | from plotly.graph_objs._figure import Figure as PlotlyFigure # type: ignore 35 | from plotly.io import to_html as plotly_to_html # type: ignore 36 | 37 | 38 | T = TypeVar("T", bound="Content") 39 | 40 | 41 | class Content(AbstractContent, ABC): 42 | """Template for Content elements. 43 | 44 | Attributes: 45 | content (Any): Item to be included in the page - should match the encompassing Content class. 46 | 47 | """ 48 | 49 | content: Any 50 | _dependencies: Set[str] 51 | 52 | @abstractmethod 53 | def to_html(self, **kwargs: bool) -> str: 54 | """Convert content to HTML string. 55 | 56 | Returns: 57 | str: HTML string. 58 | 59 | """ 60 | raise NotImplementedError 61 | 62 | def display(self) -> None: 63 | """Display rendered content in a Jupyter Notebook cell.""" 64 | nb_display(self) 65 | 66 | def __add__(self, other: Child) -> "Row": 67 | from esparto.design.layout import Row 68 | 69 | return Row(children=[self, other]) 70 | 71 | def __iter__(self) -> Iterator["Content"]: 72 | return iter([self]) 73 | 74 | def __len__(self) -> int: 75 | return len(list(self.content)) 76 | 77 | def _repr_html_(self) -> None: 78 | nb_display(self) 79 | 80 | def __str__(self) -> str: 81 | return getattr(self, "title", None) or self.__class__.__name__ 82 | 83 | def __eq__(self, other: Any) -> bool: 84 | if isinstance(other, self.__class__): 85 | if hasattr(self.content, "__iter__") and hasattr(other.content, "__iter__"): 86 | return all(x == y for x, y in zip(self.content, other.content)) 87 | return bool(self.content == other.content) 88 | return False 89 | 90 | def __ne__(self, other: Any) -> bool: 91 | return not self.__eq__(other) 92 | 93 | 94 | class RawHTML(Content): 95 | """Raw HTML content. 96 | 97 | Args: 98 | html (str): HTML string. 99 | 100 | """ 101 | 102 | _dependencies: Set[Any] = set("") 103 | content: str 104 | 105 | def __init__(self, html: str) -> None: 106 | if not isinstance(html, str): 107 | raise TypeError(r"HTML must be str") 108 | 109 | self.content = html 110 | 111 | def to_html(self, **kwargs: bool) -> str: 112 | return self.content 113 | 114 | 115 | class Markdown(Content): 116 | """Markdown text content. 117 | 118 | Args: 119 | text (str): Markdown text to be added to document. 120 | 121 | """ 122 | 123 | _dependencies = {"bootstrap"} 124 | 125 | def __init__(self, text: str) -> None: 126 | if not isinstance(text, str): 127 | raise TypeError(r"text must be str") 128 | 129 | self.content: str = text 130 | 131 | def to_html(self, **kwargs: bool) -> str: 132 | html = md.markdown(self.content, extensions=["extra", "smarty"]) 133 | html = f"{html}\n" 134 | html = f"
\n{html}\n
" 135 | return html 136 | 137 | 138 | class Image(Content): 139 | """Image content. 140 | 141 | Can be read from a filepath, PIL.Image object, or from bytes.. 142 | 143 | Args: 144 | image (str, Path, PIL.Image, BytesIO): Image data. 145 | caption (str): Image caption (default = None) 146 | alt_text (str): Alternative text. (default = None) 147 | scale (float): Scale image by proportion. (default = None) 148 | set_width (int): Set width in pixels. (default = None) 149 | set_height (int): Set height in pixels. (default = None) 150 | 151 | """ 152 | 153 | _dependencies = {"bootstrap"} 154 | 155 | def __init__( 156 | self, 157 | image: Union[str, Path, "PILImage", BytesIO], 158 | caption: Optional[str] = "", 159 | alt_text: Optional[str] = "Image", 160 | scale: Optional[float] = None, 161 | set_width: Optional[int] = None, 162 | set_height: Optional[int] = None, 163 | ): 164 | valid_types: Tuple[Any, ...] 165 | 166 | if _OptionalDependencies.PIL: 167 | valid_types = (str, Path, PILImage, BytesIO) 168 | else: 169 | valid_types = (str, Path, BytesIO) 170 | 171 | if not isinstance(image, (valid_types)): 172 | raise TypeError(r"`image` must be one of {}".format(valid_types)) 173 | 174 | self.content = image 175 | self.alt_text = alt_text 176 | self.caption = caption 177 | self._scale = scale 178 | self._width = set_width 179 | self._height = set_height 180 | 181 | def set_width(self, width: int) -> None: 182 | """Set width of image. 183 | 184 | Args: 185 | width (int): New width in pixels. 186 | 187 | """ 188 | self._width = width 189 | 190 | def set_height(self, height: int) -> None: 191 | """Set height of image. 192 | 193 | Args: 194 | height (int): New height in pixels. 195 | 196 | """ 197 | self._height = height 198 | 199 | def rescale(self, scale: float) -> None: 200 | """Resize the image by a scaling factor. 201 | 202 | Args: 203 | scale (float): Scaling ratio. 204 | 205 | """ 206 | self._scale = scale 207 | 208 | def to_html(self, **kwargs: bool) -> str: 209 | image_bytes = image_to_bytes(self.content) 210 | image_encoded = bytes_to_base64(image_bytes) 211 | 212 | width = f"min({self._width}, 100%)" if self._width else "auto" 213 | height = f"min({self._height}, 100%)" if self._height else "auto" 214 | scale = f"transform: scale({self._scale});" if self._scale else "" 215 | 216 | html = ( 217 | "
" 218 | "" 222 | ) 223 | 224 | if self.caption: 225 | html += f"
{self.caption}
" 226 | 227 | html += "
" 228 | 229 | return html 230 | 231 | 232 | class DataFramePd(Content): 233 | """Pandas DataFrame to be converted to table. 234 | 235 | Args: 236 | df (pd.DataFrame): A Pandas DataFrame 237 | index (bool): If True, render the DataFrame index. (default = True) 238 | col_space (str, int): Minimum column width in CSS units. Use int for pixels. (default = 0) 239 | 240 | Attributes: 241 | css_classes (List[str]): CSS classes applied to the HTML output. 242 | 243 | """ 244 | 245 | _dependencies = {"bootstrap"} 246 | 247 | def __init__( 248 | self, df: "DataFrame", index: bool = True, col_space: Union[int, str] = 0 249 | ): 250 | if not isinstance(df, DataFrame): 251 | raise TypeError(r"df must be Pandas DataFrame") 252 | 253 | self.content: "DataFrame" = df 254 | self.index = index 255 | self.col_space = col_space 256 | self.css_classes = [ 257 | "table", 258 | "table-hover", 259 | ] 260 | 261 | def to_html(self, **kwargs: bool) -> str: 262 | html: str = self.content.to_html( 263 | index=self.index, 264 | border=0, 265 | col_space=self.col_space, 266 | classes=self.css_classes, 267 | ) 268 | html = f"
{html}
" 269 | return html 270 | 271 | 272 | class FigureMpl(Content): 273 | """Matplotlib figure. 274 | 275 | Args: 276 | figure (plt.Figure): A Matplotlib figure. 277 | output_format (str): 'svg' or 'png'. (default = None) 278 | pdf_figsize (tuple, float): Set figure size for PDF output. (default = None) 279 | Accepts a tuple of (height, width) or a float to use as scale factor. 280 | 281 | """ 282 | 283 | _dependencies = {"bootstrap"} 284 | 285 | def __init__( 286 | self, 287 | figure: "MplFigure", 288 | output_format: Optional[str] = None, 289 | pdf_figsize: Optional[Union[Tuple[int, int], float]] = None, 290 | ) -> None: 291 | if not isinstance(figure, MplFigure): 292 | raise TypeError(r"figure must be a Matplotlib Figure") 293 | 294 | self.content: MplFigure = figure 295 | self.output_format = output_format or options.matplotlib.html_output_format 296 | self.pdf_figsize = pdf_figsize or options.matplotlib.pdf_figsize 297 | 298 | self._original_figsize = figure.get_size_inches() 299 | 300 | def to_html(self, **kwargs: bool) -> str: 301 | if kwargs.get("notebook_mode"): 302 | output_format = options.matplotlib.notebook_format 303 | else: 304 | output_format = self.output_format 305 | 306 | if kwargs.get("pdf_mode") and self.pdf_figsize: 307 | if isinstance(self.pdf_figsize, float): 308 | figsize = self.pdf_figsize * self._original_figsize 309 | else: 310 | figsize = self.pdf_figsize 311 | self.content.set_size_inches(*figsize) 312 | 313 | if output_format == "svg": 314 | string_buffer = StringIO() 315 | self.content.savefig(string_buffer, format="svg") 316 | string_buffer.seek(0) 317 | xml = string_buffer.read() 318 | 319 | dpi = 96 320 | fig_width, fig_height = ( 321 | int(val * dpi) for val in self.content.get_size_inches() 322 | ) 323 | 324 | if kwargs.get("pdf_mode"): 325 | xml = responsive_svg_mpl( 326 | xml, width=int(fig_width), height=int(fig_height) 327 | ) 328 | temp_file = Path(options._pdf_temp_dir) / f"{uuid4()}.svg" 329 | temp_file.write_text(xml) 330 | inner = ( 331 | "\n" 333 | ) 334 | else: 335 | xml = responsive_svg_mpl(xml) 336 | inner = xml 337 | 338 | html = ( 339 | f"
" 340 | f"{inner}\n
\n" 341 | ) 342 | 343 | # Reset figsize in case it was changed 344 | self.content.set_size_inches(*self._original_figsize) 345 | 346 | return html 347 | 348 | # If not svg: 349 | bytes_buffer = BytesIO() 350 | self.content.savefig(bytes_buffer, format="png") 351 | bytes_buffer.seek(0) 352 | return Image(bytes_buffer).to_html() 353 | 354 | 355 | class FigureBokeh(Content): 356 | """Bokeh object to be rendered as an interactive plot. 357 | 358 | Args: 359 | figure (bokeh.layouts.LayoutDOM): A Bokeh object. 360 | layout_attributes (dict): Attributes set on `figure`. (default = None) 361 | 362 | """ 363 | 364 | _dependencies = {"bokeh"} 365 | 366 | def __init__( 367 | self, 368 | figure: "BokehObject", 369 | layout_attributes: Optional[Dict[Any, Any]] = None, 370 | ): 371 | if not issubclass(type(figure), BokehObject): 372 | raise TypeError(r"figure must be a Bokeh object") 373 | 374 | self.content: BokehObject = figure 375 | self.layout_attributes = layout_attributes or options.bokeh.layout_attributes 376 | 377 | def to_html(self, **kwargs: bool) -> str: 378 | if self.layout_attributes: 379 | for key, value in self.layout_attributes.items(): 380 | setattr(self.content, key, value) 381 | 382 | # Bokeh to PDF is experimental and untested 383 | if kwargs.get("pdf_mode"): # pragma: no cover 384 | from bokeh.io import export_svg # type: ignore 385 | 386 | temp_file = Path(options._pdf_temp_dir) / f"{uuid4()}.svg" 387 | export_svg(self.content, filename=str(temp_file)) 388 | html = f"\n" 389 | return html 390 | 391 | html, js = components(self.content) 392 | 393 | # Remove outer
tag so we can give our own attributes 394 | html = remove_outer_div(html) 395 | 396 | fig_width = self.content.properties_with_values().get("width", 1000) 397 | 398 | return ( 399 | "
" 401 | f"\n{html}\n{js}\n
" 402 | ) 403 | 404 | 405 | class FigurePlotly(Content): 406 | """Plotly figure to be rendered as an interactive plot. 407 | 408 | Args: 409 | figure (plotly.graph_objs._figure.Figure): A Plotly figure. 410 | layout_args (dict): Args passed to `figure.update_layout()`. (default = None) 411 | 412 | """ 413 | 414 | _dependencies = {"plotly"} 415 | 416 | def __init__( 417 | self, 418 | figure: "PlotlyFigure", 419 | layout_args: Optional[Dict[Any, Any]] = None, 420 | ): 421 | if not isinstance(figure, PlotlyFigure): 422 | raise TypeError(r"figure must be a Plotly Figure") 423 | 424 | self.layout_args = layout_args or options.plotly.layout_args 425 | 426 | self.content: PlotlyFigure = figure 427 | self._original_layout = figure.layout 428 | 429 | def to_html(self, **kwargs: bool) -> str: 430 | if self.layout_args: 431 | self.content.update_layout(**self.layout_args) 432 | 433 | # Default width is 700, default height is 450 434 | fig_width: int = getattr(self.content, "width", 700) 435 | 436 | if kwargs.get("pdf_mode"): 437 | temp_file = Path(options._pdf_temp_dir) / f"{uuid4()}.svg" 438 | self.content.write_image(str(temp_file)) 439 | inner = f"" 440 | 441 | else: 442 | inner = plotly_to_html( 443 | self.content, include_plotlyjs=False, full_html=False 444 | ) 445 | # Remove outer
tag so we can give our own attributes. 446 | inner = remove_outer_div(inner) 447 | 448 | html = f"
{inner}\n
" 449 | 450 | # Reset layout in case it was changed 451 | self.content.update_layout(self._original_layout) 452 | 453 | return html 454 | 455 | 456 | def remove_outer_div(html: str) -> str: 457 | """Remove outer
tags.""" 458 | html = html.replace("
", "", 1) 459 | html = "".join(html.rsplit("
", 1)) 460 | return html 461 | 462 | 463 | def image_to_bytes(image: Union[str, Path, BytesIO, "PILImage"]) -> BytesIO: 464 | """Convert `image` to bytes. 465 | 466 | Args: 467 | image (Union[str, Path, BytesIO, PIL.Image]): image object. 468 | 469 | Raises: 470 | TypeError: image type not recognised. 471 | 472 | Returns: 473 | BytesIO: image as bytes object. 474 | 475 | """ 476 | if isinstance(image, BytesIO): 477 | return image 478 | elif _OptionalDependencies.PIL and isinstance(image, PILImage): 479 | return BytesIO(image.tobytes()) 480 | elif isinstance(image, (str, Path)): 481 | return BytesIO(Path(image).read_bytes()) 482 | else: 483 | raise TypeError(type(image)) 484 | 485 | 486 | def bytes_to_base64(bytes: BytesIO) -> str: 487 | """ 488 | Convert an image from bytes to base64 representation. 489 | 490 | Args: 491 | image (BytesIO): image bytes object. 492 | 493 | Returns: 494 | str: image encoded as a base64 utf-8 string. 495 | 496 | """ 497 | return base64.b64encode(bytes.getvalue()).decode("utf-8") 498 | 499 | 500 | def table_of_contents( 501 | object: AbstractLayout, max_depth: Optional[int] = None, numbered: bool = True 502 | ) -> "Markdown": 503 | """Produce table of contents for a Layout object. 504 | 505 | Args: 506 | object (Layout): Target object for TOC. 507 | max_depth (int): Maximum depth of returned TOC. 508 | numbered (bool): If True TOC items are numbered. 509 | If False, bulletpoints are used. 510 | 511 | """ 512 | from esparto.design.content import Markdown 513 | 514 | max_depth = max_depth or 99 515 | 516 | TOCItem = namedtuple("TOCItem", "title, level, id") 517 | 518 | def get_toc_items(parent: AbstractLayout) -> List[TOCItem]: 519 | def find_ids(parent: Any, level: int, acc: List[TOCItem]) -> List[TOCItem]: 520 | if hasattr(parent, "get_title_identifier") and parent.title: 521 | acc.append(TOCItem(parent.title, level, parent.get_title_identifier())) 522 | level += 1 523 | if hasattr(parent, "children"): 524 | for child in parent.children: 525 | find_ids(child, level, acc) 526 | else: 527 | return acc 528 | return acc 529 | 530 | acc_new = find_ids(parent, 0, []) 531 | return acc_new 532 | 533 | toc_items = get_toc_items(object) 534 | 535 | tab = "\t" 536 | marker = "1." if numbered else "*" 537 | markdown_list = [ 538 | f"{(item.level - 1) * tab} {marker} [{item.title}](#{item.id})" 539 | for item in toc_items 540 | if item.level > 0 and item.level <= max_depth 541 | ] 542 | markdown_str = "\n".join(markdown_list) 543 | 544 | return Markdown(markdown_str) 545 | 546 | 547 | def responsive_svg_mpl( 548 | source: str, width: Optional[int] = None, height: Optional[int] = None 549 | ) -> str: 550 | """Make SVG element responsive.""" 551 | 552 | regex_w = r"width=\S*" 553 | regex_h = r"height=\S*" 554 | 555 | width_ = f"width='{width}px'" if width else "" 556 | height_ = f"height='{height}px'" if height else "" 557 | 558 | source = re.sub(regex_w, width_, source, count=1) 559 | source = re.sub(regex_h, height_, source, count=1) 560 | 561 | # Preserve aspect ratio of SVG 562 | old_str = r" 2 | -------------------------------------------------------------------------------- /esparto/design/layout.py: -------------------------------------------------------------------------------- 1 | """Layout classes for defining page appearance and structure.""" 2 | 3 | import copy 4 | import re 5 | from abc import ABC 6 | from pprint import pformat 7 | from typing import ( 8 | Any, 9 | Callable, 10 | Dict, 11 | Iterable, 12 | Iterator, 13 | List, 14 | Optional, 15 | Set, 16 | Type, 17 | TypeVar, 18 | Union, 19 | ) 20 | 21 | import bs4 # type: ignore 22 | 23 | from esparto._options import OutputOptions, options, options_context 24 | from esparto.design.base import AbstractLayout, Child 25 | from esparto.publish.output import nb_display, publish_html, publish_pdf 26 | 27 | T = TypeVar("T", bound="Layout") 28 | 29 | 30 | class Layout(AbstractLayout, ABC): 31 | """Class Template for Layout elements. 32 | 33 | Layout class hierarchy: 34 | `Page -> Section -> Row -> Column -> Content` 35 | 36 | Attributes: 37 | title (str): Object title. Used as a title within the page and as a key value. 38 | children (list): Child items defining the page layout and content. 39 | title_classes (list): Additional CSS classes to apply to title. 40 | title_styles (dict): Additional CSS styles to apply to title. 41 | body_classes (list): Additional CSS classes to apply to body. 42 | body_styles (dict): Additional CSS styles to apply to body. 43 | 44 | """ 45 | 46 | # ------------------------------------------------------------------------+ 47 | # Magic Methods | 48 | # ------------------------------------------------------------------------+ 49 | 50 | title: Optional[str] 51 | children: List[Child] = [] 52 | 53 | title_html_tag: str 54 | title_classes: List[str] 55 | title_styles: Dict[str, Any] 56 | 57 | body_html_tag: str 58 | body_classes: List[str] 59 | body_styles: Dict[str, Any] 60 | 61 | @property 62 | def _default_id(self) -> str: 63 | return f"es-{type(self).__name__}".lower() 64 | 65 | @property 66 | def _parent_class(self) -> Type["Layout"]: 67 | raise NotImplementedError 68 | 69 | @property 70 | def _child_class(self) -> Type["Layout"]: 71 | raise NotImplementedError 72 | 73 | _dependencies = {"bootstrap"} 74 | 75 | @property 76 | def _child_ids(self) -> Dict[str, str]: 77 | """Return existing child IDs or a new dict.""" 78 | try: 79 | super().__getattribute__("__child_ids") 80 | except AttributeError: 81 | super().__setattr__("__child_ids", {}) 82 | child_ids: Dict[str, str] = super().__getattribute__("__child_ids") 83 | return child_ids 84 | 85 | def __init__( 86 | self, 87 | title: Optional[str] = None, 88 | children: Union[List[Child], Child] = None, 89 | title_classes: Optional[List[str]] = None, 90 | title_styles: Optional[Dict[str, Any]] = None, 91 | body_classes: Optional[List[str]] = None, 92 | body_styles: Optional[Dict[str, Any]] = None, 93 | ): 94 | self.title = title 95 | children = children or [] 96 | self.set_children(children) 97 | 98 | self.__post_init__() 99 | 100 | title_classes = title_classes or [] 101 | title_styles = title_styles or {} 102 | body_classes = body_classes or [] 103 | body_styles = body_styles or {} 104 | 105 | self.title_classes += title_classes 106 | self.title_styles.update(title_styles) 107 | 108 | self.body_classes += body_classes 109 | self.body_styles.update(body_styles) 110 | 111 | def __post_init__(self) -> None: 112 | raise NotImplementedError 113 | 114 | def __iter__(self) -> Iterator["Layout"]: 115 | return iter([self]) 116 | 117 | def __repr__(self) -> str: 118 | return self._tree() 119 | 120 | def _repr_html_(self) -> None: 121 | self.display() 122 | 123 | def __str__(self) -> str: 124 | return self._tree() 125 | 126 | def __add__(self: T, other: Child) -> T: 127 | new = copy.copy(self) 128 | new.children = self.children + [*self._smart_wrap(other)] 129 | return new 130 | 131 | def __eq__(self, other: Any) -> bool: 132 | if isinstance(other, self.__class__): 133 | return ( 134 | self.title == other.title 135 | and len(self.children) == len(other.children) 136 | and all((x == y for x, y in zip(self.children, other.children))) 137 | ) 138 | return False 139 | 140 | def __ne__(self, other: Any) -> bool: 141 | return not self.__eq__(other) 142 | 143 | def __getattribute__(self, key: str) -> Any: 144 | child_id = super().__getattribute__("_child_ids").get(key) 145 | if child_id: 146 | return self.__getitem__(child_id) 147 | return super().__getattribute__(key) 148 | 149 | def __setattr__(self, key: str, value: Any) -> None: 150 | child_id = super().__getattribute__("_child_ids").get(key) 151 | if child_id: 152 | self.__setitem__(child_id, value) 153 | else: 154 | super().__setattr__(key, value) 155 | 156 | def __delattr__(self, key: str) -> None: 157 | child_id = super().__getattribute__("_child_ids").get(key) 158 | if child_id: 159 | self.__delitem__(child_id) 160 | else: 161 | super().__delattr__(key) 162 | 163 | def __getitem__(self, key: Union[str, int]) -> Any: 164 | if isinstance(key, str): 165 | indexes = get_matching_titles(key, self.children) 166 | if len(indexes) and key: 167 | return self.children[indexes[0]] 168 | value = self._child_class(title=key) 169 | self.children.append(value) 170 | if key: 171 | self._add_child_id(key) 172 | return self.children[-1] 173 | 174 | elif isinstance(key, int): 175 | if key < len(self.children): 176 | return self.children[key] 177 | value = self._child_class() 178 | self.children.append(value) 179 | return self.children[-1] 180 | 181 | raise KeyError(key) 182 | 183 | def __setitem__(self, key: Union[str, int], value: Any) -> None: 184 | value = copy.copy(value) 185 | title = ( 186 | getattr(value, "title", None) if issubclass(type(value), Layout) else None 187 | ) 188 | if not isinstance(value, self._child_class): 189 | if issubclass(self._child_class, Column): 190 | value = self._child_class(title=title, children=[value]) 191 | else: 192 | value = self._smart_wrap(value) 193 | value = value[0] 194 | if isinstance(key, str): 195 | if key: 196 | value.title = title or key 197 | indexes = get_matching_titles(key, self.children) 198 | if indexes: 199 | self.children[indexes[0]] = value 200 | else: 201 | self.children.append(value) 202 | self._add_child_id(value.title) 203 | else: 204 | self.children.append(value) 205 | return 206 | elif isinstance(key, int): 207 | if key < len(self.children): 208 | value.title = title or getattr(self.children[key], "title", None) 209 | self.children[key] = value 210 | return 211 | self.children.append(value) 212 | return 213 | 214 | raise KeyError(key) 215 | 216 | def __delitem__(self, key: Union[int, str]) -> None: 217 | if isinstance(key, str): 218 | indexes = get_matching_titles(key, self.children) 219 | if len(indexes): 220 | self._remove_child_id(key) 221 | del self.children[indexes[0]] 222 | return None 223 | elif isinstance(key, int) and key < len(self.children): 224 | child_title = getattr(self.children[key], "title", None) 225 | if child_title: 226 | self._remove_child_id(child_title) 227 | del self.children[key] 228 | return None 229 | raise KeyError(key) 230 | 231 | def __lshift__(self, other: Child) -> Child: 232 | self.set_children(other) 233 | return other 234 | 235 | def __rshift__(self, other: Child) -> "Layout": 236 | self.set_children(other) 237 | return self 238 | 239 | def __copy__(self) -> "Layout": 240 | attributes = vars(self) 241 | new = self.__class__() 242 | new.__dict__.update(attributes) 243 | new.children = [*new.children] 244 | return new 245 | 246 | # ------------------------------------------------------------------------+ 247 | # Public Methods | 248 | # ------------------------------------------------------------------------+ 249 | 250 | def display(self) -> None: 251 | """Render content in a Notebook environment.""" 252 | nb_display(self) 253 | 254 | def get_identifier(self) -> str: 255 | """Get the HTML element ID for the current object.""" 256 | return clean_attr_name(str(self.title)) if self.title else self._default_id 257 | 258 | def get_title_identifier(self) -> str: 259 | """Get the HTML element ID for the current object title.""" 260 | return f"{self.get_identifier()}-title" 261 | 262 | def set_children(self, other: Union[List[Child], Child]) -> None: 263 | """Set children as `other`.""" 264 | other = copy.copy(other) 265 | self.children = [*self._smart_wrap(other)] 266 | for child in self.children: 267 | title = getattr(child, "title", None) 268 | if title: 269 | self._add_child_id(title) 270 | 271 | def to_html(self, **kwargs: bool) -> str: 272 | """Render object as HTML string. 273 | 274 | Returns: 275 | html (str): HTML string. 276 | 277 | """ 278 | children_rendered = " ".join([c.to_html(**kwargs) for c in self.children]) 279 | title_rendered = ( 280 | render_html( 281 | self.title_html_tag, 282 | self.title_classes, 283 | self.title_styles, 284 | self.title, 285 | self.get_title_identifier(), 286 | ) 287 | if self.title 288 | else "" 289 | ) 290 | html = render_html( 291 | self.body_html_tag, 292 | self.body_classes, 293 | self.body_styles, 294 | f"{title_rendered}\n{children_rendered}\n", 295 | self.get_identifier(), 296 | ) 297 | html = bs4.BeautifulSoup(html, "html.parser").prettify() 298 | return html 299 | 300 | def tree(self) -> None: 301 | """Display page tree.""" 302 | print(self._tree()) 303 | 304 | # ------------------------------------------------------------------------+ 305 | # Private Methods | 306 | # ------------------------------------------------------------------------+ 307 | 308 | def _add_child_id(self, key: str) -> None: 309 | attr_name = clean_attr_name(key) 310 | if attr_name: 311 | self._child_ids[attr_name] = key 312 | super().__setattr__(attr_name, self[key]) 313 | 314 | def _remove_child_id(self, key: str) -> None: 315 | attr_name = clean_attr_name(key) 316 | if attr_name in self._child_ids: 317 | del self._child_ids[attr_name] 318 | super().__delattr__(attr_name) 319 | 320 | def _smart_wrap(self, child_list: Union[List[Child], Child]) -> List[Child]: 321 | """Wrap children in a coherent class hierarchy. 322 | 323 | Args: 324 | children: Sequence of Content and / or Child items. 325 | 326 | Returns: 327 | List of Layout and Content items wrapped in a coherent class hierarchy. 328 | 329 | 330 | If the parent object is a Column and the item is a Content Class: 331 | - return child with no modification 332 | If the parent object is a Column and the item is not a Content Class: 333 | - cast the child to an appropriate Content Class if possible 334 | - return the child 335 | If the current item is wrapped and unwrapped items have been accumulated: 336 | - wrap the unwrapped children 337 | - append newly wrapped to output 338 | - append current child to output 339 | If the current child is wrapped and we have no accumulated unwrapped items: 340 | - append the wrapped child to output 341 | If the current child is a dict and the parent is a Row: 342 | - use the dictionary key as a title and value as content 343 | - wrap and append the current child to output 344 | If the current child is unwrapped and the parent is a Row: 345 | - wrap and append the current child to output 346 | If the current item is unwrapped and the parent is not a Row: 347 | - add the current child to unwrapped item accumulator 348 | Finally: 349 | - wrap any accumulated unwrapped items 350 | - append the final wrapped segment to output 351 | 352 | """ 353 | return smart_wrap(self, child_list) 354 | 355 | def _recurse_children(self, idx: int) -> Dict[str, Any]: 356 | key = self.title or f"{type(self).__name__} {idx}" 357 | tree = { 358 | f"{key}": [ 359 | child._recurse_children(idx) # type: ignore 360 | if hasattr(child, "_recurse_children") 361 | else str(child) 362 | for idx, child in enumerate(self.children) 363 | ] 364 | } 365 | return tree 366 | 367 | def _required_dependencies(self) -> Set[str]: 368 | deps: Set[str] = self._dependencies 369 | 370 | def dep_finder(parent: Any) -> None: 371 | nonlocal deps 372 | for child in parent.children: 373 | deps = deps | set(getattr(child, "_dependencies", {})) 374 | if hasattr(child, "children"): 375 | dep_finder(child) 376 | 377 | dep_finder(self) 378 | return deps 379 | 380 | def _tree(self) -> str: 381 | return pformat(self._recurse_children(idx=0)) 382 | 383 | def _ipython_key_completions_(self) -> List[str]: # pragma: no cover 384 | return [ 385 | getattr(child, "title") 386 | for child in self.children 387 | if hasattr(child, "title") 388 | ] 389 | 390 | 391 | class Page(Layout): 392 | """Layout class that defines a Page. 393 | 394 | Args: 395 | title (str): Used as a title within the page and as a key value. 396 | navbrand (str): Brand name. Displayed in the page navbar if provided. 397 | table_of_contents (bool, int): Add a Table of Contents to the top of page. 398 | Passing an `int` will define the maximum depth. 399 | max_width (int): Maximum page width expressed in pixels. 400 | output_options (es.OutputOptions): Page specific rendering and output options. 401 | children (list): Child items defining layout and content. 402 | title_classes (list): Additional CSS classes to apply to title. 403 | title_styles (dict): Additional CSS styles to apply to title. 404 | body_classes (list): Additional CSS classes to apply to body. 405 | body_styles (dict): Additional CSS styles to apply to body. 406 | 407 | """ 408 | 409 | output_options: OutputOptions = options 410 | 411 | def __init__( 412 | self, 413 | title: Optional[str] = None, 414 | navbrand: Optional[str] = "", 415 | table_of_contents: Union[bool, int] = False, 416 | max_width: int = 800, 417 | output_options: Optional[OutputOptions] = None, 418 | children: Union[List[Child], Child] = None, 419 | title_classes: Optional[List[str]] = None, 420 | title_styles: Optional[Dict[str, Any]] = None, 421 | body_classes: Optional[List[str]] = None, 422 | body_styles: Optional[Dict[str, Any]] = None, 423 | ): 424 | super().__init__( 425 | title, children, title_classes, title_styles, body_classes, body_styles 426 | ) 427 | self.navbrand = navbrand 428 | self.table_of_contents = table_of_contents 429 | self.max_width = max_width 430 | self.output_options = output_options or options 431 | 432 | def save( 433 | self, 434 | filepath: str = "./esparto-doc.html", 435 | return_html: bool = False, 436 | dependency_source: Optional[str] = None, 437 | ) -> Optional[str]: 438 | """ 439 | Save page to HTML file. 440 | 441 | Note: 442 | Alias for `self.save_html()`. 443 | 444 | Args: 445 | filepath (str): Destination filepath. 446 | return_html (bool): If True, return HTML as a string. 447 | dependency_source (str): 'cdn' or 'inline'. 448 | 449 | Returns: 450 | html (str): Document rendered as HTML. (If `return_html` is True) 451 | 452 | """ 453 | html = self.save_html( 454 | filepath=filepath, 455 | return_html=return_html, 456 | dependency_source=dependency_source, 457 | ) 458 | 459 | if return_html: 460 | return html 461 | return None 462 | 463 | @options_context(output_options) 464 | def save_html( 465 | self, 466 | filepath: str = "./esparto-doc.html", 467 | return_html: bool = False, 468 | dependency_source: Optional[str] = None, 469 | ) -> Optional[str]: 470 | """ 471 | Save page to HTML file. 472 | 473 | Args: 474 | filepath (str): Destination filepath. 475 | return_html (bool): If True, return HTML as a string. 476 | dependency_source (str): 'cdn' or 'inline'. 477 | 478 | Returns: 479 | html (str): Document rendered as HTML. (If `return_html` is True) 480 | 481 | """ 482 | html = publish_html( 483 | self, 484 | filepath=filepath, 485 | return_html=return_html, 486 | dependency_source=dependency_source, 487 | ) 488 | if return_html: 489 | return html 490 | return None 491 | 492 | @options_context(output_options) 493 | def save_pdf( 494 | self, filepath: str = "./esparto-doc.pdf", return_html: bool = False 495 | ) -> Optional[str]: 496 | """ 497 | Save page to PDF file. 498 | 499 | Note: 500 | Requires `weasyprint` library. 501 | 502 | Args: 503 | filepath (str): Destination filepath. 504 | return_html (bool): If True, return intermediate HTML representation as a string. 505 | 506 | Returns: 507 | html (str): Document rendered as HTML. (If `return_html` is True) 508 | 509 | """ 510 | html = publish_pdf(self, filepath, return_html=return_html) 511 | if return_html: 512 | return html 513 | return None 514 | 515 | @options_context(output_options) 516 | def to_html(self, **kwargs: bool) -> str: 517 | if self.table_of_contents: 518 | # Create a copy of the page and dynamically generate the TOC. 519 | # Copy is required so that TOC is not added multiple times and 520 | # always reflects the current content. 521 | from esparto.design.content import table_of_contents 522 | 523 | max_depth = ( 524 | None if self.table_of_contents is True else self.table_of_contents 525 | ) 526 | page_copy = copy.copy(self) 527 | toc = table_of_contents(page_copy, max_depth=max_depth) 528 | page_copy.children.insert( 529 | 0, 530 | page_copy._child_class( 531 | title="Contents", children=[toc], title_classes=["h4"] 532 | ), 533 | ) 534 | page_copy.table_of_contents = False 535 | return page_copy.to_html(**kwargs) 536 | 537 | self.body_styles.update({"max-width": f"{self.max_width}px"}) 538 | 539 | return super().to_html(**kwargs) 540 | 541 | def __post_init__(self) -> None: 542 | self.title_html_tag = "h1" 543 | self.title_classes = ["es-page-title"] 544 | self.title_styles = {} 545 | 546 | self.body_html_tag = "article" 547 | self.body_classes = ["es-page-body"] 548 | self.body_styles = {} 549 | 550 | @property 551 | def _parent_class(self) -> Type["Layout"]: 552 | return Page 553 | 554 | @property 555 | def _child_class(self) -> Type["Layout"]: 556 | return Section 557 | 558 | 559 | class Section(Layout): 560 | """Layout class that defines a Section. 561 | 562 | Args: 563 | title (str): Used as a title within the page and as a key value. 564 | children (list): Child items defining layout and content. 565 | title_classes (list): Additional CSS classes to apply to title. 566 | title_styles (dict): Additional CSS styles to apply to title. 567 | body_classes (list): Additional CSS classes to apply to body. 568 | body_styles (dict): Additional CSS styles to apply to body. 569 | 570 | """ 571 | 572 | def __post_init__(self) -> None: 573 | self.title_html_tag = "h3" 574 | self.title_classes = ["es-section-title"] 575 | self.title_styles = {} 576 | 577 | self.body_html_tag = "section" 578 | self.body_classes = ["es-section-body"] 579 | self.body_styles = {} 580 | 581 | @property 582 | def _parent_class(self) -> Type["Layout"]: 583 | return Page 584 | 585 | @property 586 | def _child_class(self) -> Type["Layout"]: 587 | return Row 588 | 589 | 590 | class CardSection(Section): 591 | """Layout class that defines a CardSection. CardSections wrap content in Cards by default. 592 | 593 | Args: 594 | title (str): Used as a title within the page and as a key value. 595 | children (list): Child items defining layout and content. 596 | cards_equal (bool): Cards in the same Row are stretched vertically if True. 597 | title_classes (list): Additional CSS classes to apply to title. 598 | title_styles (dict): Additional CSS styles to apply to title. 599 | body_classes (list): Additional CSS classes to apply to body. 600 | body_styles (dict): Additional CSS styles to apply to body. 601 | 602 | """ 603 | 604 | def __init__( 605 | self, 606 | title: Optional[str] = None, 607 | children: Union[List[Child], Child, None] = None, 608 | cards_equal: bool = False, 609 | title_classes: Optional[List[str]] = None, 610 | title_styles: Optional[Dict[str, Any]] = None, 611 | body_classes: Optional[List[str]] = None, 612 | body_styles: Optional[Dict[str, Any]] = None, 613 | ): 614 | super().__init__( 615 | title=title, 616 | children=children, 617 | title_classes=title_classes, 618 | title_styles=title_styles, 619 | body_classes=body_classes, 620 | body_styles=body_styles, 621 | ) 622 | 623 | self.cards_equal = cards_equal 624 | 625 | @property 626 | def _child_class(self) -> Type["Layout"]: 627 | # Attribute missing if class is not instantiated 628 | if hasattr(self, "cards_equal") and self.cards_equal: 629 | return CardRowEqual 630 | return CardRow 631 | 632 | 633 | class Row(Layout): 634 | """Layout class that defines a Row. 635 | 636 | Args: 637 | title (str): Used as a title within the page and as a key value. 638 | children (list): Child items defining layout and content. 639 | title_classes (list): Additional CSS classes to apply to title. 640 | title_styles (dict): Additional CSS styles to apply to title. 641 | body_classes (list): Additional CSS classes to apply to body. 642 | body_styles (dict): Additional CSS styles to apply to body. 643 | 644 | """ 645 | 646 | def __post_init__(self) -> None: 647 | self.title_html_tag = "h5" 648 | self.title_classes = ["col-12", "es-row-title"] 649 | self.title_styles = {} 650 | 651 | self.body_html_tag = "div" 652 | self.body_classes = ["row", "es-row-body"] 653 | self.body_styles = {} 654 | 655 | @property 656 | def _parent_class(self) -> Type["Layout"]: 657 | return Section 658 | 659 | @property 660 | def _child_class(self) -> Type["Layout"]: 661 | return Column 662 | 663 | def __setitem__(self, key: Union[str, int], value: Any) -> None: 664 | if isinstance(value, dict): 665 | title, content = list(value.items())[0] 666 | value = self._child_class(title=title, children=[content]) 667 | super().__setitem__(key, value) 668 | 669 | 670 | class Column(Layout): 671 | """Layout class that defines a Column. 672 | 673 | Args: 674 | title (str): Used as a title within the page and as a key value. 675 | children (list): Child items defining layout and content. 676 | col_width (int): Fix column width - must be between 1 and 12. 677 | title_classes (list): Additional CSS classes to apply to title. 678 | title_styles (dict): Additional CSS styles to apply to title. 679 | body_classes (list): Additional CSS classes to apply to body. 680 | body_styles (dict): Additional CSS styles to apply to body. 681 | 682 | """ 683 | 684 | def __init__( 685 | self, 686 | title: Optional[str] = None, 687 | children: Union[List[Child], Child] = None, 688 | col_width: Optional[int] = None, 689 | title_classes: Optional[List[str]] = None, 690 | title_styles: Optional[Dict[str, Any]] = None, 691 | body_classes: Optional[List[str]] = None, 692 | body_styles: Optional[Dict[str, Any]] = None, 693 | ): 694 | self.title = title 695 | children = children or [] 696 | self.set_children(children) 697 | self.col_width = col_width 698 | 699 | self.__post_init__() 700 | 701 | title_classes = title_classes or [] 702 | title_styles = title_styles or {} 703 | body_classes = body_classes or [] 704 | body_styles = body_styles or {} 705 | 706 | self.title_classes += title_classes 707 | self.title_styles.update(title_styles) 708 | 709 | self.body_classes += body_classes 710 | self.body_styles.update(body_styles) 711 | 712 | def __post_init__(self) -> None: 713 | self.title_html_tag = "h5" 714 | self.title_classes = ["es-column-title"] 715 | self.title_styles = {} 716 | 717 | col_class = f"col-lg-{self.col_width}" if self.col_width else "col-lg" 718 | self.body_html_tag = "div" 719 | self.body_classes = [col_class, "es-column-body"] 720 | self.body_styles = {} 721 | 722 | @property 723 | def _parent_class(self) -> Type["Layout"]: 724 | return Row 725 | 726 | @property 727 | def _child_class(self) -> Type["Layout"]: 728 | raise NotImplementedError 729 | 730 | 731 | class CardRow(Row): 732 | """Layout class that defines a CardRow. CardRows wrap content in Cards by default. 733 | 734 | Args: 735 | title (str): Used as a title within the page and as a key value. 736 | children (list): Child items defining layout and content. 737 | title_classes (list): Additional CSS classes to apply to title. 738 | title_styles (dict): Additional CSS styles to apply to title. 739 | body_classes (list): Additional CSS classes to apply to body. 740 | body_styles (dict): Additional CSS styles to apply to body. 741 | 742 | """ 743 | 744 | @property 745 | def _child_class(self) -> Type["Layout"]: 746 | return Card 747 | 748 | 749 | class CardRowEqual(CardRow): 750 | """Layout class that defines a CardRow with Cards of equal height. 751 | 752 | Args: 753 | title (str): Used as a title within the page and as a key value. 754 | children (list): Child items defining layout and content. 755 | title_classes (list): Additional CSS classes to apply to title. 756 | title_styles (dict): Additional CSS styles to apply to title. 757 | body_classes (list): Additional CSS classes to apply to body. 758 | body_styles (dict): Additional CSS styles to apply to body. 759 | 760 | """ 761 | 762 | def __post_init__(self) -> None: 763 | super().__post_init__() 764 | self.body_styles = {"align-items": "stretch"} 765 | 766 | 767 | class Card(Column): 768 | """Layout class that defines a Card. 769 | 770 | Child items will be vertically stacked by default. 771 | Horizontal arrangement is achieved by nesting content inside a Row. 772 | 773 | Args: 774 | title (str): Used as a title within the page and as a key value. 775 | children (list): Child items defining layout and content. 776 | col_width (int): Fix column width - must be between 1 and 12. 777 | title_classes (list): Additional CSS classes to apply to title. 778 | title_styles (dict): Additional CSS styles to apply to title. 779 | body_classes (list): Additional CSS classes to apply to body. 780 | body_styles (dict): Additional CSS styles to apply to body. 781 | 782 | """ 783 | 784 | def __init__( 785 | self, 786 | title: Optional[str] = None, 787 | children: Union[List[Child], Child] = None, 788 | col_width: int = 6, 789 | title_classes: Optional[List[str]] = None, 790 | title_styles: Optional[Dict[str, Any]] = None, 791 | body_classes: Optional[List[str]] = None, 792 | body_styles: Optional[Dict[str, Any]] = None, 793 | ): 794 | super().__init__( 795 | title=title, 796 | children=children, 797 | col_width=col_width, 798 | title_classes=title_classes, 799 | title_styles=title_styles, 800 | body_classes=body_classes, 801 | body_styles=body_styles, 802 | ) 803 | 804 | def __post_init__(self) -> None: 805 | self.title_html_tag = "h5" 806 | self.title_classes = ["card-title", "es-card-title"] 807 | self.title_styles = {} 808 | 809 | self.body_html_tag = "div" 810 | 811 | col_class = f"col-lg-{self.col_width}" if self.col_width else "col-lg" 812 | self.body_classes = [col_class, "es-card"] 813 | self.body_styles = {} 814 | 815 | def to_html(self, **kwargs: bool) -> str: 816 | """Render content to HTML string. 817 | 818 | Returns: 819 | html (str): HTML string. 820 | 821 | """ 822 | children_rendered = " ".join([c.to_html(**kwargs) for c in self.children]) 823 | title_rendered = ( 824 | render_html( 825 | self.title_html_tag, 826 | self.title_classes, 827 | self.title_styles, 828 | self.title, 829 | self.get_title_identifier(), 830 | ) 831 | if self.title 832 | else "" 833 | ) 834 | card_body_classes = ["es-card-body"] 835 | card_body_styles: Dict[str, str] = {} 836 | html_body = render_html( 837 | "div", 838 | card_body_classes, 839 | card_body_styles, 840 | f"\n{title_rendered}\n{children_rendered}\n", 841 | f"{self.get_identifier()}-body", 842 | ) 843 | html_full = render_html( 844 | self.body_html_tag, 845 | self.body_classes, 846 | self.body_styles, 847 | f"\n{html_body}\n", 848 | f"{self.get_identifier()}", 849 | ) 850 | 851 | return html_full 852 | 853 | 854 | class Spacer(Column): 855 | """Empty Column for making space within a Row.""" 856 | 857 | 858 | class PageBreak(Section): 859 | """Defines a page break when printing or saving to PDF.""" 860 | 861 | body_id = "es-page-break" 862 | 863 | def __post_init__(self) -> None: 864 | self.title_html_tag = "" 865 | self.title_classes = [] 866 | self.title_styles = {} 867 | 868 | self.body_html_tag = "div" 869 | self.body_classes = [] 870 | self.body_styles = {} 871 | 872 | 873 | def smart_wrap(self: Layout, child_list: Union[List[Child], Child]) -> List[Child]: 874 | from esparto.design.adaptors import content_adaptor 875 | 876 | child_list = ensure_iterable(child_list) 877 | 878 | if isinstance(self, Column): 879 | if any((isinstance(x, dict) for x in child_list)): 880 | raise TypeError("Invalid content passed to Column: 'dict'") 881 | return [content_adaptor(x) for x in child_list] 882 | 883 | is_row = isinstance(self, Row) 884 | unwrapped_acc: List[Child] = [] 885 | output: List[Child] = [] 886 | 887 | for child in child_list: 888 | is_wrapped = isinstance(child, self._child_class) 889 | 890 | if is_wrapped: 891 | if unwrapped_acc: 892 | wrapped_segment = self._child_class(children=unwrapped_acc) 893 | output.append(wrapped_segment) 894 | output.append(child) 895 | unwrapped_acc = [] 896 | else: 897 | output.append(child) 898 | else: # if not is_wrapped 899 | if is_row: 900 | if isinstance(child, dict): 901 | title, child = list(child.items())[0] 902 | else: 903 | title = None 904 | output.append(self._child_class(title=title, children=[child])) 905 | else: 906 | unwrapped_acc.append(child) 907 | 908 | if unwrapped_acc: 909 | wrapped_segment = self._child_class(children=unwrapped_acc) 910 | output.append(wrapped_segment) 911 | 912 | return output 913 | 914 | 915 | def render_html( 916 | tag: str, 917 | classes: List[str], 918 | styles: Dict[str, str], 919 | children: str, 920 | identifier: Optional[str] = None, 921 | ) -> str: 922 | """Render HTML from provided attributes.""" 923 | class_str = " ".join(classes) if classes else "" 924 | class_str = f"class='{class_str}'" if classes else "" 925 | 926 | style_str = "; ".join((f"{key}: {value}" for key, value in styles.items())) 927 | style_str = f"style='{style_str}'" if styles else "" 928 | 929 | id_str = f"id='{identifier}'" if identifier else "" 930 | 931 | rendered = " ".join((f"<{tag} {id_str} {class_str} {style_str}>").split()) 932 | rendered += f"\n {children}\n" 933 | 934 | return rendered 935 | 936 | 937 | def get_index_where( 938 | condition: Callable[..., bool], iterable: Iterable[Any] 939 | ) -> List[int]: 940 | """Return index values where `condition` is `True`.""" 941 | return [idx for idx, item in enumerate(iterable) if condition(item)] 942 | 943 | 944 | def get_matching_titles(title: str, children: List["Child"]) -> List[int]: 945 | """Return child items with matching title.""" 946 | return get_index_where(lambda x: bool(getattr(x, "title", None) == title), children) 947 | 948 | 949 | def clean_attr_name(attr_name: str) -> str: 950 | """Remove invalid characters from the attribute name.""" 951 | if not attr_name: 952 | return "" 953 | 954 | # Remove leading and trailing spaces 955 | attr_name = attr_name.strip().replace(" ", "_").lower() 956 | 957 | # Remove invalid characters 958 | attr_name = re.sub("[^0-9a-zA-Z_]", "", attr_name) 959 | 960 | # Remove leading characters until we find a letter or underscore 961 | attr_name = re.sub("^[^a-zA-Z_]+", "", attr_name) 962 | 963 | return attr_name 964 | 965 | 966 | def ensure_iterable(something: Any) -> Iterable[Any]: 967 | # Convert any non-list iterators to lists 968 | iterable = ( 969 | list(something) if isinstance(something, (list, tuple, set)) else [something] 970 | ) 971 | # Un-nest any nested lists of children 972 | if len(list(iterable)) == 1 and isinstance(list(iterable)[0], (list, tuple, set)): 973 | iterable = list(iterable)[0] 974 | return iterable 975 | --------------------------------------------------------------------------------