mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
committed by
Paweł Redzyński
parent
c8903dc3a6
commit
4a1985d7cd
+3
-32
@@ -1,33 +1,4 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Dict
|
||||
from .image import ImageRenderer
|
||||
from .vega import VegaRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.types import StrPath
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Renderer:
|
||||
def __init__(self, data: Dict):
|
||||
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 _convert(self, path: "StrPath"):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def DIV(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_html(self, path: "StrPath"):
|
||||
"""this method might edit content of path"""
|
||||
partial = self._convert(path)
|
||||
div_id = f"plot_{self.filename.replace('.', '_').replace('/', '_')}"
|
||||
return self.DIV.format(id=div_id, partial=partial)
|
||||
RENDERERS = [ImageRenderer, VegaRenderer]
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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):
|
||||
def __init__(self, data: Dict, templates=None):
|
||||
self.data = data
|
||||
self.templates = templates
|
||||
|
||||
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 _convert(self, path: "StrPath"):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def DIV(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def SCRIPTS(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@abc.abstractmethod
|
||||
def as_json(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def generate_html(self, path: "StrPath"):
|
||||
"""this method might edit content of path"""
|
||||
partial = self._convert(path)
|
||||
div_id = f"plot_{self.filename.replace('.', '_').replace('/', '_')}"
|
||||
return self.DIV.format(id=div_id, partial=partial)
|
||||
+16
-9
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
from dvc.exceptions import DvcException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.render import Renderer
|
||||
from dvc.render.base import Renderer
|
||||
from dvc.types import StrPath
|
||||
|
||||
PAGE_HTML = """<!DOCTYPE html>
|
||||
@@ -13,9 +13,7 @@ PAGE_HTML = """<!DOCTYPE html>
|
||||
<head>
|
||||
{refresh_tag}
|
||||
<title>DVC Plot</title>
|
||||
<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>
|
||||
{scripts}
|
||||
</head>
|
||||
<body>
|
||||
{plot_divs}
|
||||
@@ -29,8 +27,9 @@ class MissingPlaceholderError(DvcException):
|
||||
|
||||
|
||||
class HTML:
|
||||
PLACEHOLDER = "plot_divs"
|
||||
PLACEHOLDER_FORMAT_STR = f"{{{PLACEHOLDER}}}"
|
||||
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="{}">'
|
||||
|
||||
@@ -40,11 +39,12 @@ class HTML:
|
||||
refresh_seconds: Optional[int] = None,
|
||||
):
|
||||
template = template or PAGE_HTML
|
||||
if self.PLACEHOLDER_FORMAT_STR not in template:
|
||||
raise MissingPlaceholderError(self.PLACEHOLDER_FORMAT_STR)
|
||||
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)
|
||||
@@ -65,13 +65,19 @@ class HTML:
|
||||
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.PLACEHOLDER: "\n".join(self.elements),
|
||||
self.SCRIPTS_PLACEHOLDER: self.scripts,
|
||||
self.PLOTS_PLACEHOLDER: "\n".join(self.elements),
|
||||
self.REFRESH_PLACEHOLDER: self.refresh_tag,
|
||||
}
|
||||
return self.template.format(**kwargs)
|
||||
@@ -98,6 +104,7 @@ def write(
|
||||
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"))
|
||||
|
||||
+6
-1
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dvc.render import Renderer
|
||||
from dvc.render.base import Renderer
|
||||
from dvc.render.utils import get_files
|
||||
from dvc.utils import relpath
|
||||
|
||||
@@ -17,6 +17,8 @@ class ImageRenderer(Renderer):
|
||||
{partial}
|
||||
</div>"""
|
||||
|
||||
SCRIPTS = ""
|
||||
|
||||
def _write_image(
|
||||
self,
|
||||
page_dir_path: "StrPath",
|
||||
@@ -56,6 +58,9 @@ class ImageRenderer(Renderer):
|
||||
return "\n".join(div_content)
|
||||
return ""
|
||||
|
||||
def as_json(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@staticmethod
|
||||
def matches(data):
|
||||
files = get_files(data)
|
||||
|
||||
+5
-7
@@ -26,20 +26,18 @@ def find_vega(repo, plots_data, target):
|
||||
from dvc.render.vega import VegaRenderer
|
||||
|
||||
if found and VegaRenderer.matches(found):
|
||||
return VegaRenderer(found, repo.plots.templates).get_vega()
|
||||
return VegaRenderer(found, repo.plots.templates).as_json()
|
||||
return ""
|
||||
|
||||
|
||||
def match_renderers(plots_data, templates):
|
||||
from dvc.render.image import ImageRenderer
|
||||
from dvc.render.vega import VegaRenderer
|
||||
from dvc.render import RENDERERS
|
||||
|
||||
renderers = []
|
||||
for g in group_by_filename(plots_data):
|
||||
if VegaRenderer.matches(g):
|
||||
renderers.append(VegaRenderer(g, templates))
|
||||
if ImageRenderer.matches(g):
|
||||
renderers.append(ImageRenderer(g))
|
||||
for renderer_class in RENDERERS:
|
||||
if renderer_class.matches(g):
|
||||
renderers.append(renderer_class(g, templates))
|
||||
return renderers
|
||||
|
||||
|
||||
|
||||
+46
-19
@@ -5,24 +5,17 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
||||
from funcy import first
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.render import Renderer
|
||||
from dvc.render.base import (
|
||||
INDEX_FIELD,
|
||||
REVISION_FIELD,
|
||||
BadTemplateError,
|
||||
Renderer,
|
||||
)
|
||||
from dvc.render.utils import get_files
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo.plots.template import PlotTemplates
|
||||
from dvc.types import StrPath
|
||||
|
||||
REVISION_FIELD = "rev"
|
||||
INDEX_FIELD = "step"
|
||||
|
||||
|
||||
class PlotMetricTypeError(DvcException):
|
||||
def __init__(self, file):
|
||||
super().__init__(
|
||||
"'{}' - file type error\n"
|
||||
"Only JSON, YAML, CSV and TSV formats are supported.".format(file)
|
||||
)
|
||||
|
||||
|
||||
class PlotDataStructureError(DvcException):
|
||||
def __init__(self):
|
||||
@@ -108,9 +101,11 @@ class VegaRenderer(Renderer):
|
||||
</div>
|
||||
"""
|
||||
|
||||
def __init__(self, data: Dict, templates: "PlotTemplates"):
|
||||
super().__init__(data)
|
||||
self.templates = templates
|
||||
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 _squash_props(self) -> Dict:
|
||||
resolved: Dict[str, str] = {}
|
||||
@@ -144,7 +139,36 @@ class VegaRenderer(Renderer):
|
||||
datapoints.extend(tmp)
|
||||
return datapoints
|
||||
|
||||
def get_vega(self) -> Optional[str]:
|
||||
def fill_template(self, template, datapoints, props=None):
|
||||
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"))
|
||||
|
||||
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
|
||||
|
||||
def as_json(self) -> Optional[str]:
|
||||
props = self._squash_props()
|
||||
|
||||
template = self.templates.load(props.get("template", None))
|
||||
@@ -162,11 +186,14 @@ class VegaRenderer(Renderer):
|
||||
props["y"] = first(
|
||||
f for f in reversed(fields) if f not in skip
|
||||
)
|
||||
return template.render(datapoints, props=props)
|
||||
filled_template = self.fill_template(template, datapoints, props)
|
||||
|
||||
return filled_template
|
||||
|
||||
return None
|
||||
|
||||
def _convert(self, path: "StrPath"):
|
||||
return self.get_vega()
|
||||
return self.as_json()
|
||||
|
||||
@staticmethod
|
||||
def matches(data):
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Callable, Dict, Generator, List, Optional
|
||||
from funcy import cached_property, first, project
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.render.vega import PlotMetricTypeError
|
||||
from dvc.utils import (
|
||||
error_handler,
|
||||
errored_revisions,
|
||||
@@ -24,6 +23,14 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlotMetricTypeError(DvcException):
|
||||
def __init__(self, file):
|
||||
super().__init__(
|
||||
"'{}' - file type error\n"
|
||||
"Only JSON, YAML, CSV and TSV formats are supported.".format(file)
|
||||
)
|
||||
|
||||
|
||||
class NotAPlotError(DvcException):
|
||||
def __init__(self, out):
|
||||
super().__init__(
|
||||
|
||||
@@ -20,10 +20,6 @@ class TemplateNotFoundError(DvcException):
|
||||
super().__init__(f"Template '{name}' not found.")
|
||||
|
||||
|
||||
class BadTemplateError(DvcException):
|
||||
pass
|
||||
|
||||
|
||||
class NoFieldInDataError(DvcException):
|
||||
def __init__(self, field_name):
|
||||
super().__init__(
|
||||
@@ -41,59 +37,26 @@ class Template:
|
||||
self.content = content
|
||||
self.name = name
|
||||
|
||||
def render(self, data, props=None):
|
||||
props = props or {}
|
||||
|
||||
if self._anchor_str("data") not in self.content:
|
||||
anchor = self.anchor("data")
|
||||
raise BadTemplateError(
|
||||
f"Template '{self.name}' is not using '{anchor}' anchor"
|
||||
)
|
||||
|
||||
if props.get("x"):
|
||||
Template._check_field_exists(data, props.get("x"))
|
||||
if props.get("y"):
|
||||
Template._check_field_exists(data, props.get("y"))
|
||||
|
||||
content = self._fill_anchor(self.content, "data", data)
|
||||
content = self._fill_metadata(content, props)
|
||||
|
||||
return content
|
||||
|
||||
@classmethod
|
||||
def anchor(cls, name):
|
||||
return cls.ANCHOR.format(name.upper())
|
||||
|
||||
def has_anchor(self, name):
|
||||
return self._anchor_str(name) in self.content
|
||||
return self.anchor_str(name) in self.content
|
||||
|
||||
@classmethod
|
||||
def _fill_anchor(cls, content, name, value):
|
||||
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)
|
||||
return content.replace(cls.anchor_str(name), value_str)
|
||||
|
||||
@classmethod
|
||||
def _anchor_str(cls, name):
|
||||
def anchor_str(cls, name):
|
||||
return f'"{cls.anchor(name)}"'
|
||||
|
||||
@classmethod
|
||||
def _fill_metadata(cls, content, props):
|
||||
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 = cls._fill_anchor(content, name, value)
|
||||
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
def _check_field_exists(data, field):
|
||||
def check_field_exists(data, field):
|
||||
if not any(field in row for row in data):
|
||||
raise NoFieldInDataError(field)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ from funcy import get_in
|
||||
from dvc.dvcfile import PIPELINE_FILE
|
||||
from dvc.exceptions import OverlappingOutputPathsError
|
||||
from dvc.main import main
|
||||
from dvc.render.vega import PlotMetricTypeError
|
||||
from dvc.repo import Repo
|
||||
from dvc.repo.plots import PlotMetricTypeError
|
||||
from dvc.utils import onerror_collect
|
||||
from dvc.utils.fs import remove
|
||||
from dvc.utils.serialize import EncodingError, YAMLFileCorruptedError
|
||||
|
||||
@@ -22,7 +22,7 @@ CUSTOM_PAGE_HTML = """<!DOCTYPE html>
|
||||
(
|
||||
None,
|
||||
["content"],
|
||||
PAGE_HTML.format(plot_divs="content", refresh_tag=""),
|
||||
PAGE_HTML.format(plot_divs="content", refresh_tag="", scripts=""),
|
||||
),
|
||||
(
|
||||
CUSTOM_PAGE_HTML,
|
||||
|
||||
@@ -9,15 +9,12 @@ from dvc.render.utils import find_vega, group_by_filename
|
||||
from dvc.render.vega import (
|
||||
INDEX_FIELD,
|
||||
REVISION_FIELD,
|
||||
BadTemplateError,
|
||||
VegaRenderer,
|
||||
_find_data,
|
||||
_lists,
|
||||
)
|
||||
from dvc.repo.plots.template import (
|
||||
BadTemplateError,
|
||||
NoFieldInDataError,
|
||||
TemplateNotFoundError,
|
||||
)
|
||||
from dvc.repo.plots.template import NoFieldInDataError, TemplateNotFoundError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -110,7 +107,7 @@ def test_one_column(tmp_dir, scm, dvc):
|
||||
}
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["title"] == "mytitle"
|
||||
@@ -136,7 +133,7 @@ def test_multiple_columns(tmp_dir, scm, dvc):
|
||||
"workspace": {"data": {"file.json": {"data": metric, "props": {}}}}
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -172,7 +169,7 @@ def test_choose_axes(tmp_dir, scm, dvc):
|
||||
data = {
|
||||
"workspace": {"data": {"file.json": {"data": metric, "props": props}}}
|
||||
}
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -210,7 +207,7 @@ def test_confusion(tmp_dir, dvc):
|
||||
}
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -248,7 +245,7 @@ def test_multiple_revs_default(tmp_dir, scm, dvc):
|
||||
},
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -274,7 +271,7 @@ def test_metric_missing(tmp_dir, scm, dvc, caplog):
|
||||
"data": {"file.json": {"error": FileNotFoundError(), "props": {}}}
|
||||
},
|
||||
}
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -294,7 +291,7 @@ def test_custom_template(tmp_dir, scm, dvc, custom_template):
|
||||
"workspace": {"data": {"file.json": {"data": metric, "props": props}}}
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -313,7 +310,7 @@ def test_raise_on_no_template(tmp_dir, dvc):
|
||||
}
|
||||
|
||||
with pytest.raises(TemplateNotFoundError):
|
||||
VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
|
||||
def test_bad_template(tmp_dir, dvc):
|
||||
@@ -325,7 +322,7 @@ def test_bad_template(tmp_dir, dvc):
|
||||
}
|
||||
|
||||
with pytest.raises(BadTemplateError):
|
||||
VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
|
||||
def test_plot_choose_columns(tmp_dir, scm, dvc, custom_template):
|
||||
@@ -340,7 +337,7 @@ def test_plot_choose_columns(tmp_dir, scm, dvc, custom_template):
|
||||
"workspace": {"data": {"file.json": {"data": metric, "props": props}}}
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
plot_content = json.loads(plot_string)
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -359,7 +356,7 @@ def test_plot_default_choose_column(tmp_dir, scm, dvc):
|
||||
}
|
||||
}
|
||||
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
plot_string = VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
plot_content = json.loads(plot_string)
|
||||
|
||||
assert plot_content["data"]["values"] == [
|
||||
@@ -381,7 +378,7 @@ def test_raise_on_wrong_field(tmp_dir, scm, dvc):
|
||||
}
|
||||
|
||||
with pytest.raises(NoFieldInDataError):
|
||||
VegaRenderer(data, dvc.plots.templates).get_vega()
|
||||
VegaRenderer(data, dvc.plots.templates).as_json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
Reference in New Issue
Block a user