Skip to content

Commit

Permalink
table: Add generate_markdown.
Browse files Browse the repository at this point in the history
image: Add `generate_markdown`.

vega: Add `generate_markdown`.

"Translate" vega plot to `matplotlib` figure to save as `png`.

Introduce `render_markdown`.

Closes #62
  • Loading branch information
daavoo committed Jul 8, 2022
1 parent 99b0641 commit eab133e
Show file tree
Hide file tree
Showing 15 changed files with 326 additions and 41 deletions.
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,21 @@ warn_unreachable = true
files = ["src", "tests"]

[[tool.mypy.overrides]]
module = "tabulate"
module = [
"matplotlib.*",
"tabulate"
]
ignore_missing_imports = true

[tool.pylint.message_control]
enable = ["c-extension-no-member", "no-else-return"]
disable = ["missing-module-docstring", "missing-class-docstring", "invalid-name", "R0801"]
disable = [
"missing-module-docstring",
"missing-class-docstring",
"invalid-name",
"R0801",
"C0415"
]

[tool.pylint.variables]
dummy-variables-rgx = "_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_"
Expand Down
5 changes: 5 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ packages = find:
[options.extras_require]
table =
tabulate>=0.8.7
markdown =
%(table)s
matplotlib
docs =
mkdocs==1.3.0
mkdocs-gen-files==0.3.4
Expand All @@ -35,6 +38,7 @@ docs =
mkdocstrings-python==0.7.0
tests =
%(table)s
%(markdown)s
funcy>=1.17
pytest==7.1.2
pytest-sugar==0.9.4
Expand All @@ -45,6 +49,7 @@ tests =
pytest-test-utils>=0.0.6
dev =
%(table)s
%(markdown)s
%(tests)s
%(docs)s

Expand Down
8 changes: 7 additions & 1 deletion src/dvc_render/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import abc
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, List, Union
from typing import TYPE_CHECKING, Iterable, List, Optional, Union

if TYPE_CHECKING:
from os import PathLike
Expand Down Expand Up @@ -61,6 +61,12 @@ def generate_html(self, html_path=None) -> str:
return self.DIV.format(id=div_id, partial=partial)
return ""

def generate_markdown(
self, output_path: Optional[StrPath] = None
) -> str: # pylint: disable=missing-function-docstring
"Generate a markdown element"
raise NotImplementedError

@classmethod
def matches(
cls, filename, properties # pylint: disable=unused-argument
Expand Down
7 changes: 7 additions & 0 deletions src/dvc_render/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
class DvcRenderException(Exception):
pass


class MissingPlaceholderError(DvcRenderException):
def __init__(self, placeholder, template_type):
super().__init__(
f"{template_type} template has to contain '{placeholder}'."
)
5 changes: 1 addition & 4 deletions src/dvc_render/html.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional
from typing import TYPE_CHECKING, List, Optional

from .exceptions import DvcRenderException

Expand Down Expand Up @@ -81,7 +81,6 @@ def embed(self) -> str:
def render_html(
renderers: List["Renderer"],
output_file: "StrPath",
metrics: Optional[Dict[str, Dict]] = None,
template_path: Optional["StrPath"] = None,
refresh_seconds: Optional[int] = None,
) -> "StrPath":
Expand All @@ -95,8 +94,6 @@ def render_html(
page_html = fobj.read()

document = HTML(page_html, refresh_seconds=refresh_seconds)
if metrics:
document.with_element("<br>")

for renderer in renderers:
document.with_scripts(renderer.SCRIPTS)
Expand Down
11 changes: 11 additions & 0 deletions src/dvc_render/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,14 @@ def partial_html(self, html_path=None, **kwargs) -> str:
div_content.insert(0, f"<p>{self.name}</p>")
return "\n".join(div_content)
return ""

def generate_markdown(self, output_path=None) -> str:
content = []
for datapoint in self.datapoints:
src = datapoint[self.SRC_FIELD]
if src.startswith("data:image;base64"):
raise ValueError("`generate_markdown` doesn't support base64")
content.append(f"\n![{datapoint[self.TITLE_FIELD]}]({src})")
if content:
return "\n".join(content)
return ""
73 changes: 73 additions & 0 deletions src/dvc_render/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional

from .exceptions import MissingPlaceholderError

if TYPE_CHECKING:
from .base import Renderer, StrPath


PAGE_MARKDOWN = """# DVC Report
{renderers}
"""


class Markdown:
RENDERERS_PLACEHOLDER = "renderers"
RENDERERS_PLACEHOLDER_FORMAT_STR = f"{{{RENDERERS_PLACEHOLDER}}}"

def __init__(
self,
template: Optional[str] = None,
):
template = template or PAGE_MARKDOWN
if self.RENDERERS_PLACEHOLDER_FORMAT_STR not in template:
raise MissingPlaceholderError(
self.RENDERERS_PLACEHOLDER_FORMAT_STR, "Markdown"
)

self.template = template
self.elements: List[str] = []

def with_element(self, md: str) -> "Markdown":
"Adds custom markdown element."
self.elements.append(md)
return self

def embed(self) -> str:
"Format Markdown template with all elements."
kwargs = {
self.RENDERERS_PLACEHOLDER: "\n".join(self.elements),
}
for placeholder, value in kwargs.items():
self.template = self.template.replace(
"{" + placeholder + "}", value
)
return self.template


def render_markdown(
renderers: List["Renderer"],
output_file: "StrPath",
template_path: Optional["StrPath"] = None,
) -> "StrPath":
"User renderers to fill an Markdown template and write to path."
output_path = Path(output_file)
output_path.parent.mkdir(exist_ok=True)

page = None
if template_path:
with open(template_path, encoding="utf-8") as fobj:
page = fobj.read()

document = Markdown(page)

for renderer in renderers:
document.with_element(
renderer.generate_markdown(output_path=output_path)
)

output_path.write_text(document.embed(), encoding="utf8")

return output_file
7 changes: 2 additions & 5 deletions src/dvc_render/plotly.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import json
from collections import defaultdict
from typing import Any, Dict, Optional

from .base import Renderer
from .utils import list_dict_to_dict_list


class ParallelCoordinatesRenderer(Renderer):
Expand Down Expand Up @@ -46,10 +46,7 @@ def partial_html(self, **kwargs) -> str:
return json.dumps(self._get_plotly_data())

def _get_plotly_data(self):
tabular_dict = defaultdict(list)
for row in self.datapoints:
for col_name, value in row.items():
tabular_dict[col_name].append(str(value))
tabular_dict = list_dict_to_dict_list(self.datapoints)

trace: Dict[str, Any] = {"type": "parcoords", "dimensions": []}
for label, values in tabular_dict.items():
Expand Down
22 changes: 14 additions & 8 deletions src/dvc_render/table.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .base import Renderer
from .utils import list_dict_to_dict_list

try:
from tabulate import tabulate
Expand All @@ -22,12 +23,17 @@ class TableRenderer(Renderer):

EXTENSIONS = {".yml", ".yaml", ".json"}

def partial_html(self, **kwargs) -> str:
# From list of dicts to dict of lists
data = {
k: [datapoint[k] for datapoint in self.datapoints]
for k in self.datapoints[0]
}
@classmethod
def to_tabulate(cls, datapoints, tablefmt):
"""Convert datapoints to tabulate format"""
if tabulate is None:
raise ImportError(f"{self.__class__} requires `tabulate`.")
return tabulate(data, headers="keys", tablefmt="html")
raise ImportError(f"{cls.__name__} requires `tabulate`.")
data = list_dict_to_dict_list(datapoints)
return tabulate(data, headers="keys", tablefmt=tablefmt)

def partial_html(self, **kwargs) -> str:
return self.to_tabulate(self.datapoints, tablefmt="html")

def generate_markdown(self, output_path=None) -> str:
table = self.to_tabulate(self.datapoints, tablefmt="github")
return f"{self.name}\n\n{table}"
5 changes: 5 additions & 0 deletions src/dvc_render/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def list_dict_to_dict_list(list_dict):
"""Convert from list of dictionaries to dictionary of lists."""
if not list_dict:
return {}
return {k: [x[k] for x in list_dict] for k in list_dict[0]}
37 changes: 36 additions & 1 deletion src/dvc_render/vega.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from copy import deepcopy
from pathlib import Path
from typing import List, Optional

from .base import Renderer
from .exceptions import DvcRenderException
from .vega_templates import get_template
from .utils import list_dict_to_dict_list
from .vega_templates import LinearTemplate, get_template


class BadTemplateError(DvcRenderException):
Expand Down Expand Up @@ -85,3 +87,36 @@ def get_filled_template(

def partial_html(self, **kwargs) -> str:
return self.get_filled_template()

def generate_markdown(self, output_path=None) -> str:
if not isinstance(self.template, LinearTemplate):
raise ValueError(
"`generate_markdown` can only be used with `LinearTemplate`"
)
try:
from matplotlib import pyplot as plt
except ImportError as e:
raise ImportError(
"matplotlib is required for `generate_markdown`"
) from e

data = list_dict_to_dict_list(self.datapoints)
if data:
output_file = Path(output_path).parent / self.name
output_file = output_file.with_suffix(".png")

x = self.properties.get("x")
y = self.properties.get("y")
data[x] = list(map(float, data[x]))
data[y] = list(map(float, data[y]))

plt.title(self.properties.get("title", output_file.stem))
plt.xlabel(self.properties.get("x_label", x))
plt.ylabel(self.properties.get("y_label", y))
plt.plot(x, y, data=data)
plt.tight_layout()
plt.savefig(output_file)
plt.close()

return f"\n![{self.name}]({output_file.name})"
return ""
35 changes: 31 additions & 4 deletions tests/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def test_matches(extension, matches):
@pytest.mark.parametrize(
"src", ["relpath.jpg", "data:image;base64,encoded_image"]
)
def test_render(html_path, src):
def test_generate_html(html_path, src):
datapoints = [
{
"filename": "file.jpg",
Expand All @@ -46,6 +46,32 @@ def test_render(html_path, src):
assert f'<img src="{src}">' in html


def test_generate_markdown():
datapoints = [
{
"rev": "workspace",
"src": "file.jpg",
}
]

md = ImageRenderer(datapoints, "file.jpg").generate_markdown()

assert "![workspace](file.jpg)" in md


def test_invalid_generate_markdown():
datapoints = [
{
"rev": "workspace",
"src": "data:image;base64,encoded_image",
}
]
with pytest.raises(
ValueError, match="`generate_markdown` doesn't support base64"
):
ImageRenderer(datapoints, "file.jpg").generate_markdown()


@pytest.mark.parametrize(
"html_path,img_path,expected_path",
[
Expand Down Expand Up @@ -81,6 +107,7 @@ def test_render_evaluate_path(tmp_dir, html_path, img_path, expected_path):
assert f'<img src="{expected_path}">' in html


def test_render_empty():
html = ImageRenderer(None, None).generate_html()
assert html == ""
@pytest.mark.parametrize("method", ["generate_html", "generate_markdown"])
def test_render_empty(method):
renderer = ImageRenderer(None, None)
assert getattr(renderer, method)() == ""
Loading

0 comments on commit eab133e

Please sign in to comment.