render: Extract to dvc_render

Create and use separate package for rendering logic.
Closes #6944
This commit is contained in:
David de la Iglesia Castro
2022-03-28 19:34:05 +02:00
parent b71a0f30fb
commit ddd6c97f71
43 changed files with 755 additions and 2726 deletions
+3 -2
View File
@@ -432,14 +432,15 @@ def show_experiments(
for x in td.column("Experiment")
]
out = kwargs.get("out") or "dvc_plots"
output_file = os.path.join(out, "index.html")
ui.write(
td.to_parallel_coordinates(
output_path=os.path.abspath(out),
output_path=os.path.abspath(output_file),
color_by=kwargs.get("sort_by") or "Experiment",
)
)
if kwargs.get("open"):
return ui.open_browser(os.path.join(out, "index.html"))
return ui.open_browser(output_file)
else:
td.render(
+8 -4
View File
@@ -10,17 +10,21 @@ class CmdLive(CmdBase):
UNINITIALIZED = True
def _run(self, target, revs=None):
from dvc.render.utils import match_renderers, render
from dvc_render import render_html
from dvc.render.match import match_renderers
metrics, plots = self.repo.live.show(target=target, revs=revs)
if plots:
from pathlib import Path
html_path = Path.cwd() / (self.args.target + "_html")
output = Path.cwd() / (self.args.target + "_html") / "index.html"
renderers = match_renderers(plots, self.repo.plots.templates)
index_path = render(self.repo, renderers, metrics, html_path)
renderers = match_renderers(
plots, templates_dir=self.repo.plots.templates_dir
)
index_path = render_html(renderers, output, metrics)
ui.write(index_path.as_uri())
return 0
return 1
+34 -27
View File
@@ -1,6 +1,7 @@
import argparse
import json
import logging
import os
from funcy import first
@@ -14,15 +15,11 @@ from dvc.utils import format_link
logger = logging.getLogger(__name__)
def _show_json(renderers, path: None, split=False):
if any(r.needs_output_path for r in renderers) and not path:
raise DvcException("Output path ('-o') is required!")
def _show_json(renderers, split=False):
from dvc.render.convert import to_json
result = {
renderer.filename: json.loads(
renderer.as_json(path=path, fill_data=not split)
)
for renderer in renderers
renderer.name: to_json(renderer, split) for renderer in renderers
}
if result:
ui.write_json(result)
@@ -42,8 +39,9 @@ class CmdPlots(CmdBase):
def run(self):
from pathlib import Path
from dvc.render.utils import match_renderers, render
from dvc.render.vega import VegaRenderer
from dvc_render import render_html
from dvc.render.match import match_renderers
if self.args.show_vega:
if not self.args.targets:
@@ -74,31 +72,40 @@ class CmdPlots(CmdBase):
)
renderers = match_renderers(
plots_data=plots_data, templates=self.repo.plots.templates
plots_data=plots_data, out=self.args.out
)
if self.args.show_vega:
renderer = first(
filter(lambda r: isinstance(r, VegaRenderer), renderers)
)
renderer = first(filter(lambda r: r.TYPE == "vega", renderers))
if renderer:
content = renderer.asdict()
ui.write_json(content)
ui.write_json(json.loads(renderer.partial_html()))
return 0
if self.args.json:
_show_json(renderers, self.args.out, self.args.split)
_show_json(renderers, self.args.split)
return 0
html_template_path = self.args.html_template
if not html_template_path:
html_template_path = self.repo.config.get("plots", {}).get(
"html_template", None
)
if html_template_path and not os.path.isabs(
html_template_path
):
html_template_path = os.path.join(
self.repo.dvc_dir, html_template_path
)
rel: str = self.args.out or "dvc_plots"
path: Path = (Path.cwd() / rel).resolve()
index_path = render(
self.repo,
renderers,
path=path,
html_template_path=self.args.html_template,
output_file: Path = (Path.cwd() / rel).resolve() / "index.html"
render_html(
renderers=renderers,
output_file=output_file,
template_path=html_template_path,
)
ui.write(index_path.as_uri())
ui.write(output_file.as_uri())
auto_open = self.repo.config["plots"].get("auto_open", False)
if self.args.open or auto_open:
if not auto_open:
@@ -107,7 +114,7 @@ class CmdPlots(CmdBase):
"\n"
"\tdvc config plots.auto_open true"
)
return ui.open_browser(index_path)
return ui.open_browser(output_file)
return 0
@@ -154,17 +161,17 @@ class CmdPlotsTemplates(CmdBase):
]
def run(self):
import os
from dvc_render.vega_templates import dump_templates
try:
out = (
os.path.join(os.getcwd(), self.args.out)
if self.args.out
else self.repo.plots.templates.templates_dir
else self.repo.plots.templates_dir
)
targets = [self.args.target] if self.args.target else None
self.repo.plots.templates.init(output=out, targets=targets)
dump_templates(output=out, targets=targets)
templates_path = os.path.relpath(out, os.getcwd())
ui.write(f"Templates have been written into '{templates_path}'.")
+11 -7
View File
@@ -168,17 +168,21 @@ class TabularData(MutableSequence[Sequence["CellT"]]):
def to_parallel_coordinates(
self, output_path: "StrPath", color_by: str = None
) -> str:
from dvc.render.html import write
from dvc.render.plotly import ParallelCoordinatesRenderer
) -> "StrPath":
from dvc_render.html import render_html
from dvc_render.plotly import ParallelCoordinatesRenderer
index_path = write(
output_path,
render_html(
renderers=[
ParallelCoordinatesRenderer(self, color_by, self._fill_value)
ParallelCoordinatesRenderer(
self.as_dict(),
color_by=color_by,
fill_value=self._fill_value,
)
],
output_file=output_path,
)
return index_path.as_uri()
return output_path
def add_column(self, name: str) -> None:
self._columns[name] = Column([self._fill_value] * len(self))
+5 -4
View File
@@ -1,4 +1,5 @@
from .image import ImageRenderer
from .vega import VegaRenderer
RENDERERS = [ImageRenderer, VegaRenderer]
INDEX_FIELD = "step"
REVISION_FIELD = "rev"
REVISIONS_KEY = "revisions"
TYPE_KEY = "type"
SRC_FIELD = "src"
-77
View File
@@ -1,77 +0,0 @@
import abc
from typing import TYPE_CHECKING, Dict
from dvc.exceptions import DvcException
if TYPE_CHECKING:
from dvc.types import StrPath
REVISION_FIELD = "rev"
INDEX_FIELD = "step"
class BadTemplateError(DvcException):
pass
class Renderer(abc.ABC):
REVISIONS_KEY = "revisions"
TYPE_KEY = "type"
def __init__(self, data: Dict, **kwargs):
self.data = data
from dvc.render.utils import get_files
files = get_files(self.data)
# we assume comparison of same file between revisions for now
assert len(files) == 1
self.filename = files.pop()
def partial_html(self, **kwargs):
"""
Us this method to generate partial HTML content,
that will fill self.DIV
"""
raise NotImplementedError
@property
@abc.abstractmethod
def TYPE(self):
raise NotImplementedError
@property
@abc.abstractmethod
def DIV(self):
raise NotImplementedError
@property
@abc.abstractmethod
def SCRIPTS(self):
raise NotImplementedError
@abc.abstractmethod
def as_json(self, **kwargs):
raise NotImplementedError
@staticmethod
def _remove_special_chars(string: str):
return string.translate(
{ord(c): "_" for c in r"!@#$%^&*()[]{};,<>?\/:.|`~=_+"}
)
@property
def needs_output_path(self):
return False
def generate_html(self, path: "StrPath"):
"""this method might edit content of path"""
partial = self.partial_html(path=path)
div_id = self._remove_special_chars(self.filename)
div_id = f"plot_{div_id}"
return self.DIV.format(id=div_id, partial=partial)
+68
View File
@@ -0,0 +1,68 @@
import json
from collections import defaultdict
from typing import Dict, List, Union
from dvc.render import REVISION_FIELD, REVISIONS_KEY, SRC_FIELD, TYPE_KEY
from dvc.render.image_converter import ImageConverter
from dvc.render.vega_converter import VegaConverter
def get_converter(
renderer_class, props
) -> Union[VegaConverter, ImageConverter]:
from dvc_render import ImageRenderer, VegaRenderer
if renderer_class.TYPE == VegaRenderer.TYPE:
return VegaConverter(props)
if renderer_class.TYPE == ImageRenderer.TYPE:
return ImageConverter(props)
raise ValueError(f"Invalid renderer class {renderer_class}")
def to_datapoints(renderer_class, data: Dict, props: Dict):
converter = get_converter(renderer_class, props)
datapoints: List[Dict] = []
for revision, rev_data in data.items():
for filename, file_data in rev_data.get("data", {}).items():
if "data" in file_data:
processed, final_props = converter.convert(
revision, filename, file_data.get("data")
)
datapoints.extend(processed)
return datapoints, final_props
def _group_by_rev(datapoints):
grouped = defaultdict(list)
for datapoint in datapoints:
rev = datapoint.pop(REVISION_FIELD)
grouped[rev].append(datapoint)
return dict(grouped)
def to_json(renderer, split: bool = False) -> List[Dict]:
if renderer.TYPE == "vega":
grouped = _group_by_rev(renderer.datapoints)
if split:
content = renderer.get_filled_template(skip_anchors=["data"])
else:
content = renderer.get_filled_template()
return [
{
TYPE_KEY: renderer.TYPE,
REVISIONS_KEY: sorted(grouped.keys()),
"content": json.loads(content),
"datapoints": grouped,
}
]
if renderer.TYPE == "image":
return [
{
TYPE_KEY: renderer.TYPE,
REVISIONS_KEY: datapoint.get(REVISION_FIELD),
"url": datapoint.get(SRC_FIELD),
}
for datapoint in renderer.datapoints
]
raise ValueError(f"Invalid renderer: {renderer.TYPE}")
-114
View File
@@ -1,114 +0,0 @@
import os
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional
from dvc.exceptions import DvcException
if TYPE_CHECKING:
from dvc.render.base import Renderer
from dvc.types import StrPath
PAGE_HTML = """<!DOCTYPE html>
<html>
<head>
{refresh_tag}
<title>DVC Plot</title>
{scripts}
</head>
<body>
{plot_divs}
</body>
</html>"""
class MissingPlaceholderError(DvcException):
def __init__(self, placeholder):
super().__init__(f"HTML template has to contain '{placeholder}'.")
class HTML:
SCRIPTS_PLACEHOLDER = "scripts"
PLOTS_PLACEHOLDER = "plot_divs"
PLOTS_PLACEHOLDER_FORMAT_STR = f"{{{PLOTS_PLACEHOLDER}}}"
REFRESH_PLACEHOLDER = "refresh_tag"
REFRESH_TAG = '<meta http-equiv="refresh" content="{}">'
def __init__(
self,
template: Optional[str] = None,
refresh_seconds: Optional[int] = None,
):
template = template or PAGE_HTML
if self.PLOTS_PLACEHOLDER_FORMAT_STR not in template:
raise MissingPlaceholderError(self.PLOTS_PLACEHOLDER_FORMAT_STR)
self.template = template
self.elements: List[str] = []
self.scripts: str = ""
self.refresh_tag = ""
if refresh_seconds is not None:
self.refresh_tag = self.REFRESH_TAG.format(refresh_seconds)
def with_metrics(self, metrics: Dict[str, Dict]) -> "HTML":
import tabulate
header: List[str] = []
rows: List[List[str]] = []
for _, rev_data in metrics.items():
for _, data in rev_data.items():
if not header:
header.extend(sorted(data.keys()))
rows.append([data[key] for key in header])
self.elements.append(tabulate.tabulate(rows, header, tablefmt="html"))
return self
def with_scripts(self, scripts: str) -> "HTML":
if scripts not in self.scripts:
self.scripts += f"\n{scripts}"
return self
def with_element(self, html: str) -> "HTML":
self.elements.append(html)
return self
def embed(self) -> str:
kwargs = {
self.SCRIPTS_PLACEHOLDER: self.scripts,
self.PLOTS_PLACEHOLDER: "\n".join(self.elements),
self.REFRESH_PLACEHOLDER: self.refresh_tag,
}
return self.template.format(**kwargs)
def write(
path: "StrPath",
renderers: List["Renderer"],
metrics: Optional[Dict[str, Dict]] = None,
template_path: Optional["StrPath"] = None,
refresh_seconds: Optional[int] = None,
):
os.makedirs(path, exist_ok=True)
page_html = None
if template_path:
with open(template_path, encoding="utf-8") as fobj:
page_html = fobj.read()
document = HTML(page_html, refresh_seconds=refresh_seconds)
if metrics:
document.with_metrics(metrics)
document.with_element("<br>")
for renderer in renderers:
document.with_scripts(renderer.SCRIPTS)
document.with_element(renderer.generate_html(path))
index = Path(os.path.join(path, "index.html"))
with open(index, "w", encoding="utf-8") as fd:
fd.write(document.embed())
return index
-110
View File
@@ -1,110 +0,0 @@
import json
import os
from typing import TYPE_CHECKING
from funcy import reraise
from dvc.exceptions import DvcException
from dvc.render.base import Renderer
from dvc.render.utils import get_files
from dvc.utils import relpath
if TYPE_CHECKING:
from dvc.types import StrPath
class ImageRenderer(Renderer):
TYPE = "image"
DIV = """
<div
id="{id}"
style="border:1px solid black;text-align:center;
white-space: nowrap;overflow-y:hidden;">
{partial}
</div>"""
SCRIPTS = ""
@property
def needs_output_path(self):
return True
def _write_image(
self,
path: "StrPath",
revision: str,
filename: str,
image_data: bytes,
):
img_path = os.path.join(
path, f"{revision}_{filename.replace(os.sep, '_')}"
)
with open(img_path, "wb") as fd:
fd.write(image_data)
return img_path
def _save_images(self, path: "StrPath"):
for rev, rev_data in self.data.items():
if "data" in rev_data:
for file, file_data in rev_data.get("data", {}).items():
if "data" in file_data:
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
yield rev, file, self._write_image(
os.path.abspath(path), rev, file, file_data["data"]
)
def partial_html(self, **kwargs):
path = kwargs.get("path", None)
if not path:
raise DvcException("Can't save here")
static = os.path.join(path, "static")
div_content = []
for rev, _, img_path in self._save_images(static):
div_content.append(
"""
<div
style="border:1px dotted black;margin:2px;display: inline-block;
overflow:hidden;margin-left:8px;">
<p>{title}</p>
<img src="{src}">
</div>""".format(
title=rev, src=(relpath(img_path, path))
)
)
if div_content:
div_content.insert(0, f"<p>{self.filename}</p>")
return "\n".join(div_content)
return ""
def as_json(self, **kwargs):
with reraise(
KeyError,
DvcException(
f"{type(self).__name__} needs 'path' to store images."
),
):
path = kwargs["path"]
results = []
for revision, _, img_path in self._save_images(path):
results.append(
{
self.TYPE_KEY: self.TYPE,
self.REVISIONS_KEY: [revision],
"url": img_path,
}
)
return json.dumps(results)
@staticmethod
def matches(data):
files = get_files(data)
extensions = set(map(lambda f: os.path.splitext(f)[1], files))
return extensions.issubset({".jpg", ".jpeg", ".gif", ".png"})
+58
View File
@@ -0,0 +1,58 @@
import base64
import os
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from dvc.render import REVISION_FIELD, SRC_FIELD
if TYPE_CHECKING:
from dvc.types import StrPath
class ImageConverter:
def __init__(self, plot_properties: Optional[Dict] = None):
self.plot_properties = plot_properties or {}
@staticmethod
def _write_image(
path: "StrPath",
revision: str,
filename: str,
image_data: bytes,
) -> "StrPath":
img_path = os.path.join(
path, f"{revision}_{filename.replace(os.sep, '_')}"
)
with open(img_path, "wb") as fd:
fd.write(image_data)
return img_path
@staticmethod
def _encode_image(
image_data: bytes,
) -> str:
base64_str = base64.b64encode(image_data).decode()
return f"data:image;base64,{base64_str}"
def convert(
self, revision, filename, data: bytes
) -> Tuple[List[Dict], Dict]:
"""
Convert the DVC Plots content to DVC Render datapoints.
Return both generated datapoints and updated properties.
"""
path = self.plot_properties.get("out")
if path:
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
src = self._write_image(
os.path.abspath(path), revision, filename, data
)
else:
src = self._encode_image(data)
datapoint = {
REVISION_FIELD: revision,
"filename": filename,
SRC_FIELD: src,
}
return [datapoint], self.plot_properties
+55
View File
@@ -0,0 +1,55 @@
from typing import TYPE_CHECKING, Dict, List, Optional
import dpath.util
from dvc_render import RENDERERS
from .convert import to_datapoints
if TYPE_CHECKING:
from dvc.types import StrPath
def get_files(plots_data: Dict) -> List:
values = dpath.util.values(plots_data, ["*", "*"])
return sorted({key for submap in values for key in submap.keys()})
def group_by_filename(plots_data: Dict) -> Dict:
files = get_files(plots_data)
grouped = {
file: dpath.util.search(plots_data, ["*", "*", file]) for file in files
}
return grouped
def squash_plots_properties(data: Dict) -> Dict:
resolved: Dict[str, str] = {}
for rev_data in data.values():
for file_data in rev_data.get("data", {}).values():
props = file_data.get("props", {})
resolved = {**resolved, **props}
return resolved
def match_renderers(
plots_data,
out: Optional["StrPath"] = None,
templates_dir: Optional["StrPath"] = None,
):
renderers = []
for filename, group in group_by_filename(plots_data).items():
plot_properties = squash_plots_properties(group)
for renderer_class in RENDERERS:
if renderer_class.matches(filename, plot_properties):
if out is not None:
plot_properties["out"] = out
if templates_dir is not None:
plot_properties["templates_dir"] = templates_dir
datapoints, plot_properties = to_datapoints(
renderer_class, group, plot_properties
)
renderers.append(
renderer_class(datapoints, filename, **plot_properties)
)
break
return renderers
-92
View File
@@ -1,92 +0,0 @@
import json
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, Optional
from dvc.render.base import Renderer
if TYPE_CHECKING:
from dvc.compare import TabularData
class ParallelCoordinatesRenderer(Renderer):
TYPE = "plotly"
DIV = """
<div id = "{id}">
<script type = "text/javascript">
var plotly_data = {partial};
Plotly.newPlot("{id}", plotly_data.data, plotly_data.layout);
</script>
</div>
"""
SCRIPTS = """
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
"""
# pylint: disable=W0231
def __init__(
self,
tabular_data: "TabularData",
color_by: Optional[str] = None,
fill_value: str = "",
):
self.tabular_data = tabular_data
self.color_by = color_by
self.filename = "experiments"
self.fill_value = fill_value
def partial_html(self, **kwargs):
return self.as_json()
def as_json(self, **kwargs) -> str:
tabular_dict = defaultdict(list)
for row in self.tabular_data.as_dict():
for col_name, value in row.items():
tabular_dict[col_name].append(str(value))
trace: Dict[str, Any] = {"type": "parcoords", "dimensions": []}
for label, values in tabular_dict.items():
is_categorical = False
try:
float_values = [
float(x) if x != self.fill_value else None for x in values
]
except ValueError:
is_categorical = True
if is_categorical:
non_missing = [x for x in values if x != self.fill_value]
unique_values = sorted(set(non_missing))
unique_values.append(self.fill_value)
dummy_values = [unique_values.index(x) for x in values]
values = [
x if x != self.fill_value else "Missing" for x in values
]
trace["dimensions"].append(
{
"label": label,
"values": dummy_values,
"tickvals": dummy_values,
"ticktext": values,
}
)
else:
trace["dimensions"].append(
{"label": label, "values": float_values}
)
if label == self.color_by:
trace["line"] = {
"color": dummy_values if is_categorical else float_values,
"showscale": True,
"colorbar": {"title": self.color_by},
}
if is_categorical:
trace["line"]["colorbar"]["tickmode"] = "array"
trace["line"]["colorbar"]["tickvals"] = dummy_values
trace["line"]["colorbar"]["ticktext"] = values
return json.dumps({"data": [trace], "layout": {}})
-70
View File
@@ -1,70 +0,0 @@
import os.path
from typing import Dict, List
import dpath.util
def get_files(plots_data: Dict) -> List:
values = dpath.util.values(plots_data, ["*", "*"])
return sorted({key for submap in values for key in submap.keys()})
def group_by_filename(plots_data: Dict) -> List[Dict]:
files = get_files(plots_data)
grouped = []
for file in files:
grouped.append(dpath.util.search(plots_data, ["*", "*", file]))
return grouped
def squash_plots_properties(data: Dict) -> Dict:
resolved: Dict[str, str] = {}
for rev_data in data.values():
for file_data in rev_data.get("data", {}).values():
props = file_data.get("props", {})
resolved = {**resolved, **props}
return resolved
def match_renderers(plots_data, templates):
from dvc.render import RENDERERS
renderers = []
for group in group_by_filename(plots_data):
plot_properties = squash_plots_properties(group)
template = templates.load(plot_properties.get("template", None))
for renderer_class in RENDERERS:
if renderer_class.matches(group):
renderers.append(
renderer_class(
group, template=template, properties=plot_properties
)
)
return renderers
def render(
repo,
renderers,
metrics=None,
path=None,
html_template_path=None,
refresh_seconds=None,
):
if not html_template_path:
html_template_path = repo.config.get("plots", {}).get(
"html_template", None
)
if html_template_path and not os.path.isabs(html_template_path):
html_template_path = os.path.join(repo.dvc_dir, html_template_path)
from dvc.render.html import write
return write(
path,
renderers,
metrics=metrics,
template_path=html_template_path,
refresh_seconds=refresh_seconds,
)
-134
View File
@@ -1,134 +0,0 @@
import json
import os
from copy import deepcopy
from typing import Dict, List, Optional
from funcy import cached_property
from dvc.render.base import REVISION_FIELD, BadTemplateError, Renderer
from dvc.render.data import Converter, to_datapoints
from dvc.render.utils import get_files
from dvc.repo.plots.template import Template
def _flatten_datapoints(rev_datapoints: Dict[str, List[Dict]]) -> List[Dict]:
flat = []
for revision, datapoints in rev_datapoints.items():
datapoints_cp = deepcopy(datapoints)
Converter.update(datapoints_cp, {REVISION_FIELD: revision})
flat.extend(datapoints_cp)
return flat
class VegaRenderer(Renderer):
TYPE = "vega"
DIV = """
<div id = "{id}">
<script type = "text/javascript">
var spec = {partial};
vegaEmbed('#{id}', spec);
</script>
</div>
"""
SCRIPTS = """
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
"""
def __init__(
self, data: Dict, template: Template, properties: Dict = None
):
super().__init__(data)
self.properties = properties or {}
self.template = template
def _revisions(self):
return list(self.data.keys())
# TODO Shouldn't it be a part of plots?
def _fill_template(self, template, datapoints, props=None, fill_data=True):
props = props or {}
content = deepcopy(template.content)
if template.anchor_str("data") not in template.content:
anchor = template.anchor("data")
raise BadTemplateError(
f"Template '{template.name}' is not using '{anchor}' anchor"
)
if props.get("x"):
template.check_field_exists(datapoints, props.get("x"))
if props.get("y"):
template.check_field_exists(datapoints, props.get("y"))
if fill_data:
content = template.fill_anchor(content, "data", datapoints)
props.setdefault("title", "")
props.setdefault("x_label", props.get("x"))
props.setdefault("y_label", props.get("y"))
names = ["title", "x", "y", "x_label", "y_label"]
for name in names:
value = props.get(name)
if value is not None:
content = template.fill_anchor(content, name, value)
return content
@cached_property
def _converted_data(self):
return to_datapoints(self.data, self.properties)
def _get_filled_template(self, fill_data=True):
datapoints, final_props = self._converted_data
flat_datapoints = _flatten_datapoints(datapoints)
if datapoints:
filled_template = self._fill_template(
self.template,
flat_datapoints,
final_props,
fill_data=fill_data,
)
return filled_template
return ""
# TODO rename
def asdict(self, fill_data=True):
filled_template = self._get_filled_template(fill_data)
if filled_template:
return json.loads(filled_template)
return {}
def as_json(self, **kwargs) -> Optional[str]:
fill_data = kwargs.get("fill_data", True)
content = self.asdict(fill_data)
datapoints, _ = self._converted_data
return json.dumps(
[
{
self.TYPE_KEY: self.TYPE,
self.REVISIONS_KEY: self._revisions(),
"content": content,
"datapoints": datapoints,
}
],
)
def partial_html(self, **kwargs):
filled_template = self._get_filled_template()
return filled_template
@staticmethod
def matches(data):
files = get_files(data)
extensions = set(map(lambda f: os.path.splitext(f)[1], files))
return extensions.issubset({".yml", ".yaml", ".json", ".csv", ".tsv"})
@@ -5,7 +5,7 @@ from typing import Dict, List, Optional, Set, Union
from funcy import first, project
from dvc.exceptions import DvcException
from dvc.render.base import INDEX_FIELD, REVISION_FIELD
from dvc.render import INDEX_FIELD, REVISION_FIELD
class FieldsNotFoundError(DvcException):
@@ -76,7 +76,7 @@ def _append_index(datapoints: List[Dict]) -> List[Dict]:
return datapoints
class Converter:
class VegaConverter:
"""
Class that takes care of converting unspecified data blob
(Dict or List[Dict]) into datapoints (List[Dict]).
@@ -84,12 +84,6 @@ class Converter:
('x', 'y') it will attempt to fill in the blanks.
"""
@staticmethod
def update(datapoints: List[Dict], update_dict: Dict):
for data_point in datapoints:
data_point.update(update_dict)
return datapoints
def __init__(self, plot_properties: Optional[Dict] = None):
plot_properties = plot_properties or {}
self.props = deepcopy(plot_properties)
@@ -162,7 +156,7 @@ class Converter:
else:
self.inferred_props["y"] = inferred_y
def convert(self, data):
def convert(self, revision: str, filename: str, data: Dict):
"""
Convert the data. Fill necessary fields ('x', 'y') and return both
generated datapoints and updated properties.
@@ -174,20 +168,8 @@ class Converter:
self._infer_y(processed)
for datapoint in processed:
datapoint[REVISION_FIELD] = revision
datapoint["filename"] = filename
return processed, {**self.props, **self.inferred_props}
def to_datapoints(data: Dict, props: Dict):
converter = Converter(props)
datapoints: Dict[str, List[Dict]] = {}
for revision, rev_data in data.items():
datapoints[revision] = []
for _, file_data in rev_data.get("data", {}).items():
if "data" in file_data:
processed, final_props = converter.convert(
file_data.get("data")
)
datapoints[revision].extend(processed)
return datapoints, final_props
+9 -5
View File
@@ -1,9 +1,11 @@
import logging
import os
from typing import TYPE_CHECKING, List, Optional
from dvc_render import render_html
from funcy import once_per_args
from dvc.render.utils import match_renderers, render
from dvc.render.match import match_renderers
logger = logging.getLogger(__name__)
@@ -24,11 +26,13 @@ def create_summary(out):
metrics, plots = out.repo.live.show(out.fs_path)
html_path = out.fs_path + "_dvc_plots"
html_path = os.path.join(out.fs_path + "_dvc_plots", "index.html")
renderers = match_renderers(plots, out.repo.plots.templates)
index_path = render(
out.repo, renderers, metrics=metrics, path=html_path, refresh_seconds=5
renderers = match_renderers(
plots, templates_dir=out.repo.plots.templates_dir
)
index_path = render_html(
renderers, output_file=html_path, metrics=metrics, refresh_seconds=5
)
if out.repo.config["plots"].get("auto_open", False):
webbrowser_open(index_path)
+5 -5
View File
@@ -195,12 +195,14 @@ class Plots:
out.plot.pop(prop)
def modify(self, path, props=None, unset=None):
from dvc_render.vega_templates import get_template
from dvc.dvcfile import Dvcfile
props = props or {}
template = props.get("template")
if template:
self.templates.get_template(template)
get_template(template, self.templates_dir)
(out,) = self.repo.find_outs_by_path(path)
if not out.plot and unset is not None:
@@ -225,10 +227,8 @@ class Plots:
dvcfile.dump(out.stage, update_lock=False)
@cached_property
def templates(self):
from .template import PlotTemplates
return PlotTemplates(self.repo.dvc_dir)
def templates_dir(self):
return os.path.join(self.repo.dvc_dir, "plots")
def _is_plot(out: "Output") -> bool:
-595
View File
@@ -1,595 +0,0 @@
import json
import os
from typing import Any, Dict, List, Optional
from funcy import cached_property
from dvc.exceptions import DvcException
class TemplateNotFoundError(DvcException):
def __init__(self, path):
super().__init__(f"Template '{path}' not found.")
class NoFieldInDataError(DvcException):
def __init__(self, field_name):
super().__init__(
f"Field '{field_name}' does not exist in provided data."
)
class TemplateContentDoesNotMatch(DvcException):
def __init__(self, template_name: str, path: str):
super().__init__(
f"Template '{path}' already exists "
f"and its content is different than '{template_name}' content. "
"Remove it manually if you want to recreate it."
)
class Template:
INDENT = 4
SEPARATORS = (",", ": ")
EXTENSION = ".json"
ANCHOR = "<DVC_METRIC_{}>"
DEFAULT_CONTENT: Optional[Dict[str, Any]] = None
DEFAULT_NAME: Optional[str] = None
def __init__(self, content=None, name=None):
if content:
self.content = content
else:
self.content = (
json.dumps(
self.DEFAULT_CONTENT,
indent=self.INDENT,
separators=self.SEPARATORS,
)
+ "\n"
)
self.name = name or self.DEFAULT_NAME
assert self.content and self.name
self.filename = self.name + self.EXTENSION
@classmethod
def anchor(cls, name):
return cls.ANCHOR.format(name.upper())
def has_anchor(self, name):
return self.anchor_str(name) in self.content
@classmethod
def fill_anchor(cls, content, name, value):
value_str = json.dumps(
value, indent=cls.INDENT, separators=cls.SEPARATORS, sort_keys=True
)
return content.replace(cls.anchor_str(name), value_str)
@classmethod
def anchor_str(cls, name):
return f'"{cls.anchor(name)}"'
@staticmethod
def check_field_exists(data, field):
if not any(field in row for row in data):
raise NoFieldInDataError(field)
class SimpleLinearTemplate(Template):
DEFAULT_NAME = "simple"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"width": 300,
"height": 300,
"mark": {"type": "line"},
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
},
}
class ConfusionTemplate(Template):
DEFAULT_NAME = "confusion"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"facet": {"field": "rev", "type": "nominal"},
"spec": {
"transform": [
{
"aggregate": [{"op": "count", "as": "xy_count"}],
"groupby": [Template.anchor("y"), Template.anchor("x")],
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("y")],
"key": Template.anchor("x"),
"value": 0,
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("x")],
"key": Template.anchor("y"),
"value": 0,
},
{
"joinaggregate": [
{"op": "max", "field": "xy_count", "as": "max_count"}
],
"groupby": [],
},
{
"calculate": "datum.xy_count / datum.max_count",
"as": "percent_of_max",
},
],
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("y_label"),
},
},
"layer": [
{
"mark": "rect",
"width": 300,
"height": 300,
"encoding": {
"color": {
"field": "xy_count",
"type": "quantitative",
"title": "",
"scale": {"domainMin": 0, "nice": True},
}
},
},
{
"mark": "text",
"encoding": {
"text": {"field": "xy_count", "type": "quantitative"},
"color": {
"condition": {
"test": "datum.percent_of_max > 0.5",
"value": "white",
},
"value": "black",
},
},
},
],
},
}
class NormalizedConfusionTemplate(Template):
DEFAULT_NAME = "confusion_normalized"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"facet": {"field": "rev", "type": "nominal"},
"spec": {
"transform": [
{
"aggregate": [{"op": "count", "as": "xy_count"}],
"groupby": [Template.anchor("y"), Template.anchor("x")],
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("y")],
"key": Template.anchor("x"),
"value": 0,
},
{
"impute": "xy_count",
"groupby": ["rev", Template.anchor("x")],
"key": Template.anchor("y"),
"value": 0,
},
{
"joinaggregate": [
{"op": "sum", "field": "xy_count", "as": "sum_y"}
],
"groupby": [Template.anchor("y")],
},
{
"calculate": "datum.xy_count / datum.sum_y",
"as": "percent_of_y",
},
],
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "nominal",
"sort": "ascending",
"title": Template.anchor("y_label"),
},
},
"layer": [
{
"mark": "rect",
"width": 300,
"height": 300,
"encoding": {
"color": {
"field": "percent_of_y",
"type": "quantitative",
"title": "",
"scale": {"domain": [0, 1]},
}
},
},
{
"mark": "text",
"encoding": {
"text": {
"field": "percent_of_y",
"type": "quantitative",
"format": ".2f",
},
"color": {
"condition": {
"test": "datum.percent_of_y > 0.5",
"value": "white",
},
"value": "black",
},
},
},
],
},
}
class ScatterTemplate(Template):
DEFAULT_NAME = "scatter"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"width": 300,
"height": 300,
"layer": [
{
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
},
"layer": [
{"mark": "point"},
{
"selection": {
"label": {
"type": "single",
"nearest": True,
"on": "mouseover",
"encodings": ["x"],
"empty": "none",
"clear": "mouseout",
}
},
"mark": "point",
"encoding": {
"opacity": {
"condition": {
"selection": "label",
"value": 1,
},
"value": 0,
}
},
},
],
},
{
"transform": [{"filter": {"selection": "label"}}],
"layer": [
{
"encoding": {
"text": {
"type": "quantitative",
"field": Template.anchor("y"),
},
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
},
},
"layer": [
{
"mark": {
"type": "text",
"align": "left",
"dx": 5,
"dy": -5,
},
"encoding": {
"color": {
"type": "nominal",
"field": "rev",
}
},
}
],
}
],
},
],
}
class SmoothLinearTemplate(Template):
DEFAULT_NAME = "smooth"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"mark": {"type": "line"},
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
},
"transform": [
{
"loess": Template.anchor("y"),
"on": Template.anchor("x"),
"groupby": ["rev"],
"bandwidth": 0.3,
}
],
}
class LinearTemplate(Template):
DEFAULT_NAME = "linear"
DEFAULT_CONTENT = {
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"data": {"values": Template.anchor("data")},
"title": Template.anchor("title"),
"width": 300,
"height": 300,
"layer": [
{
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
"title": Template.anchor("x_label"),
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
"title": Template.anchor("y_label"),
"scale": {"zero": False},
},
"color": {"field": "rev", "type": "nominal"},
},
"layer": [
{"mark": "line"},
{
"selection": {
"label": {
"type": "single",
"nearest": True,
"on": "mouseover",
"encodings": ["x"],
"empty": "none",
"clear": "mouseout",
}
},
"mark": "point",
"encoding": {
"opacity": {
"condition": {
"selection": "label",
"value": 1,
},
"value": 0,
}
},
},
],
},
{
"transform": [{"filter": {"selection": "label"}}],
"layer": [
{
"mark": {"type": "rule", "color": "gray"},
"encoding": {
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
}
},
},
{
"encoding": {
"text": {
"type": "quantitative",
"field": Template.anchor("y"),
},
"x": {
"field": Template.anchor("x"),
"type": "quantitative",
},
"y": {
"field": Template.anchor("y"),
"type": "quantitative",
},
},
"layer": [
{
"mark": {
"type": "text",
"align": "left",
"dx": 5,
"dy": -5,
},
"encoding": {
"color": {
"type": "nominal",
"field": "rev",
}
},
}
],
},
],
},
],
}
class PlotTemplates:
TEMPLATES_DIR = "plots"
TEMPLATES = [
SimpleLinearTemplate,
LinearTemplate,
ConfusionTemplate,
NormalizedConfusionTemplate,
ScatterTemplate,
SmoothLinearTemplate,
]
@cached_property
def templates_dir(self):
return os.path.join(self.dvc_dir, self.TEMPLATES_DIR)
def get_template(self, template_name: str):
template_path = os.path.abspath(template_name)
if os.path.exists(template_path):
return os.path.abspath(template_path)
if self.dvc_dir and os.path.exists(self.dvc_dir):
template_path = os.path.join(self.templates_dir, template_name)
if os.path.exists(template_path):
return template_path
all_templates = [
os.path.join(root, file)
for root, _, files in os.walk(self.templates_dir)
for file in files
]
matches = [
template
for template in all_templates
if os.path.splitext(template)[0] == template_path
]
if matches:
assert len(matches) == 1
return matches[0]
raise TemplateNotFoundError(template_name)
def __init__(self, dvc_dir):
self.dvc_dir = dvc_dir
def init(
self, output: Optional[str] = None, targets: Optional[List] = None
):
from dvc.utils.fs import makedirs
output = output or self.templates_dir
makedirs(output, exist_ok=True)
if targets:
templates = [
template
for template in self.TEMPLATES
if template.DEFAULT_NAME in targets
]
else:
templates = self.TEMPLATES
for template in templates:
self._dump(template(), output)
def _dump(self, template: Template, output: str):
path = os.path.join(output, template.filename)
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as fd:
content = fd.read()
if content != template.content:
raise TemplateContentDoesNotMatch(
template.DEFAULT_NAME or "", path
)
else:
with open(path, "w", encoding="utf-8") as fd:
fd.write(template.content)
def load(self, template_name=None):
if not template_name:
template_name = "linear"
try:
path = self.get_template(template_name)
with open(path, "r", encoding="utf-8") as fd:
content = fd.read()
return Template(content, name=template_name)
except TemplateNotFoundError:
for template in self.TEMPLATES:
if template.DEFAULT_NAME == template_name:
return template()
raise
-107
View File
@@ -1,107 +0,0 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": "<DVC_METRIC_DATA>"
},
"title": "<DVC_METRIC_TITLE>",
"facet": {
"field": "rev",
"type": "nominal"
},
"spec": {
"transform": [
{
"aggregate": [
{
"op": "count",
"as": "xy_count"
}
],
"groupby": [
"<DVC_METRIC_Y>",
"<DVC_METRIC_X>"
]
},
{
"impute": "xy_count",
"groupby": [
"rev",
"<DVC_METRIC_Y>"
],
"key": "<DVC_METRIC_X>",
"value": 0
},
{
"impute": "xy_count",
"groupby": [
"rev",
"<DVC_METRIC_X>"
],
"key": "<DVC_METRIC_Y>",
"value": 0
},
{
"joinaggregate": [
{
"op": "max",
"field": "xy_count",
"as": "max_count"
}
],
"groupby": []
},
{
"calculate": "datum.xy_count / datum.max_count",
"as": "percent_of_max"
}
],
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "nominal",
"sort": "ascending",
"title": "<DVC_METRIC_X_LABEL>"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "nominal",
"sort": "ascending",
"title": "<DVC_METRIC_Y_LABEL>"
}
},
"layer": [
{
"mark": "rect",
"width": 300,
"height": 300,
"encoding": {
"color": {
"field": "xy_count",
"type": "quantitative",
"title": "",
"scale": {
"domainMin": 0,
"nice": true
}
}
}
},
{
"mark": "text",
"encoding": {
"text": {
"field": "xy_count",
"type": "quantitative"
},
"color": {
"condition": {
"test": "datum.percent_of_max > 0.5",
"value": "white"
},
"value": "black"
}
}
}
]
}
}
@@ -1,112 +0,0 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": "<DVC_METRIC_DATA>"
},
"title": "<DVC_METRIC_TITLE>",
"facet": {
"field": "rev",
"type": "nominal"
},
"spec": {
"transform": [
{
"aggregate": [
{
"op": "count",
"as": "xy_count"
}
],
"groupby": [
"<DVC_METRIC_Y>",
"<DVC_METRIC_X>"
]
},
{
"impute": "xy_count",
"groupby": [
"rev",
"<DVC_METRIC_Y>"
],
"key": "<DVC_METRIC_X>",
"value": 0
},
{
"impute": "xy_count",
"groupby": [
"rev",
"<DVC_METRIC_X>"
],
"key": "<DVC_METRIC_Y>",
"value": 0
},
{
"joinaggregate": [
{
"op": "sum",
"field": "xy_count",
"as": "sum_y"
}
],
"groupby": [
"<DVC_METRIC_Y>"
]
},
{
"calculate": "datum.xy_count / datum.sum_y",
"as": "percent_of_y"
}
],
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "nominal",
"sort": "ascending",
"title": "<DVC_METRIC_X_LABEL>"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "nominal",
"sort": "ascending",
"title": "<DVC_METRIC_Y_LABEL>"
}
},
"layer": [
{
"mark": "rect",
"width": 300,
"height": 300,
"encoding": {
"color": {
"field": "percent_of_y",
"type": "quantitative",
"title": "",
"scale": {
"domain": [
0,
1
]
}
}
}
},
{
"mark": "text",
"encoding": {
"text": {
"field": "percent_of_y",
"type": "quantitative",
"format": ".2f"
},
"color": {
"condition": {
"test": "datum.percent_of_y > 0.5",
"value": "white"
},
"value": "black"
}
}
}
]
}
}
-116
View File
@@ -1,116 +0,0 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": "<DVC_METRIC_DATA>"
},
"title": "<DVC_METRIC_TITLE>",
"width": 300,
"height": 300,
"layer": [
{
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative",
"title": "<DVC_METRIC_X_LABEL>"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "quantitative",
"title": "<DVC_METRIC_Y_LABEL>",
"scale": {
"zero": false
}
},
"color": {
"field": "rev",
"type": "nominal"
}
},
"layer": [
{
"mark": "line"
},
{
"selection": {
"label": {
"type": "single",
"nearest": true,
"on": "mouseover",
"encodings": [
"x"
],
"empty": "none",
"clear": "mouseout"
}
},
"mark": "point",
"encoding": {
"opacity": {
"condition": {
"selection": "label",
"value": 1
},
"value": 0
}
}
}
]
},
{
"transform": [
{
"filter": {
"selection": "label"
}
}
],
"layer": [
{
"mark": {
"type": "rule",
"stroke": "gray"
},
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative"
}
}
},
{
"encoding": {
"text": {
"type": "quantitative",
"field": "<DVC_METRIC_Y>"
},
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "quantitative"
}
},
"layer": [
{
"mark": {
"type": "text",
"align": "left",
"dx": 5,
"dy": -5
},
"encoding": {
"color": {
"type": "nominal",
"field": "rev"
}
}
}
]
}
]
}
]
}
-104
View File
@@ -1,104 +0,0 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": "<DVC_METRIC_DATA>"
},
"title": "<DVC_METRIC_TITLE>",
"width": 300,
"height": 300,
"layer": [
{
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative",
"title": "<DVC_METRIC_X_LABEL>"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "quantitative",
"title": "<DVC_METRIC_Y_LABEL>",
"scale": {
"zero": false
}
},
"color": {
"field": "rev",
"type": "nominal"
}
},
"layer": [
{
"mark": "point"
},
{
"selection": {
"label": {
"type": "single",
"nearest": true,
"on": "mouseover",
"encodings": [
"x"
],
"empty": "none",
"clear": "mouseout"
}
},
"mark": "point",
"encoding": {
"opacity": {
"condition": {
"selection": "label",
"value": 1
},
"value": 0
}
}
}
]
},
{
"transform": [
{
"filter": {
"selection": "label"
}
}
],
"layer": [
{
"encoding": {
"text": {
"type": "quantitative",
"field": "<DVC_METRIC_Y>"
},
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "quantitative"
}
},
"layer": [
{
"mark": {
"type": "text",
"align": "left",
"dx": 5,
"dy": -5
},
"encoding": {
"color": {
"type": "nominal",
"field": "rev"
}
}
}
]
}
]
}
]
}
-31
View File
@@ -1,31 +0,0 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": "<DVC_METRIC_DATA>"
},
"title": "<DVC_METRIC_TITLE>",
"width": 300,
"height": 300,
"mark": {
"type": "line"
},
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative",
"title": "<DVC_METRIC_X_LABEL>"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "quantitative",
"title": "<DVC_METRIC_Y_LABEL>",
"scale": {
"zero": false
}
},
"color": {
"field": "rev",
"type": "nominal"
}
}
}
-39
View File
@@ -1,39 +0,0 @@
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {
"values": "<DVC_METRIC_DATA>"
},
"title": "<DVC_METRIC_TITLE>",
"mark": {
"type": "line"
},
"encoding": {
"x": {
"field": "<DVC_METRIC_X>",
"type": "quantitative",
"title": "<DVC_METRIC_X_LABEL>"
},
"y": {
"field": "<DVC_METRIC_Y>",
"type": "quantitative",
"title": "<DVC_METRIC_Y_LABEL>",
"scale": {
"zero": false
}
},
"color": {
"field": "rev",
"type": "nominal"
}
},
"transform": [
{
"loess": "<DVC_METRIC_Y>",
"on": "<DVC_METRIC_X>",
"groupby": [
"rev"
],
"bandwidth": 0.3
}
]
}
+1
View File
@@ -76,6 +76,7 @@ install_requires =
diskcache>=5.2.1
jaraco.windows>=5.7.0; python_version < '3.8' and sys_platform == 'win32'
scmrepo==0.0.13
dvc_render==0.0.2
[options.extras_require]
all =
+3 -11
View File
@@ -1,3 +1,4 @@
import json
import os
import sys
from contextlib import suppress
@@ -164,19 +165,10 @@ def pytest_configure(config):
@pytest.fixture
def custom_template(tmp_dir, dvc):
try:
import importlib_resources
except ImportError:
import importlib.resources as importlib_resources
content = (
importlib_resources.files("dvc.repo.plots")
/ "templates"
/ "simple.json"
).read_text()
from dvc_render.vega_templates import SimpleLinearTemplate
template = tmp_dir / "custom_template.json"
template.write_text(content)
template.write_text(json.dumps(SimpleLinearTemplate.DEFAULT_CONTENT))
return template
+2 -1
View File
@@ -4,7 +4,6 @@ import pytest
from dvc.dvcfile import PIPELINE_LOCK
from dvc.repo.plots import PropsNotFoundError
from dvc.repo.plots.template import TemplateNotFoundError
from dvc.utils import relpath
@@ -47,6 +46,8 @@ def test_plots_modify_should_not_change_lockfile(
def test_plots_modify_not_existing_template(dvc):
from dvc_render.vega_templates import TemplateNotFoundError
with pytest.raises(TemplateNotFoundError):
dvc.plots.modify(
"metric.json", props={"template": "not-existing-template.json"}
+1 -1
View File
@@ -6,7 +6,7 @@ import pytest
from funcy import first
from dvc import stage as stage_module
from dvc.render.utils import get_files
from dvc.render.match import get_files
LIVE_SCRIPT = dedent(
"""
+1 -1
View File
@@ -862,5 +862,5 @@ def test_show_experiments_pcp(tmp_dir, mocker):
kwargs = td.to_parallel_coordinates.call_args[1]
assert kwargs["output_path"] == str(tmp_dir / "dvc_plots")
assert kwargs["output_path"] == str(tmp_dir / "dvc_plots" / "index.html")
assert kwargs["color_by"] == "Experiment"
+20 -63
View File
@@ -55,7 +55,7 @@ def test_plots_diff(dvc, mocker, plots_data):
cmd = cli_args.func(cli_args)
m = mocker.patch("dvc.repo.plots.diff.diff", return_value=plots_data)
render_mock = mocker.patch(
"dvc.render.utils.render", return_value="html_path"
"dvc_render.render_html", return_value="html_path"
)
assert cmd.run() == 0
@@ -100,7 +100,7 @@ def test_plots_show_vega(dvc, mocker, plots_data):
return_value=plots_data,
)
render_mock = mocker.patch(
"dvc.render.utils.render", return_value="html_path"
"dvc_render.render_html", return_value="html_path"
)
assert cmd.run() == 0
@@ -127,10 +127,10 @@ def test_plots_diff_vega(dvc, mocker, capsys, plots_data):
cmd = cli_args.func(cli_args)
mocker.patch("dvc.repo.plots.diff.diff", return_value=plots_data)
mocker.patch(
"dvc.render.VegaRenderer.asdict",
return_value={"this": "is vega json"},
"dvc_render.VegaRenderer.partial_html",
return_value=json.dumps({"this": "is vega json"}),
)
render_mock = mocker.patch("dvc.render.utils.render")
render_mock = mocker.patch("dvc_render.render_html")
assert cmd.run() == 0
out, _ = capsys.readouterr()
@@ -156,7 +156,7 @@ def test_plots_diff_open(tmp_dir, dvc, mocker, capsys, plots_data, auto_open):
mocker.patch("dvc.repo.plots.diff.diff", return_value=plots_data)
index_path = tmp_dir / "dvc_plots" / "index.html"
mocker.patch("dvc.render.utils.render", return_value=index_path)
mocker.patch("dvc_render.render_html", return_value=index_path)
assert cmd.run() == 0
mocked_open.assert_called_once_with(index_path.as_uri())
@@ -178,7 +178,7 @@ def test_plots_diff_open_WSL(tmp_dir, dvc, mocker, plots_data):
mocker.patch("dvc.repo.plots.diff.diff", return_value=plots_data)
index_path = tmp_dir / "dvc_plots" / "index.html"
mocker.patch("dvc.render.utils.render", return_value=index_path)
mocker.patch("dvc_render.render_html", return_value=index_path)
assert cmd.run() == 0
mocked_open.assert_called_once_with(str(Path("dvc_plots") / "index.html"))
@@ -253,9 +253,9 @@ def test_should_call_render(tmp_dir, mocker, capsys, plots_data, output):
output = output or "dvc_plots"
index_path = tmp_dir / output / "index.html"
renderers = mocker.MagicMock()
mocker.patch("dvc.render.utils.match_renderers", return_value=renderers)
mocker.patch("dvc.render.match.match_renderers", return_value=renderers)
render_mock = mocker.patch(
"dvc.render.utils.render", return_value=index_path
"dvc_render.render_html", return_value=index_path
)
assert cmd.run() == 0
@@ -264,7 +264,9 @@ def test_should_call_render(tmp_dir, mocker, capsys, plots_data, output):
assert index_path.as_uri() in out
render_mock.assert_called_once_with(
cmd.repo, renderers, path=tmp_dir / output, html_template_path=None
renderers=renderers,
output_file=Path(tmp_dir / output / "index.html"),
template_path=None,
)
@@ -289,66 +291,21 @@ def test_plots_diff_json(dvc, mocker, capsys):
mocker.patch("dvc.repo.plots.diff.diff", return_value=data)
renderers = mocker.MagicMock()
mocker.patch("dvc.render.utils.match_renderers", return_value=renderers)
render_mock = mocker.patch("dvc.render.utils.render")
mocker.patch("dvc.render.match.match_renderers", return_value=renderers)
render_mock = mocker.patch("dvc_render.render_html")
show_json_mock = mocker.patch("dvc.commands.plots._show_json")
assert cmd.run() == 0
show_json_mock.assert_called_once_with(renderers, "out", True)
show_json_mock.assert_called_once_with(renderers, True)
render_mock.assert_not_called()
def test_show_json_requires_out(dvc, mocker, capsys):
cli_args = parse_args(
[
"plots",
"diff",
"HEAD~10",
"HEAD~1",
"--json",
"--targets",
"plot.csv",
]
)
cmd = cli_args.func(cli_args)
mocker.patch("dvc.repo.plots.diff.diff")
renderer = mocker.MagicMock()
renderer.needs_output_path = False
renderer.filename = "foo"
renderer.as_json.return_value = "{}"
mocker.patch("dvc.render.utils.match_renderers", return_value=[renderer])
assert cmd.run() == 0
renderer.needs_output_path = True
assert cmd.run() == 1
cli_args = parse_args(
[
"plots",
"diff",
"HEAD~10",
"HEAD~1",
"--json",
"--targets",
"plot.csv",
"-o",
"out",
]
)
cmd = cli_args.func(cli_args)
assert cmd.run() == 0
@pytest.mark.parametrize("target", (("t1"), (None)))
def test_plots_templates(tmp_dir, dvc, mocker, capsys, target):
assert not os.path.exists(dvc.plots.templates.templates_dir)
assert not os.path.exists(dvc.plots.templates_dir)
mocker.patch(
"dvc.commands.plots.CmdPlotsTemplates.TEMPLATES_CHOICES",
["t1", "t2"],
@@ -361,21 +318,21 @@ def test_plots_templates(tmp_dir, dvc, mocker, capsys, target):
cli_args = parse_args(arguments)
assert cli_args.func == CmdPlotsTemplates
init_mock = mocker.patch("dvc.repo.plots.template.PlotTemplates.init")
dump_mock = mocker.patch("dvc_render.vega_templates.dump_templates")
cmd = cli_args.func(cli_args)
assert cmd.run() == 0
out, _ = capsys.readouterr()
init_mock.assert_called_once_with(
dump_mock.assert_called_once_with(
output=os.path.abspath("output"), targets=[target] if target else None
)
assert "Templates have been written into 'output'." in out
def test_plots_templates_choices(tmp_dir, dvc):
from dvc.repo.plots.template import PlotTemplates
from dvc_render import TEMPLATES
assert CmdPlotsTemplates.TEMPLATES_CHOICES == list(
pluck_attr("DEFAULT_NAME", PlotTemplates.TEMPLATES)
pluck_attr("DEFAULT_NAME", TEMPLATES)
)
+219
View File
@@ -0,0 +1,219 @@
from dvc.render import (
INDEX_FIELD,
REVISION_FIELD,
REVISIONS_KEY,
SRC_FIELD,
TYPE_KEY,
)
from dvc.render.convert import to_datapoints, to_json
def test_to_datapoints_single_revision(mocker):
renderer = mocker.MagicMock()
renderer.TYPE = "vega"
input_data = {
"revision": {
"data": {
"filename": {
"data": {
"metric": [
{"v": 1, "v2": 0.1, "v3": 0.01, "v4": 0.001},
{"v": 2, "v2": 0.2, "v3": 0.02, "v4": 0.002},
]
}
}
}
}
}
props = {"fields": {"v"}, "x": "v2", "y": "v3"}
datapoints, resolved_properties = to_datapoints(
renderer, input_data, props
)
assert datapoints == [
{
"v": 1,
"v2": 0.1,
"v3": 0.01,
"rev": "revision",
"filename": "filename",
},
{
"v": 2,
"v2": 0.2,
"v3": 0.02,
"rev": "revision",
"filename": "filename",
},
]
assert resolved_properties == {
"fields": {"v", "v2", "v3"},
"x": "v2",
"y": "v3",
}
def test_to_datapoints_revision_with_error(mocker):
renderer = mocker.MagicMock()
renderer.TYPE = "vega"
data = {
"v2": {
"data": {"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}}}
},
"workspace": {
"data": {"file.json": {"error": FileNotFoundError(), "props": {}}}
},
}
datapoints, final_props = to_datapoints(renderer, data, {})
assert datapoints == [
{
"y": 2,
INDEX_FIELD: 0,
REVISION_FIELD: "v2",
"filename": "file.json",
},
{
"y": 3,
INDEX_FIELD: 1,
REVISION_FIELD: "v2",
"filename": "file.json",
},
]
assert final_props == {"x": INDEX_FIELD, "y": "y"}
def test_to_datapoints_multiple_revisions(mocker):
renderer = mocker.MagicMock()
renderer.TYPE = "vega"
metric_1 = [{"y": 2}, {"y": 3}]
metric_2 = [{"y": 3}, {"y": 5}]
metric_3 = [{"y": 5}, {"y": 6}]
data = {
"HEAD": {
"data": {
"file.json": {"data": metric_3, "props": {"fields": {"y"}}}
}
},
"v2": {
"data": {
"file.json": {"data": metric_2, "props": {"fields": {"y"}}}
}
},
"v1": {
"data": {
"file.json": {"data": metric_1, "props": {"fields": {"y"}}}
}
},
}
props = {"fields": {"y"}}
datapoints, final_props = to_datapoints(renderer, data, props)
assert datapoints == [
{
"y": 5,
INDEX_FIELD: 0,
REVISION_FIELD: "HEAD",
"filename": "file.json",
},
{
"y": 6,
INDEX_FIELD: 1,
REVISION_FIELD: "HEAD",
"filename": "file.json",
},
{
"y": 3,
INDEX_FIELD: 0,
REVISION_FIELD: "v2",
"filename": "file.json",
},
{
"y": 5,
INDEX_FIELD: 1,
REVISION_FIELD: "v2",
"filename": "file.json",
},
{
"y": 2,
INDEX_FIELD: 0,
REVISION_FIELD: "v1",
"filename": "file.json",
},
{
"y": 3,
INDEX_FIELD: 1,
REVISION_FIELD: "v1",
"filename": "file.json",
},
]
assert final_props == {"x": INDEX_FIELD, "y": "y", "fields": {"y", "step"}}
def test_to_json_vega(mocker):
vega_renderer = mocker.MagicMock()
vega_renderer.TYPE = "vega"
vega_renderer.get_filled_template.return_value = '{"this": "is vega"}'
vega_renderer.datapoints = [
{"x": 1, "y": 2, REVISION_FIELD: "foo", "filename": "foo.json"},
{"x": 2, "y": 1, REVISION_FIELD: "bar", "filename": "foo.json"},
]
result = to_json(vega_renderer)
assert result[0] == {
TYPE_KEY: vega_renderer.TYPE,
REVISIONS_KEY: ["bar", "foo"],
"content": {"this": "is vega"},
"datapoints": {
"foo": [
{"x": 1, "y": 2, "filename": "foo.json"},
],
"bar": [
{"x": 2, "y": 1, "filename": "foo.json"},
],
},
}
vega_renderer.get_filled_template.assert_called()
def test_to_json_vega_split(mocker):
vega_renderer = mocker.MagicMock()
vega_renderer.TYPE = "vega"
vega_renderer.get_filled_template.return_value = (
'{"this": "is split vega"}'
)
vega_renderer.datapoints = [
{"x": 1, "y": 2, REVISION_FIELD: "foo", "filename": "foo.json"},
{"x": 2, "y": 1, REVISION_FIELD: "bar", "filename": "foo.json"},
]
result = to_json(vega_renderer, split=True)
assert result[0] == {
TYPE_KEY: vega_renderer.TYPE,
REVISIONS_KEY: ["bar", "foo"],
"content": {"this": "is split vega"},
"datapoints": {
"foo": [{"x": 1, "y": 2, "filename": "foo.json"}],
"bar": [{"x": 2, "y": 1, "filename": "foo.json"}],
},
}
vega_renderer.get_filled_template.assert_called_with(skip_anchors=["data"])
def test_to_json_image(mocker):
image_renderer = mocker.MagicMock()
image_renderer.TYPE = "image"
image_renderer.datapoints = [
{SRC_FIELD: "contentfoo", REVISION_FIELD: "foo"},
{SRC_FIELD: "contentbar", REVISION_FIELD: "bar"},
]
result = to_json(image_renderer)
assert result[0] == {
"url": image_renderer.datapoints[0].get(SRC_FIELD),
REVISIONS_KEY: image_renderer.datapoints[0].get(REVISION_FIELD),
TYPE_KEY: image_renderer.TYPE,
}
-47
View File
@@ -1,47 +0,0 @@
import pytest
from dvc.render.html import HTML, PAGE_HTML, MissingPlaceholderError
CUSTOM_PAGE_HTML = """<!DOCTYPE html>
<html>
<head>
<title>TITLE</title>
<script type="text/javascript" src="vega"></script>
<script type="text/javascript" src="vega-lite"></script>
<script type="text/javascript" src="vega-embed"></script>
</head>
<body>
{plot_divs}
</body>
</html>"""
@pytest.mark.parametrize(
"template,page_elements,expected_page",
[
(
None,
["content"],
PAGE_HTML.format(plot_divs="content", refresh_tag="", scripts=""),
),
(
CUSTOM_PAGE_HTML,
["content"],
CUSTOM_PAGE_HTML.format(plot_divs="content"),
),
],
)
def test_html(tmp_dir, template, page_elements, expected_page):
page = HTML(template)
page.elements = page_elements
result = page.embed()
assert result == expected_page
def test_no_placeholder(tmp_dir):
template = "<head></head><body></body>"
with pytest.raises(MissingPlaceholderError):
HTML(template)
-79
View File
@@ -1,79 +0,0 @@
import json
import os
import pytest
from dvc.render.image import ImageRenderer
@pytest.mark.parametrize(
"extension, matches",
(
(".csv", False),
(".json", False),
(".tsv", False),
(".yaml", False),
(".jpg", True),
(".gif", True),
(".jpeg", True),
(".png", True),
),
)
def test_matches(extension, matches):
filename = "file" + extension
data = {
"HEAD": {"data": {filename: {}}},
"v1": {"data": {filename: {}}},
}
assert ImageRenderer.matches(data) == matches
def test_render(tmp_dir):
data = {"workspace": {"data": {"file.jpg": {"data": b"content"}}}}
page_dir = os.path.join("some", "path")
html = ImageRenderer(data).generate_html(page_dir)
assert (tmp_dir / page_dir).is_dir()
image_file = tmp_dir / page_dir / "static" / "workspace_file.jpg"
assert image_file.is_file()
with open(image_file, "rb") as fobj:
assert fobj.read() == b"content"
assert "<p>file.jpg</p>" in html
assert (
f'<img src="{os.path.join("static", "workspace_file.jpg")}">' in html
)
def test_as_json(tmp_dir):
data = {
"workspace": {"data": {"file.jpg": {"data": b"content"}}},
"HEAD": {"data": {"file.jpg": {"data": b"content2"}}},
}
page_dir = os.path.join("some", "path")
json_content = json.loads(ImageRenderer(data).as_json(path=page_dir))
img1 = os.path.join(page_dir, "workspace_file.jpg")
img2 = os.path.join(page_dir, "HEAD_file.jpg")
assert {
"type": "image",
"revisions": ["workspace"],
"url": os.path.abspath(img1),
} in json_content
assert {
"type": "image",
"revisions": ["HEAD"],
"url": os.path.abspath(img2),
} in json_content
def assert_file_with_content(path, content):
assert os.path.isfile(path)
with open(path, "rb") as fd:
assert fd.read() == content
assert_file_with_content(img1, b"content")
assert_file_with_content(img2, b"content2")
+30
View File
@@ -0,0 +1,30 @@
from dvc.render import REVISION_FIELD, SRC_FIELD
from dvc.render.image_converter import ImageConverter
def test_image_converter_no_out():
data = b"content"
converter = ImageConverter()
datapoints, _ = converter.convert("r", "image.png", data)
assert datapoints[0] == {
REVISION_FIELD: "r",
"filename": "image.png",
SRC_FIELD: converter._encode_image(b"content"),
}
def test_image_converter_with_out(tmp_dir):
data = b"content"
converter = ImageConverter({"out": tmp_dir / "foo"})
datapoints, _ = converter.convert("r", "image.png", data)
assert datapoints[0] == {
REVISION_FIELD: "r",
"filename": "image.png",
SRC_FIELD: str(tmp_dir / "foo" / "r_image.png"),
}
assert (tmp_dir / "foo" / "r_image.png").read_bytes() == b"content"
+168
View File
@@ -0,0 +1,168 @@
from dvc.render.match import (
group_by_filename,
match_renderers,
squash_plots_properties,
)
def test_group_by_filename():
error = FileNotFoundError()
data = {
"v2": {
"data": {
"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}},
"other_file.jpg": {"data": "content"},
}
},
"v1": {
"data": {"file.json": {"data": [{"y": 4}, {"y": 5}], "props": {}}}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
"other_file.jpg": {"data": "content2"},
}
},
}
results = group_by_filename(data)
assert results["file.json"] == {
"v2": {
"data": {
"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}},
}
},
"v1": {
"data": {"file.json": {"data": [{"y": 4}, {"y": 5}], "props": {}}}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
}
},
}
assert results["other_file.jpg"] == {
"v2": {
"data": {
"other_file.jpg": {"data": "content"},
}
},
"workspace": {
"data": {
"other_file.jpg": {"data": "content2"},
}
},
}
def test_squash_plots_properties():
error = FileNotFoundError()
group = {
"v2": {
"data": {
"file.json": {
"data": [{"y": 2}, {"y": 3}],
"props": {"foo": 1},
},
}
},
"v1": {
"data": {
"file.json": {
"data": [{"y": 4}, {"y": 5}],
"props": {"bar": 1},
}
}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
}
},
}
plot_properties = squash_plots_properties(group)
assert plot_properties == {"foo": 1, "bar": 1}
def test_match_renderers_no_out(mocker):
from dvc import render
vega_convert = mocker.spy(render.vega_converter.VegaConverter, "convert")
image_convert = mocker.spy(
render.image_converter.ImageConverter, "convert"
)
image_encode = mocker.spy(
render.image_converter.ImageConverter, "_encode_image"
)
image_write = mocker.spy(
render.image_converter.ImageConverter, "_write_image"
)
error = FileNotFoundError()
data = {
"v2": {
"data": {
"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}},
"other_file.jpg": {"data": b"content"},
}
},
"v1": {
"data": {"file.json": {"data": [{"y": 4}, {"y": 5}], "props": {}}}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
"other_file.jpg": {"data": b"content2"},
}
},
}
renderers = match_renderers(data)
assert {r.TYPE for r in renderers} == {"vega", "image"}
vega_convert.assert_called()
image_convert.assert_called()
image_encode.assert_called()
image_write.assert_not_called()
def test_match_renderers_with_out(tmp_dir, mocker):
from dvc import render
image_encode = mocker.spy(
render.image_converter.ImageConverter, "_encode_image"
)
image_write = mocker.spy(
render.image_converter.ImageConverter, "_write_image"
)
error = FileNotFoundError()
data = {
"v2": {
"data": {
"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}},
"other_file.jpg": {"data": b"content"},
}
},
"v1": {
"data": {"file.json": {"data": [{"y": 4}, {"y": 5}], "props": {}}}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
"other_file.jpg": {"data": b"content2"},
}
},
}
match_renderers(data, out=tmp_dir / "foo")
image_encode.assert_not_called()
image_write.assert_called()
assert (tmp_dir / "foo" / "v2_other_file.jpg").read_bytes() == b"content"
assert (
tmp_dir / "foo" / "workspace_other_file.jpg"
).read_bytes() == b"content2"
@@ -1,156 +0,0 @@
import json
from dvc.compare import TabularData
from dvc.render.html import write
from dvc.render.plotly import ParallelCoordinatesRenderer
# pylint: disable=W1514
def expected_format(result):
assert "data" in result
assert "layout" in result
assert isinstance(result["data"], list)
assert result["data"][0]["type"] == "parcoords"
assert isinstance(result["data"][0]["dimensions"], list)
return True
def test_scalar_columns():
td = TabularData(["col-1", "col-2", "col-3"])
td.extend([["0.1", "1", ""], ["2", "0.2", "0"]])
renderer = ParallelCoordinatesRenderer(td)
result = json.loads(renderer.as_json())
assert expected_format(result)
assert result["data"][0]["dimensions"][0] == {
"label": "col-1",
"values": [0.1, 2.0],
}
assert result["data"][0]["dimensions"][1] == {
"label": "col-2",
"values": [1.0, 0.2],
}
assert result["data"][0]["dimensions"][2] == {
"label": "col-3",
"values": [None, 0],
}
def test_categorical_columns():
td = TabularData(["col-1", "col-2"])
td.extend([["foo", ""], ["bar", "foobar"], ["foo", ""]])
renderer = ParallelCoordinatesRenderer(td)
result = json.loads(renderer.as_json())
assert expected_format(result)
assert result["data"][0]["dimensions"][0] == {
"label": "col-1",
"values": [1, 0, 1],
"tickvals": [1, 0, 1],
"ticktext": ["foo", "bar", "foo"],
}
assert result["data"][0]["dimensions"][1] == {
"label": "col-2",
"values": [1, 0, 1],
"tickvals": [1, 0, 1],
"ticktext": ["Missing", "foobar", "Missing"],
}
def test_mixed_columns():
td = TabularData(["categorical", "scalar"])
td.extend([["foo", "0.1"], ["bar", "2"]])
renderer = ParallelCoordinatesRenderer(td)
result = json.loads(renderer.as_json())
assert expected_format(result)
assert result["data"][0]["dimensions"][0] == {
"label": "categorical",
"values": [1, 0],
"tickvals": [1, 0],
"ticktext": ["foo", "bar"],
}
assert result["data"][0]["dimensions"][1] == {
"label": "scalar",
"values": [0.1, 2.0],
}
def test_color_by_scalar():
td = TabularData(["categorical", "scalar"])
td.extend([["foo", "0.1"], ["bar", "2"]])
renderer = ParallelCoordinatesRenderer(td, color_by="scalar")
result = json.loads(renderer.as_json())
assert expected_format(result)
assert result["data"][0]["line"] == {
"color": [0.1, 2.0],
"showscale": True,
"colorbar": {"title": "scalar"},
}
def test_color_by_categorical():
td = TabularData(["categorical", "scalar"])
td.extend([["foo", "0.1"], ["bar", "2"]])
renderer = ParallelCoordinatesRenderer(td, color_by="categorical")
result = json.loads(renderer.as_json())
assert expected_format(result)
assert result["data"][0]["line"] == {
"color": [1, 0],
"showscale": True,
"colorbar": {
"title": "categorical",
"tickmode": "array",
"tickvals": [1, 0],
"ticktext": ["foo", "bar"],
},
}
def test_write_parallel_coordinates(tmp_dir):
td = TabularData(["categorical", "scalar"])
td.extend([["foo", "0.1"], ["bar", "2"]])
renderer = ParallelCoordinatesRenderer(td)
html_path = write(tmp_dir, renderers=[renderer])
html_text = html_path.read_text()
assert ParallelCoordinatesRenderer.SCRIPTS in html_text
div = ParallelCoordinatesRenderer.DIV.format(
id="plot_experiments", partial=renderer.as_json()
)
assert div in html_text
def test_fill_value():
td = TabularData(["categorical", "scalar"])
td.extend([["foo", "-"], ["-", "2"]])
renderer = ParallelCoordinatesRenderer(td, fill_value="-")
result = json.loads(renderer.as_json())
assert expected_format(result)
assert result["data"][0]["dimensions"][0] == {
"label": "categorical",
"values": [0, 1],
"tickvals": [0, 1],
"ticktext": ["foo", "Missing"],
}
assert result["data"][0]["dimensions"][1] == {
"label": "scalar",
"values": [None, 2.0],
}
-78
View File
@@ -1,78 +0,0 @@
import os
import dpath
from dvc.render.utils import match_renderers, render
from dvc.render.vega import VegaRenderer
def assert_website_has_image(page_path, revision, filename, image_content):
index_path = page_path / "index.html"
assert index_path.is_file()
index_content = index_path.read_text()
resources_filename = f"{revision}_{filename.replace(os.sep, '_')}"
image_path = page_path / "static" / resources_filename
assert image_path.is_file()
img_html = f'<img src="{os.path.join("static", resources_filename)}">'
assert img_html in index_content
with open(image_path, "rb") as fobj:
assert fobj.read() == image_content
def test_render(tmp_dir, dvc):
data = {
"HEAD": {
"data": {
"file.json": {
"data": [{"y": 5}, {"y": 6}],
"props": {"fields": {"y"}},
},
os.path.join("sub", "other_file.jpg"): {"data": b"content"},
}
},
"v2": {
"data": {
"file.json": {
"data": [{"y": 3}, {"y": 5}],
"props": {"fields": {"y"}},
},
"other_file.jpg": {"data": b"content2"},
}
},
"v1": {
"data": {
"some.csv": {
"data": [{"y": 2}, {"y": 3}],
"props": {"fields": {"y"}},
},
"another.gif": {"data": b"content3"},
}
},
}
renderers = match_renderers(data, dvc.plots.templates)
render(dvc, renderers, path=os.path.join("results", "dir"))
page_path = tmp_dir / "results" / "dir"
index_path = page_path / "index.html"
assert index_path.is_file()
assert_website_has_image(
page_path, "HEAD", os.path.join("sub", "other_file.jpg"), b"content"
)
assert_website_has_image(page_path, "v2", "other_file.jpg", b"content2")
assert_website_has_image(page_path, "v1", "another.gif", b"content3")
def clean(txt: str) -> str:
return txt.replace("\n", "").replace("\r", "").replace(" ", "")
def get_vega_string(data, filename):
file_data = dpath.util.search(data, ["*", "*", filename])
return VegaRenderer(
file_data, dvc.plots.templates.load()
).partial_html()
index_content = index_path.read_text()
assert clean(get_vega_string(data, "file.json")) in clean(index_content)
assert clean(get_vega_string(data, "some.csv")) in clean(index_content)
-9
View File
@@ -1,9 +0,0 @@
from dvc.render.base import Renderer
def test_remove_special_characters():
special_chars = r"!@#$%^&*()[]{};,<>?\/:.|`~=_+"
dirty = f"plot_name{special_chars}"
assert Renderer._remove_special_chars(dirty) == "plot_name" + "_" * len(
special_chars
)
-375
View File
@@ -1,375 +0,0 @@
import json
import os
from collections import OrderedDict
import pytest
from funcy import first
from dvc.render.base import BadTemplateError
from dvc.render.data import INDEX_FIELD, REVISION_FIELD
from dvc.render.utils import group_by_filename
from dvc.render.vega import VegaRenderer
from dvc.repo.plots.template import NoFieldInDataError
def test_group_plots_data():
error = FileNotFoundError()
data = {
"v2": {
"data": {
"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}},
"other_file.jpg": {"data": "content"},
}
},
"v1": {
"data": {"file.json": {"data": [{"y": 4}, {"y": 5}], "props": {}}}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
"other_file.jpg": {"data": "content2"},
}
},
}
results = group_by_filename(data)
assert {
"v2": {
"data": {
"file.json": {"data": [{"y": 2}, {"y": 3}], "props": {}},
}
},
"v1": {
"data": {"file.json": {"data": [{"y": 4}, {"y": 5}], "props": {}}}
},
"workspace": {
"data": {
"file.json": {"error": error, "props": {}},
}
},
} in results
assert {
"v2": {
"data": {
"other_file.jpg": {"data": "content"},
}
},
"workspace": {
"data": {
"other_file.jpg": {"data": "content2"},
}
},
} in results
def test_one_column(tmp_dir, dvc):
props = {
"x_label": "x_title",
"y_label": "y_title",
"title": "mytitle",
}
data = {
"workspace": {
"data": {"file.json": {"data": [{"val": 2}, {"val": 3}]}}
}
}
plot_content = VegaRenderer(
data, template=dvc.plots.templates.load(), properties=props
).asdict()
assert plot_content["title"] == "mytitle"
assert plot_content["data"]["values"] == [
{"val": 2, INDEX_FIELD: 0, REVISION_FIELD: "workspace"},
{"val": 3, INDEX_FIELD: 1, REVISION_FIELD: "workspace"},
]
assert (
first(plot_content["layer"])["encoding"]["x"]["field"] == INDEX_FIELD
)
assert first(plot_content["layer"])["encoding"]["y"]["field"] == "val"
assert first(plot_content["layer"])["encoding"]["x"]["title"] == "x_title"
assert first(plot_content["layer"])["encoding"]["y"]["title"] == "y_title"
def test_multiple_columns(tmp_dir, scm, dvc):
metric = [
OrderedDict([("first_val", 100), ("second_val", 100), ("val", 2)]),
OrderedDict([("first_val", 200), ("second_val", 300), ("val", 3)]),
]
data = {
"workspace": {"data": {"file.json": {"data": metric, "props": {}}}}
}
plot_content = VegaRenderer(data, dvc.plots.templates.load()).asdict()
assert plot_content["data"]["values"] == [
{
"val": 2,
INDEX_FIELD: 0,
REVISION_FIELD: "workspace",
"first_val": 100,
"second_val": 100,
},
{
"val": 3,
INDEX_FIELD: 1,
REVISION_FIELD: "workspace",
"first_val": 200,
"second_val": 300,
},
]
assert (
first(plot_content["layer"])["encoding"]["x"]["field"] == INDEX_FIELD
)
assert first(plot_content["layer"])["encoding"]["y"]["field"] == "val"
def test_choose_axes(tmp_dir, scm, dvc):
metric = [
OrderedDict([("first_val", 100), ("second_val", 100), ("val", 2)]),
OrderedDict([("first_val", 200), ("second_val", 300), ("val", 3)]),
]
props = {"x": "first_val", "y": "second_val"}
data = {
"workspace": {"data": {"file.json": {"data": metric, "props": props}}}
}
plot_content = VegaRenderer(
data, template=dvc.plots.templates.load(), properties=props
).asdict()
assert plot_content["data"]["values"] == [
{
"val": 2,
REVISION_FIELD: "workspace",
"first_val": 100,
"second_val": 100,
},
{
"val": 3,
REVISION_FIELD: "workspace",
"first_val": 200,
"second_val": 300,
},
]
assert (
first(plot_content["layer"])["encoding"]["x"]["field"] == "first_val"
)
assert (
first(plot_content["layer"])["encoding"]["y"]["field"] == "second_val"
)
def test_confusion(tmp_dir, dvc):
confusion_matrix = [
{"predicted": "B", "actual": "A"},
{"predicted": "A", "actual": "A"},
]
props = {"template": "confusion", "x": "predicted", "y": "actual"}
data = {"workspace": {"data": {"file.json": {"data": confusion_matrix}}}}
plot_content = VegaRenderer(
data, template=dvc.plots.templates.load("confusion"), properties=props
).asdict()
assert plot_content["data"]["values"] == [
{"predicted": "B", "actual": "A", REVISION_FIELD: "workspace"},
{"predicted": "A", "actual": "A", REVISION_FIELD: "workspace"},
]
assert plot_content["spec"]["transform"][0]["groupby"] == [
"actual",
"predicted",
]
assert plot_content["spec"]["encoding"]["x"]["field"] == "predicted"
assert plot_content["spec"]["encoding"]["y"]["field"] == "actual"
def test_multiple_revs_default(tmp_dir, scm, dvc):
metric_1 = [{"y": 2}, {"y": 3}]
metric_2 = [{"y": 3}, {"y": 5}]
metric_3 = [{"y": 5}, {"y": 6}]
data = {
"HEAD": {
"data": {
"file.json": {"data": metric_3, "props": {"fields": {"y"}}}
}
},
"v2": {
"data": {
"file.json": {"data": metric_2, "props": {"fields": {"y"}}}
}
},
"v1": {
"data": {
"file.json": {"data": metric_1, "props": {"fields": {"y"}}}
}
},
}
plot_content = VegaRenderer(data, dvc.plots.templates.load()).asdict()
assert plot_content["data"]["values"] == [
{"y": 5, INDEX_FIELD: 0, REVISION_FIELD: "HEAD"},
{"y": 6, INDEX_FIELD: 1, REVISION_FIELD: "HEAD"},
{"y": 3, INDEX_FIELD: 0, REVISION_FIELD: "v2"},
{"y": 5, INDEX_FIELD: 1, REVISION_FIELD: "v2"},
{"y": 2, INDEX_FIELD: 0, REVISION_FIELD: "v1"},
{"y": 3, INDEX_FIELD: 1, REVISION_FIELD: "v1"},
]
assert (
first(plot_content["layer"])["encoding"]["x"]["field"] == INDEX_FIELD
)
assert first(plot_content["layer"])["encoding"]["y"]["field"] == "y"
def test_metric_missing(tmp_dir, scm, dvc, caplog):
metric = [{"y": 2}, {"y": 3}]
data = {
"v2": {"data": {"file.json": {"data": metric, "props": {}}}},
"workspace": {
"data": {"file.json": {"error": FileNotFoundError(), "props": {}}}
},
}
plot_content = VegaRenderer(data, dvc.plots.templates.load()).asdict()
assert plot_content["data"]["values"] == [
{"y": 2, INDEX_FIELD: 0, REVISION_FIELD: "v2"},
{"y": 3, INDEX_FIELD: 1, REVISION_FIELD: "v2"},
]
assert (
first(plot_content["layer"])["encoding"]["x"]["field"] == INDEX_FIELD
)
assert first(plot_content["layer"])["encoding"]["y"]["field"] == "y"
def test_bad_template(tmp_dir, dvc):
metric = [{"val": 2}, {"val": 3}]
data = {"workspace": {"data": {"file.json": {"data": metric}}}}
from dvc.repo.plots.template import Template
with pytest.raises(BadTemplateError):
VegaRenderer(data, Template("name", "content")).asdict()
def test_plot_choose_columns(tmp_dir, scm, dvc, custom_template):
metric = [{"a": 1, "b": 2, "c": 3}, {"a": 2, "b": 3, "c": 4}]
props = {
"fields": {"b", "c"},
"x": "b",
"y": "c",
}
data = {
"workspace": {"data": {"file.json": {"data": metric, "props": props}}}
}
plot_content = VegaRenderer(
data,
template=dvc.plots.templates.load(os.fspath(custom_template)),
properties=props,
).asdict()
assert plot_content["data"]["values"] == [
{"b": 2, "c": 3, REVISION_FIELD: "workspace"},
{"b": 3, "c": 4, REVISION_FIELD: "workspace"},
]
assert plot_content["encoding"]["x"]["field"] == "b"
assert plot_content["encoding"]["y"]["field"] == "c"
def test_raise_on_wrong_field(tmp_dir, scm, dvc):
metric = [{"val": 2}, {"val": 3}]
props = {"x": "no_val"}
data = {"workspace": {"data": {"file.json": {"data": metric}}}}
with pytest.raises(NoFieldInDataError):
VegaRenderer(
data, template=dvc.plots.templates.load(), properties=props
).asdict()
@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):
filename = "file" + extension
data = {
"HEAD": {"data": {filename: {}}},
"v1": {"data": {filename: {}}},
}
assert VegaRenderer.matches(data) == matches
@pytest.mark.parametrize(
"template_path, target_name",
[
(os.path.join(".dvc", "plots", "template.json"), "template"),
(os.path.join(".dvc", "plots", "template.json"), "template.json"),
(
os.path.join(".dvc", "plots", "subdir", "template.json"),
os.path.join("subdir", "template.json"),
),
(
os.path.join(".dvc", "plots", "subdir", "template.json"),
os.path.join("subdir", "template"),
),
("template.json", "template.json"),
],
)
def test_should_resolve_template(tmp_dir, dvc, template_path, target_name):
os.makedirs(os.path.abspath(os.path.dirname(template_path)), exist_ok=True)
with open(template_path, "w", encoding="utf-8") as fd:
fd.write("template_content")
assert dvc.plots.templates.get_template(target_name) == os.path.abspath(
template_path
)
# TODO remove content checks
def test_as_json(tmp_dir, scm, dvc):
metric = [{"a": 1, "b": 2, "c": 3}, {"a": 2, "b": 3, "c": 4}]
data = {"workspace": {"data": {"file.json": {"data": metric}}}}
props = {"fields": {"b"}}
renderer = VegaRenderer(
data, template=dvc.plots.templates.load(), properties=props
)
plot_content = renderer.asdict()
plot_as_json = first(json.loads(renderer.as_json()))
assert plot_as_json["type"] == "vega"
assert plot_as_json["revisions"] == ["workspace"]
assert plot_as_json["content"] == plot_content
def test_as_json_split(tmp_dir, scm, dvc):
metric = [{"a": 1, "b": 2, "c": 3}, {"a": 2, "b": 3, "c": 4}]
data = {"workspace": {"data": {"file.json": {"data": metric}}}}
props = {"fields": {"b"}}
renderer = VegaRenderer(
data, template=dvc.plots.templates.load(), properties=props
)
plot_content = renderer.asdict(fill_data=False)
plot_as_json = first(json.loads(renderer.as_json(fill_data=False)))
assert plot_as_json["type"] == "vega"
assert plot_as_json["revisions"] == ["workspace"]
assert plot_as_json["content"] == plot_content
assert plot_as_json["datapoints"] == renderer._converted_data[0]
@@ -2,13 +2,13 @@ from collections import OrderedDict
import pytest
from dvc.render.data import (
Converter,
from dvc.render.vega_converter import (
FieldsNotFoundError,
PlotDataStructureError,
VegaConverter,
_filter_fields,
_find_first_list,
_lists,
to_datapoints,
)
@@ -20,6 +20,9 @@ def test_find_first_list_in_dict():
assert _find_first_list(dmetric, fields=set()) == m1
assert _find_first_list(dmetric, fields={"x"}) == m2
with pytest.raises(PlotDataStructureError):
_find_first_list(dmetric, fields={"foo"})
def test_filter_fields():
m = [{"accuracy": 1, "loss": 2}, {"accuracy": 3, "loss": 4}]
@@ -57,14 +60,20 @@ def test_finding_lists(dictionary, expected_result):
# default x and y
{"metric": [{"v": 1}, {"v": 2}]},
{},
[{"v": 1, "step": 0}, {"v": 2, "step": 1}],
[
{"v": 1, "step": 0, "filename": "f", "rev": "r"},
{"v": 2, "step": 1, "filename": "f", "rev": "r"},
],
{"x": "step", "y": "v"},
),
(
# filter fields
{"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]},
{"fields": {"v"}},
[{"v": 1, "step": 0}, {"v": 2, "step": 1}],
[
{"v": 1, "step": 0, "filename": "f", "rev": "r"},
{"v": 2, "step": 1, "filename": "f", "rev": "r"},
],
{
"x": "step",
"y": "v",
@@ -75,7 +84,10 @@ def test_finding_lists(dictionary, expected_result):
# choose x and y
{"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]},
{"x": "v", "y": "v2"},
[{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}],
[
{"v": 1, "v2": 0.1, "filename": "f", "rev": "r"},
{"v": 2, "v2": 0.2, "filename": "f", "rev": "r"},
],
{"x": "v", "y": "v2"},
),
(
@@ -88,8 +100,8 @@ def test_finding_lists(dictionary, expected_result):
},
{"x": "v3", "y": "v4", "fields": {"v"}},
[
{"v": 1, "v3": 0.01, "v4": 0.001},
{"v": 2, "v3": 0.02, "v4": 0.002},
{"v": 1, "v3": 0.01, "v4": 0.001, "filename": "f", "rev": "r"},
{"v": 2, "v3": 0.02, "v4": 0.002, "filename": "f", "rev": "r"},
],
{"x": "v3", "y": "v4", "fields": {"v", "v3", "v4"}},
),
@@ -104,60 +116,41 @@ def test_finding_lists(dictionary, expected_result):
},
},
{"x": "v", "y": "v2"},
[{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}],
[
{"v": 1, "v2": 0.1, "filename": "f", "rev": "r"},
{"v": 2, "v2": 0.2, "filename": "f", "rev": "r"},
],
{"x": "v", "y": "v2"},
),
],
)
def test_convert(
input_data, properties, expected_datapoints, expected_properties
input_data,
properties,
expected_datapoints,
expected_properties,
):
converter = Converter(properties)
datapoints, resolved_properties = converter.convert(input_data)
converter = VegaConverter(properties)
datapoints, resolved_properties = converter.convert(
revision="r", filename="f", data=input_data
)
assert datapoints == expected_datapoints
assert resolved_properties == expected_properties
def test_convert_skip_step():
converter = Converter()
converter = VegaConverter()
converter.skip_step("append_index")
datapoints, resolved_properties = converter.convert(
{"a": "b", "metric": [{"v": 1}, {"v": 2}]}
revision="r",
filename="f",
data={"a": "b", "metric": [{"v": 1}, {"v": 2}]},
)
assert datapoints == [{"v": 1}, {"v": 2}]
assert datapoints == [
{"v": 1, "filename": "f", "rev": "r"},
{"v": 2, "filename": "f", "rev": "r"},
]
assert resolved_properties == {"x": "step", "y": "v"}
def test_to_datapoints():
input_data = {
"revision": {
"data": {
"filename": {
"data": {
"metric": [
{"v": 1, "v2": 0.1, "v3": 0.01, "v4": 0.001},
{"v": 2, "v2": 0.2, "v3": 0.02, "v4": 0.002},
]
}
}
}
}
}
props = {"fields": {"v"}, "x": "v2", "y": "v3"}
datapoints, resolved_properties = to_datapoints(input_data, props)
assert datapoints == {
"revision": [
{"v": 1, "v2": 0.1, "v3": 0.01},
{"v": 2, "v2": 0.2, "v3": 0.02},
]
}
assert resolved_properties == {
"fields": {"v", "v2", "v3"},
"x": "v2",
"y": "v3",
}
-73
View File
@@ -1,73 +0,0 @@
import os
import pytest
from dvc.repo.plots.template import (
LinearTemplate,
PlotTemplates,
ScatterTemplate,
TemplateContentDoesNotMatch,
TemplateNotFoundError,
)
def test_raise_on_no_template(tmp_dir, dvc):
with pytest.raises(TemplateNotFoundError):
dvc.plots.templates.load("non_existing_template.json")
@pytest.mark.parametrize(
"template_path, target_name",
[
(os.path.join(".dvc", "plots", "template.json"), "template"),
(os.path.join(".dvc", "plots", "template.json"), "template.json"),
(
os.path.join(".dvc", "plots", "subdir", "template.json"),
os.path.join("subdir", "template.json"),
),
(
os.path.join(".dvc", "plots", "subdir", "template.json"),
os.path.join("subdir", "template"),
),
("template.json", "template.json"),
],
)
def test_load_template(tmp_dir, dvc, template_path, target_name):
os.makedirs(os.path.abspath(os.path.dirname(template_path)), exist_ok=True)
with open(template_path, "w", encoding="utf-8") as fd:
fd.write("template_content")
assert dvc.plots.templates.load(target_name).content == "template_content"
def test_load_default_template(tmp_dir, dvc):
assert dvc.plots.templates.load(None).content == LinearTemplate().content
@pytest.mark.parametrize("output", ("output", None))
@pytest.mark.parametrize(
"targets,expected_templates",
(
([None, PlotTemplates.TEMPLATES]),
(["linear", "scatter"], [ScatterTemplate, LinearTemplate]),
),
)
def test_init(tmp_dir, dvc, output, targets, expected_templates):
output = output or dvc.plots.templates.templates_dir
dvc.plots.templates.init(output, targets)
assert set(os.listdir(output)) == {
cls.DEFAULT_NAME + ".json" for cls in expected_templates
}
def test_raise_on_init_modified(tmp_dir, dvc):
dvc.plots.templates.init(output=None, targets=["linear"])
with open(
tmp_dir / ".dvc" / "plots" / "linear.json", "a", encoding="utf-8"
) as fd:
fd.write("modification")
with pytest.raises(TemplateContentDoesNotMatch):
dvc.plots.templates.init(output=None, targets=["linear"])
+1 -1
View File
@@ -1,7 +1,7 @@
import json
import os
from dvc.render.utils import get_files
from dvc.render.match import get_files
def test_plots_order(tmp_dir, dvc):
+6 -4
View File
@@ -383,14 +383,16 @@ def test_to_parallel_coordinates(tmp_dir, mocker):
td = TabularData(["categorical", "scalar"])
td.extend([["foo", "0.1"], ["bar", "2"]])
write = mocker.patch("dvc.render.html.write")
write = mocker.patch("dvc_render.html.render_html")
renderer_class = mocker.patch(
"dvc.render.plotly.ParallelCoordinatesRenderer"
"dvc_render.plotly.ParallelCoordinatesRenderer"
)
renderer = renderer_class.return_value
td.to_parallel_coordinates(output_path="foo")
renderer_class.assert_called_with(td, None, td._fill_value)
renderer_class.assert_called_with(
td.as_dict(), color_by=None, fill_value=td._fill_value
)
write.assert_called_with("foo", renderers=[renderer])
write.assert_called_with(renderers=[renderer], output_file="foo")