Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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, report_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,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leftover from #61

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, report_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(report_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, report_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]}
41 changes: 40 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,40 @@ def get_filled_template(

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

def generate_markdown(self, report_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:
report_folder = Path(report_path).parent
output_file = report_folder / self.name
output_file = output_file.with_suffix(".png")
output_file.parent.mkdir(exist_ok=True, parents=True)

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.relative_to(report_folder)})"
)
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