From da055acda6f8a3549acb58aa013563d7764e8e9d Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Thu, 24 Jun 2021 09:46:26 -0500 Subject: [PATCH] provide markdown extension to render graphviz this is server side, and a more standard format, and thus I like it more than mermaid, which I've been using at work. but, I really wanted a server-side option (see my manifesto) for drawing relationship graphs, for D&D stuff of all things. this adds an optional 'graphviz' feature to package installation which consequently depends on pydot --- incorporealcms/mdx/__init__.py | 1 + incorporealcms/mdx/pydot.py | 46 +++++++++++++++++++++++++++ requirements/requirements-dev.in | 1 + requirements/requirements-dev.txt | 35 ++++++++++++-------- setup.py | 3 ++ tests/functional_markdown_tests.py | 29 +++++++++++++++++ tests/instance/pages/test-graphviz.md | 12 +++++++ 7 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 incorporealcms/mdx/__init__.py create mode 100644 incorporealcms/mdx/pydot.py create mode 100644 tests/functional_markdown_tests.py create mode 100644 tests/instance/pages/test-graphviz.md diff --git a/incorporealcms/mdx/__init__.py b/incorporealcms/mdx/__init__.py new file mode 100644 index 0000000..e3bc274 --- /dev/null +++ b/incorporealcms/mdx/__init__.py @@ -0,0 +1 @@ +"""Markdown extensions.""" diff --git a/incorporealcms/mdx/pydot.py b/incorporealcms/mdx/pydot.py new file mode 100644 index 0000000..9161624 --- /dev/null +++ b/incorporealcms/mdx/pydot.py @@ -0,0 +1,46 @@ +"""Serve dot diagrams inline.""" +import base64 +import re + +import markdown +import pydot + + +class InlinePydot(markdown.Extension): + """Wrap the markdown prepcoressor.""" + + def extendMarkdown(self, md, md_globals): + """Add InlinePydotPreprocessor to the Markdown instance.""" + md.registerExtension(self) + md.preprocessors.add('dot_block', InlinePydotPreprocessor(md), '_begin') + + +class InlinePydotPreprocessor(markdown.preprocessors.Preprocessor): + """Identify dot codeblocks and run them through pydot.""" + + BLOCK_RE = re.compile(r'~~~pydot:(?P[^\s]+)\n(?P.*?)~~~', re.DOTALL) + + def run(self, lines): + """Match and generate diagrams from dot code blocks.""" + text = '\n'.join(lines) + for match in self.BLOCK_RE.finditer(text): + filename = match.group(1) + dot_string = match.group(2) + + # use pydot to turn the text into pydot + graphs = pydot.graph_from_dot_data(dot_string) + + # encode the image and provide as an inline image in markdown + encoded_image = base64.b64encode(graphs[0].create_png()).decode('ascii') + data_path = f'data:image/png;base64,{encoded_image}' + inline_image = f'![{filename}]({data_path})' + + # replace the image in the output markdown + text = f'{text[:match.start()]}\n{inline_image}\n{text[match.end():]}' + + return text.split('\n') + + +def makeExtension(*args, **kwargs): + """Provide the extension to the markdown extension loader.""" + return InlinePydot(*args, **kwargs) diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in index 5d9e045..be6ddfd 100644 --- a/requirements/requirements-dev.in +++ b/requirements/requirements-dev.in @@ -1,6 +1,7 @@ -r requirements.in # testing runner, test reporting, packages used during testing (e.g. requests-mock), etc. +pydot pytest pytest-cov diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 9cced20..60596d5 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -6,7 +6,7 @@ # appdirs==1.4.4 # via virtualenv -attrs==20.3.0 +attrs==21.2.0 # via pytest bandit==1.6.2 # via -r requirements/requirements-dev.in @@ -18,7 +18,7 @@ click==7.1.2 # pip-tools coverage==5.5 # via pytest-cov -distlib==0.3.1 +distlib==0.3.2 # via virtualenv dlint==0.11.0 # via -r requirements/requirements-dev.in @@ -42,7 +42,7 @@ flake8-logging-format==0.6.0 # via -r requirements/requirements-dev.in flake8-mutable==1.2.0 # via -r requirements/requirements-dev.in -flake8==3.9.1 +flake8==3.9.2 # via # -r requirements/requirements-dev.in # dlint @@ -55,11 +55,11 @@ flask==1.1.2 # via -r requirements/requirements.in gitdb==4.0.7 # via gitpython -gitpython==3.1.14 +gitpython==3.1.18 # via bandit iniconfig==1.1.1 # via pytest -isort==5.8.0 +isort==5.9.1 # via flake8-isort itsdangerous==1.1.0 # via flask @@ -80,11 +80,11 @@ packaging==20.9 # bleach # pytest # tox -pbr==5.5.1 +pbr==5.6.0 # via stevedore pep517==0.10.0 # via pip-tools -pip-tools==6.1.0 +pip-tools==6.2.0 # via -r requirements/requirements-dev.in pluggy==0.13.1 # via @@ -96,15 +96,19 @@ py==1.10.0 # tox pycodestyle==2.7.0 # via flake8 -pydocstyle==6.0.0 +pydocstyle==6.1.1 # via flake8-docstrings +pydot==1.4.2 + # via -r requirements/requirements-dev.in pyflakes==2.3.1 # via flake8 pyparsing==2.4.7 - # via packaging -pytest-cov==2.11.1 + # via + # packaging + # pydot +pytest-cov==2.12.1 # via -r requirements/requirements-dev.in -pytest==6.2.3 +pytest==6.2.4 # via # -r requirements/requirements-dev.in # pytest-cov @@ -130,10 +134,11 @@ toml==0.10.2 # via # pep517 # pytest + # pytest-cov # tox tox-wheel==0.6.0 # via -r requirements/requirements-dev.in -tox==3.23.0 +tox==3.23.1 # via # -r requirements/requirements-dev.in # tox-wheel @@ -141,14 +146,16 @@ tzlocal==2.1 # via -r requirements/requirements.in versioneer==0.19 # via -r requirements/requirements-dev.in -virtualenv==20.4.3 +virtualenv==20.4.7 # via tox webencodings==0.5.1 # via bleach werkzeug==1.0.1 # via flask wheel==0.36.2 - # via tox-wheel + # via + # pip-tools + # tox-wheel # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/setup.py b/setup.py index 02db009..df71919 100644 --- a/setup.py +++ b/setup.py @@ -27,4 +27,7 @@ setup( include_package_data=True, zip_safe=False, install_requires=extract_requires(), + extras_require={ + 'graphviz': ['pydot'], + }, ) diff --git a/tests/functional_markdown_tests.py b/tests/functional_markdown_tests.py new file mode 100644 index 0000000..8125e98 --- /dev/null +++ b/tests/functional_markdown_tests.py @@ -0,0 +1,29 @@ +"""Test graphviz functionality.""" +import os + +from incorporealcms import create_app + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def app_with_pydot(): + """Create the test app, including the pydot extension.""" + return create_app(instance_path=os.path.join(HERE, 'instance'), + test_config={'MARKDOWN_EXTENSIONS': ['incorporealcms.mdx.pydot']}) + + +def test_functional_initialization(): + """Test initialization with the graphviz config.""" + app = app_with_pydot() + assert app is not None + + +def test_graphviz_is_rendered(): + """Initialize the app with the graphviz extension and ensure it does something.""" + app = app_with_pydot() + client = app.test_client() + + response = client.get('/test-graphviz') + assert response.status_code == 200 + assert b'~~~pydot' not in response.data + assert b'data:image/png;base64' in response.data diff --git a/tests/instance/pages/test-graphviz.md b/tests/instance/pages/test-graphviz.md new file mode 100644 index 0000000..c2ad5c3 --- /dev/null +++ b/tests/instance/pages/test-graphviz.md @@ -0,0 +1,12 @@ +# test + +test +~~~pydot:attack-plan +digraph G { + rankdir=LR + Earth + Mars + Earth -> Mars +} +~~~ +more test