From 6b2a9bbb67dc9b79e338991433f1de4256f76e30 Mon Sep 17 00:00:00 2001 From: Najeem Muhammed Date: Sun, 27 Aug 2023 15:36:09 +0200 Subject: [PATCH] Read me and other updates --- .github/workflows/python-pytest.yml | 4 +- README.md | 138 +++++++++++++++++++++++++++- html2dash/html2dash.py | 54 +++++++---- pyproject.toml | 2 +- testapp.py | 37 ++++++-- tests/test_basic.py | 7 +- 6 files changed, 208 insertions(+), 34 deletions(-) diff --git a/.github/workflows/python-pytest.yml b/.github/workflows/python-pytest.yml index b37e555..b6dc329 100644 --- a/.github/workflows/python-pytest.yml +++ b/.github/workflows/python-pytest.yml @@ -5,9 +5,9 @@ name: Python package on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: build: diff --git a/README.md b/README.md index 882e167..c5afa8b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,139 @@ # html2dash -Convert an HTML layout to an equivalent dash layout +Write your dash layout in html/xml form. + +## Why does this package exist? + +Dash is a great framework for building web apps using only python (no html/css/ +javascript). If you have used dash long enough, you must have noticed some of the +following. + +- For larger layouts, the python code becomes very long and hard to read. +- Cannot copy paste html code from examples on the web. +- Python's 4 space indentation makes the layout code shift a lot to the right + and look ugly. + +html2dash solves these problems by allowing you to write your dash layout in +html/xml form. It converts the html/xml code to equivalent dash layout code. + +## Examples + +Here is a simple example: + +```python +from dash import Dash +from html2dash import html2dash + +app = Dash(__name__) + +layout = """ +
+

Hello World

+

This is a paragraph

+
+

Subheading

+

Another paragraph

+
+
+""" + +app.layout = html2dash(layout) +``` + +You can define attributes like `id`, `class`, `style` etc. These +will be converted to equivalent dash attributes. For example: + +```python +layout = """ +
+

Hello World

+

This is a paragraph

+
+

Subheading

+

Another paragraph

+
+
+""" +``` + +This is equivalent to: + +```python +layout = html.Div( + id="my-div", + className="my-class", + style={"color": "red"}, + children=[ + html.H1("Hello World"), + html.P("This is a paragraph"), + html.Div( + children=[ + html.H2("Subheading"), + html.P("Another paragraph"), + ] + ) + ] +) +``` + +You can use any html tag that appears in `dash.html` module. If `html2dash` does +not find the tag in `dash.html`, it will search in the `dash.dcc` module. + +```python +from html2dash import html2dash + +layout = html2dash(""" +
+

Hello World

+

This is a paragraph

+ +
+""") +``` + +Here, `Input` is not found in `dash.html` module. So, it will search in `dash.dcc` +module and find `dcc.Input` and convert it to `dcc.Input(id="my-input", value="Hello World")`. + +The order in which `html2dash` searches for tags is: + +1. `dash.html` +2. `dash.dcc` + +You can add additional component libraries to the module list as follows. + +```python +from html2dash import html2dash, settings +import dash_mantine_components as dmc + +# settings["modules"] is a list of modules to search for tags. +# Default value is [html, dcc] +settings["modules"].append(dmc) + +layout = html2dash(""" +
+

Hello World

+

This is a paragraph

+
+ Default + Outline +
+
+""") +``` + +You can also map html tags to dash components. For example, if you dont want to +use `` tag, you can map it to `DashIconify` as follows. + +```python +from html2dash import html2dash, settings +from dash_iconify import DashIconify + +settings["element-map"]["icon"] = DashIconify + +layout = html2dash(""" +
+

Icon example

+ +
+""") +``` \ No newline at end of file diff --git a/html2dash/html2dash.py b/html2dash/html2dash.py index 69a2d1a..dd3e4f1 100644 --- a/html2dash/html2dash.py +++ b/html2dash/html2dash.py @@ -1,3 +1,13 @@ +"""html2dash + +Converts HTML to Dash components. + +Usage: + from html2dash import html2dash, settings + settings["modules"] = [html, dcc] + settings["modules"] + app.layout = html2dash(Path("layout.html").read_text()) + +""" from bs4 import BeautifulSoup, element, Comment from dash import html, dcc import re @@ -15,30 +25,47 @@ "autocomplete": "autoComplete", "autofocus": "autoFocus", "class": "className", + "colspan": "colSpan", "for": "htmlFor", "maxlength": "maxLength", "minlength": "minLength", "novalidate": "noValidate", + "readonly": "readOnly", + "rowspan": "rowSpan", "tabindex": "tabIndex", } def html2dash(html_str: str) -> html.Div: soup = BeautifulSoup(html_str, "xml") + if soup.body is not None: + soup = soup.body children = [parse_element(child) for child in soup.children] return html.Div(children=children) def parse_element(tag: element.Tag): - if tag is None: - return str(tag) - elif isinstance(tag, Comment): + if tag is None or isinstance(tag, Comment): return None elif isinstance(tag, element.NavigableString): text = str(tag) if text.strip(): return text return None + dash_element = None + for module in settings["modules"]: + mapped_element = settings["element-map"].get(tag.name) + if mapped_element is not None: + dash_element = mapped_element + elif hasattr(module, tag.name): + dash_element = getattr(module, tag.name) + elif hasattr(module, tag.name.title()): + dash_element = getattr(module, tag.name.title()) + if not dash_element: + logger.warning( + f"Could not find the element '{tag.name}'" f" in any of the modules." + ) + return None attrs = {k: v for k, v in tag.attrs.items()} attrs = fix_attrs(attrs) children = [] @@ -48,17 +75,7 @@ def parse_element(tag: element.Tag): children.append(child_object) if children: attrs["children"] = children - for module in settings["modules"]: - mapped_element = settings["element-map"].get(tag.name) - if mapped_element is not None: - return mapped_element(**attrs) - elif hasattr(module, tag.name): - return getattr(module, tag.name)(**attrs) - elif hasattr(module, tag.name.title()): - return getattr(module, tag.name.title())(**attrs) - logger.warning( - f"Could not find the element '{tag.name}'" f" in any of the modules." - ) + return dash_element(**attrs) def fix_attrs(attrs: dict) -> dict: @@ -75,9 +92,12 @@ def fix_attrs(attrs: dict) -> dict: elif isinstance(v, list): return_attrs[k] = " ".join(v) else: - try: - return_attrs[fix_hyphenated_attr(k)] = json.loads(v) - except Exception: + if isinstance(v, str) and any([s in v for s in ["{", "["]]): + try: + return_attrs[fix_hyphenated_attr(k)] = json.loads(v) + except Exception: + return_attrs[fix_hyphenated_attr(k)] = v + else: return_attrs[fix_hyphenated_attr(k)] = v return return_attrs diff --git a/pyproject.toml b/pyproject.toml index 88b91f1..b6f5c5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ readme = "README.md" requires-python = ">=3.7" license = { file = "LICENSE" } keywords = ["dash", "plotly", "html"] -dependencies = ["dash"] +dependencies = ["dash", "beautifulsoup4", "lxml"] classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", diff --git a/testapp.py b/testapp.py index 0234e77..57eeacb 100644 --- a/testapp.py +++ b/testapp.py @@ -7,6 +7,7 @@ settings["modules"].append(dmc) settings["element-map"]["icon"] = DashIconify settings["element-map"]["rprogress"] = dmc.RingProgress +settings["element-map"]["lprogress"] = dmc.Progress app = Dash( __name__, @@ -14,20 +15,36 @@ "https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/js/tabler.min.js" ], external_stylesheets=[ - "https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler.min.css" + "https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta17/dist/css/tabler.min.css", + "https://rsms.me/inter/inter.css", ] ) -app.layout = html2dash(Path("layout.html").read_text()) +app.layout = html2dash(Path("tabler.html").read_text()) -@callback( - Output("checkbox_output", "children"), - Input("checkbox", "checked"), -) -def checkbox_output(checked): - if checked: - return f"Checkbox is {checked}" - return f"Checkbox is {checked}" +# @callback( +# Output("checkbox_output", "children"), +# Input("checkbox", "checked"), +# ) +# def checkbox_output(checked): +# if checked: +# return f"Checkbox is {checked}" +# return f"Checkbox is {checked}" + +# @callback( +# Output("lprogress", "sections"), +# Input("button", "n_clicks"), +# ) +# def lprogress(n_clicks): +# if not n_clicks: +# return [ +# {"value": 10, "color": "blue", "tooltip": "10 blue"}, +# ] +# return [ +# {"value": 10, "color": "blue", "tooltip": "10 blue"}, +# {"value": 10, "color": "green", "tooltip": "10 green"}, +# {"value": 20, "color": "yellow", "tooltip": "20 yellow"}, +# ] if __name__ == "__main__": app.run_server(debug=True) diff --git a/tests/test_basic.py b/tests/test_basic.py index a4509bd..23fa339 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,6 @@ -# some basic tests - -from pathlib import Path +from dash import html from html2dash import html2dash + +def test_html2dash_empty(): + assert html2dash("").to_plotly_json() == html.Div([]).to_plotly_json()