diff --git a/pyproject.toml b/pyproject.toml index 2932d77..bfab53b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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_" diff --git a/setup.cfg b/setup.cfg index b9cde7a..a4397fa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 @@ -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 @@ -45,6 +49,7 @@ tests = pytest-test-utils>=0.0.6 dev = %(table)s + %(markdown)s %(tests)s %(docs)s diff --git a/src/dvc_render/base.py b/src/dvc_render/base.py index 8902b44..fd0017a 100644 --- a/src/dvc_render/base.py +++ b/src/dvc_render/base.py @@ -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 @@ -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 diff --git a/src/dvc_render/exceptions.py b/src/dvc_render/exceptions.py index ea65729..cf256a6 100644 --- a/src/dvc_render/exceptions.py +++ b/src/dvc_render/exceptions.py @@ -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}'." + ) diff --git a/src/dvc_render/html.py b/src/dvc_render/html.py index 7ae1ac2..5bab070 100644 --- a/src/dvc_render/html.py +++ b/src/dvc_render/html.py @@ -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 @@ -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": @@ -95,8 +94,6 @@ def render_html( page_html = fobj.read() document = HTML(page_html, refresh_seconds=refresh_seconds) - if metrics: - document.with_element("
") for renderer in renderers: document.with_scripts(renderer.SCRIPTS) diff --git a/src/dvc_render/image.py b/src/dvc_render/image.py index 016a43c..e18bbd2 100644 --- a/src/dvc_render/image.py +++ b/src/dvc_render/image.py @@ -49,3 +49,14 @@ def partial_html(self, html_path=None, **kwargs) -> str: div_content.insert(0, f"

{self.name}

") 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 "" diff --git a/src/dvc_render/markdown.py b/src/dvc_render/markdown.py new file mode 100644 index 0000000..2f77fb4 --- /dev/null +++ b/src/dvc_render/markdown.py @@ -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 diff --git a/src/dvc_render/plotly.py b/src/dvc_render/plotly.py index fcec608..921cd84 100644 --- a/src/dvc_render/plotly.py +++ b/src/dvc_render/plotly.py @@ -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): @@ -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(): diff --git a/src/dvc_render/table.py b/src/dvc_render/table.py index 3177104..87b0976 100644 --- a/src/dvc_render/table.py +++ b/src/dvc_render/table.py @@ -1,4 +1,5 @@ from .base import Renderer +from .utils import list_dict_to_dict_list try: from tabulate import tabulate @@ -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}" diff --git a/src/dvc_render/utils.py b/src/dvc_render/utils.py new file mode 100644 index 0000000..ff59a78 --- /dev/null +++ b/src/dvc_render/utils.py @@ -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]} diff --git a/src/dvc_render/vega.py b/src/dvc_render/vega.py index 66da47c..d506b5f 100644 --- a/src/dvc_render/vega.py +++ b/src/dvc_render/vega.py @@ -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): @@ -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 "" diff --git a/tests/test_image.py b/tests/test_image.py index 4190c88..354c7ed 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -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", @@ -46,6 +46,32 @@ def test_render(html_path, src): assert f'' 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", [ @@ -81,6 +107,7 @@ def test_render_evaluate_path(tmp_dir, html_path, img_path, expected_path): assert f'' 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)() == "" diff --git a/tests/test_markdown.py b/tests/test_markdown.py new file mode 100644 index 0000000..8dc6e2d --- /dev/null +++ b/tests/test_markdown.py @@ -0,0 +1,46 @@ +import pytest + +from dvc_render.markdown import ( + PAGE_MARKDOWN, + Markdown, + MissingPlaceholderError, +) + +# pylint: disable=missing-function-docstring, R0801 + + +CUSTOM_PAGE_MARKDOWN = """# CUSTOM REPORT + +{renderers} +""" + + +@pytest.mark.parametrize( + "template,page_elements,expected_page", + [ + ( + None, + ["content"], + PAGE_MARKDOWN.replace("{renderers}", "content"), + ), + ( + CUSTOM_PAGE_MARKDOWN, + ["content"], + CUSTOM_PAGE_MARKDOWN.format(renderers="content"), + ), + ], +) +def test_html(template, page_elements, expected_page): + page = Markdown(template) + page.elements = page_elements + + result = page.embed() + + assert result == expected_page + + +def test_no_placeholder(): + template = "# Missing Placeholder" + + with pytest.raises(MissingPlaceholderError): + Markdown(template) diff --git a/tests/test_table.py b/tests/test_table.py index c0ab785..bf773ad 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -3,7 +3,7 @@ # pylint: disable=missing-function-docstring -def test_render(): +def test_generate_html(): datapoints = [ {"foo": 1, "bar": 2}, ] @@ -13,3 +13,14 @@ def test_render(): assert ' bar' in html assert ' 1' in html assert ' 2' in html + + +def test_generate_markdown(): + datapoints = [ + {"foo": 1, "bar": 2}, + ] + md = TableRenderer(datapoints, "metrics.json").generate_markdown() + assert "metrics.json\n\n" in md + assert "| foo | bar |" in md + assert "|-------|-------|" in md + assert "| 1 | 2 |" in md diff --git a/tests/test_vega.py b/tests/test_vega.py index 780f7ab..3b7c073 100644 --- a/tests/test_vega.py +++ b/tests/test_vega.py @@ -9,6 +9,23 @@ # pylint: disable=missing-function-docstring, C1803 +@pytest.mark.parametrize( + "extension, matches", + ( + (".csv", True), + (".json", True), + (".tsv", True), + (".yaml", True), + (".jpg", False), + (".gif", False), + (".jpeg", False), + (".png", False), + ), +) +def test_matches(extension, matches): + assert VegaRenderer.matches("file" + extension, {}) == matches + + def test_init_empty(): renderer = VegaRenderer(None, None) @@ -17,6 +34,7 @@ def test_init_empty(): assert renderer.properties == {} assert renderer.generate_html() == "" + assert renderer.generate_markdown("foo") == "" def test_choose_axes(): @@ -91,18 +109,50 @@ def test_raise_on_wrong_field(): renderer.get_filled_template(strict=False) -@pytest.mark.parametrize( - "extension, matches", - ( - (".csv", True), - (".json", True), - (".tsv", True), - (".yaml", True), - (".jpg", False), - (".gif", False), - (".jpeg", False), - (".png", False), - ), -) -def test_matches(extension, matches): - assert VegaRenderer.matches("file" + extension, {}) == matches +def test_generate_markdown(tmp_dir, mocker): + import matplotlib.pyplot + + plot = mocker.spy(matplotlib.pyplot, "plot") + title = mocker.spy(matplotlib.pyplot, "title") + xlabel = mocker.spy(matplotlib.pyplot, "xlabel") + ylabel = mocker.spy(matplotlib.pyplot, "ylabel") + + props = {"x": "first_val", "y": "second_val", "title": "FOO"} + datapoints = [ + {"first_val": 100.0, "second_val": 100.0, "val": 2.0}, + {"first_val": 200.0, "second_val": 300.0, "val": 3.0}, + ] + renderer = VegaRenderer(datapoints, "foo", **props) + + (tmp_dir / "output").mkdir() + renderer.generate_markdown(tmp_dir / "output" / "report.md") + + assert (tmp_dir / "output" / "foo.png").exists() + plot.assert_called_with( + "first_val", + "second_val", + data={ + "first_val": [100.0, 200.0], + "second_val": [100.0, 300.0], + "val": [2, 3], + }, + ) + title.assert_called_with("FOO") + xlabel.assert_called_with("first_val") + ylabel.assert_called_with("second_val") + + +def test_invalid_generate_markdown(): + datapoints = [ + {"predicted": "B", "actual": "A"}, + {"predicted": "A", "actual": "A"}, + ] + props = {"template": "confusion", "x": "predicted", "y": "actual"} + + renderer = VegaRenderer(datapoints, "foo", **props) + + with pytest.raises( + ValueError, + match="`generate_markdown` can only be used with `LinearTemplate`", + ): + renderer.generate_markdown("output")