Skip to content

Commit

Permalink
Use general approach for rendering in commonmark
Browse files Browse the repository at this point in the history
Instead of supporting graphviz and any other possible text-to-diagram
providers directly, let's build a general approach instead where a user
can specify whatever executable they want to pipe the content of the
code block through it, and replace the whole node w/ its output.

This drops direct graphviz support in favor of built-in 'exec' pipe.
  • Loading branch information
ikalnytskyi committed Nov 3, 2023
1 parent c9f8cb6 commit 9a5c7ff
Show file tree
Hide file tree
Showing 4 changed files with 32 additions and 82 deletions.
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,5 @@ jobs:
with:
python-version: "3.10"

- name: Set up prerequisites
run: sudo apt-get install graphviz libgraphviz-dev

- name: Test
run: pipx run -- hatch run test:run
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ dependencies = [
"Markdown >= 3.4",
"docutils >= 0.19",
"feedgen >= 0.9",
"pygraphviz >= 1.10",
"termcolor >= 1.1",
"colorama >= 0.4",
"markdown-it-py >= 2.1",
Expand Down Expand Up @@ -77,10 +76,10 @@ scripts.fmt = ["ruff --fix {args:.}", "black {args:.}"]

[tool.ruff]
select = ["F", "E", "W", "I", "S", "FBT", "B", "C4", "DTZ", "T10", "ISC", "RET", "SLF", "RUF"]
ignore = ["S701", "B904"]
ignore = ["S603", "S701", "B904"]

[tool.ruff.isort]
known-first-party = ["holocron"]

[tool.ruff.per-file-ignores]
"tests/*" = ["E501", "S101", "S603", "S607", "SLF001", "RUF001"]
"tests/*" = ["E501", "S101", "S607", "SLF001", "RUF001"]
50 changes: 26 additions & 24 deletions src/holocron/_processors/commonmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import json
import logging
import pathlib
import subprocess
import typing as t

import markdown_it
import markdown_it.renderer
Expand All @@ -11,13 +12,10 @@
import pygments.formatters.html
import pygments.lexers
import pygments.util
import pygraphviz
from mdit_py_plugins.container import container_plugin
from mdit_py_plugins.deflist import deflist_plugin
from mdit_py_plugins.footnote import footnote_plugin

import holocron

from ._misc import parameters

_LOGGER = logging.getLogger("holocron")
Expand All @@ -28,22 +26,36 @@ def __init__(self, parser):
super().__init__(parser)
self._parser = parser

def fence(self, tokens, idx, options, env):
def fence(self, tokens, idx, options, env) -> str:
token = tokens[idx]

match token.info.split(maxsplit=1):
case ["dot", params]:
env.setdefault("diagrams", [])
case [_, params]:
params = json.loads(params)
diagram_name = f"diagram-{len(env['diagrams'])}.svg"
diagram_data = pygraphviz.AGraph(token.content).draw(
format=params["format"],
prog=params.get("engine", "dot"),
)
env["diagrams"].append((diagram_name, diagram_data))
return self._parser.render(f"![]({diagram_name})")
if "exec" in params:
standard_input = token.content.encode("UTF-8")
standard_output = _exec_pipe(params["exec"], standard_input)
return standard_output.decode("UTF-8")

return super().fence(tokens, idx, options, env)


def _exec_pipe(args: t.List[str], input_: t.ByteString, timeout: int = 1000) -> bytes:
try:
completed_process = subprocess.run(
args,
input=input_,
capture_output=True,
timeout=timeout,
check=True,
)
except subprocess.TimeoutExpired:
return b"timed out executing the command"
except subprocess.CalledProcessError as exc:
return exc.stderr
return completed_process.stdout


def _pygmentize(code: str, language: str, _: str) -> str:
if not language:
return code
Expand Down Expand Up @@ -118,13 +130,3 @@ def process(
item["content"] = commonmark.renderer.render(tokens, commonmark.options, env)
item["destination"] = item["destination"].with_suffix(".html")
yield item

for diagram_name, diagram_bytes in env.get("diagrams", []):
yield holocron.WebSiteItem(
{
"source": pathlib.Path("dot://", str(item["source"]), diagram_name),
"destination": item["destination"].with_name(diagram_name),
"baseurl": app.metadata["url"],
"content": diagram_bytes,
}
)
56 changes: 4 additions & 52 deletions tests/_processors/test_commonmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ def test_args_pygmentize_unknown_language(testapp, language):
]


def test_item_dot_render(testapp):
def test_item_exec(testapp):
"""Commonmark has to render DOT snippets into SVG if asked to render."""

stream = commonmark.process(
Expand All @@ -417,10 +417,8 @@ def test_item_dot_render(testapp):
{
"content": textwrap.dedent(
"""
```dot {"format": "svg"}
graph yoda {
a -- b -- c
}
```text {"exec": ["sed", "--expression", "s/ a / the /g"]}
yoda, a jedi grandmaster
```
"""
),
Expand All @@ -435,57 +433,11 @@ def test_item_dot_render(testapp):
assert list(stream) == [
holocron.Item(
{
"content": '<p><img src="diagram-0.svg" alt="" /></p>\n',
"content": "yoda, the jedi grandmaster\n",
"source": pathlib.Path("1.md"),
"destination": pathlib.Path("1.html"),
},
),
holocron.WebSiteItem(
{
"content": _pytest_regex(rb".*</svg>\s*", re.DOTALL | re.MULTILINE),
"source": pathlib.Path("dot://1.md/diagram-0.svg"),
"destination": pathlib.Path("diagram-0.svg"),
"baseurl": "https://yoda.ua",
}
),
]


def test_item_dot_not_rendered(testapp):
"""Commonmark has to preserve DOT snippet if not asked to render."""

stream = commonmark.process(
testapp,
[
holocron.Item(
{
"content": textwrap.dedent(
"""
```dot
graph yoda {
a -- b -- c
}
```
"""
),
"destination": pathlib.Path("1.md"),
}
)
],
)

assert isinstance(stream, collections.abc.Iterable)
assert list(stream) == [
holocron.Item(
{
"content": (
'<pre><code class="language-dot">graph yoda {\n'
" a -- b -- c\n"
"}\n</code></pre>\n"
),
"destination": pathlib.Path("1.html"),
}
)
]


Expand Down

0 comments on commit 9a5c7ff

Please sign in to comment.