plots: data conversion: adjust for viewer backend (#8421)

* plots: vega: converter: support multi series data split and props inference

Fixes: #7754, #8399
Related: iterative/studio#4369

* plots: converter: make converter infer x and y labels if not provided

* plots: vega: converter: roll back to datapoints format

* plots: vega: converter: refactor

* plots: infer labels on

* plots: get_plot_id: fix naming

* plots: vega: converter: notes regarding converting
This commit is contained in:
Paweł Redzyński
2022-11-18 12:36:00 +00:00
committed by GitHub
parent 4d6e045989
commit ba2e8bdac7
13 changed files with 455 additions and 481 deletions
+3 -3
View File
@@ -14,14 +14,14 @@ from dvc.render.converter.vega import VegaConverter
def _get_converter( def _get_converter(
renderer_class, props renderer_class, renderer_id, props, data
) -> Union[VegaConverter, ImageConverter]: ) -> Union[VegaConverter, ImageConverter]:
from dvc_render import ImageRenderer, VegaRenderer from dvc_render import ImageRenderer, VegaRenderer
if renderer_class.TYPE == VegaRenderer.TYPE: if renderer_class.TYPE == VegaRenderer.TYPE:
return VegaConverter(props) return VegaConverter(renderer_id, data, props)
if renderer_class.TYPE == ImageRenderer.TYPE: if renderer_class.TYPE == ImageRenderer.TYPE:
return ImageConverter(props) return ImageConverter(renderer_id, data, props)
raise ValueError(f"Invalid renderer class {renderer_class}") raise ValueError(f"Invalid renderer class {renderer_class}")
+14 -4
View File
@@ -1,9 +1,19 @@
from typing import Dict, Optional from typing import Any, Dict, List, Optional, Tuple
class Converter: class Converter:
def __init__(self, plot_properties: Optional[Dict] = None): def __init__(
self.plot_properties = plot_properties or {} self,
plot_id: str,
data: Dict[str, Any] = None,
properties: Optional[Dict] = None,
):
self.plot_id = plot_id
self.properties = properties or {}
self.data = data or {}
def convert(self, data, revision: str, filename: str, **kwargs): def convert(self) -> Tuple[List[Tuple[str, str, Any]], Dict]:
raise NotImplementedError
def flat_datapoints(self, revision: str) -> Tuple[List[Dict], Dict]:
raise NotImplementedError raise NotImplementedError
+27 -19
View File
@@ -1,6 +1,6 @@
import base64 import base64
import os import os
from typing import TYPE_CHECKING, Dict, List, Tuple from typing import TYPE_CHECKING, Any, Dict, List, Tuple
from dvc.render import FILENAME_FIELD, REVISION_FIELD, SRC_FIELD from dvc.render import FILENAME_FIELD, REVISION_FIELD, SRC_FIELD
@@ -34,25 +34,33 @@ class ImageConverter(Converter):
base64_str = base64.b64encode(image_data).decode() base64_str = base64.b64encode(image_data).decode()
return f"data:image;base64,{base64_str}" return f"data:image;base64,{base64_str}"
def convert( def convert(self) -> Tuple[List[Tuple[str, str, Any]], Dict]:
self, data, revision: str, filename: str, **kwargs datas = []
) -> Tuple[List[Dict], Dict]: for filename, image_data in self.data.items():
datas.append((filename, "", image_data))
return datas, self.properties
def flat_datapoints(self, revision: str) -> Tuple[List[Dict], Dict]:
""" """
Convert the DVC Plots content to DVC Render datapoints. Convert the DVC Plots content to DVC Render datapoints.
Return both generated datapoints and updated properties. Return both generated datapoints and updated properties.
""" """
path = self.plot_properties.get("out") path = self.properties.get("out")
if path: datapoints = []
if not os.path.isdir(path): datas, properties = self.convert()
os.makedirs(path, exist_ok=True) for filename, _, image_data in datas:
src = self._write_image( if path:
os.path.abspath(path), revision, filename, data if not os.path.isdir(path):
) os.makedirs(path, exist_ok=True)
else: src = self._write_image(
src = self._encode_image(data) os.path.abspath(path), revision, filename, image_data
datapoint = { )
REVISION_FIELD: revision, else:
FILENAME_FIELD: filename, src = self._encode_image(image_data)
SRC_FIELD: src, datapoint = {
} REVISION_FIELD: revision,
return [datapoint], self.plot_properties FILENAME_FIELD: filename,
SRC_FIELD: src,
}
datapoints.append(datapoint)
return datapoints, properties
+261 -265
View File
@@ -1,90 +1,101 @@
from copy import deepcopy from copy import deepcopy
from functools import partial from typing import Any, Dict, Iterable, List, Tuple, Union
from typing import Dict, Iterable, List, Optional, Set, Union
from funcy import first, project from funcy import first, last
from dvc.exceptions import DvcException from dvc.exceptions import DvcException
from dvc.render import ( from dvc.render import FILENAME_FIELD, INDEX_FIELD, VERSION_FIELD
FILENAME_FIELD,
INDEX_FIELD,
REVISION_FIELD,
VERSION_FIELD,
)
from . import Converter from . import Converter
class FieldsNotFoundError(DvcException): class FieldNotFoundError(DvcException):
def __init__(self, expected_fields, found_fields): def __init__(self, expected_field, found_fields):
expected_str = ", ".join(expected_fields)
found_str = ", ".join(found_fields) found_str = ", ".join(found_fields)
super().__init__( super().__init__(
f"Could not find all provided fields ('{expected_str}') " f"Could not find provided field ('{expected_field}') "
f"in data fields ('{found_str}')." f"in data fields ('{found_str}')."
) )
class PlotDataStructureError(DvcException): def _lists(blob: Union[Dict, List]) -> Iterable[List]:
def __init__(self): if isinstance(blob, list):
super().__init__( yield blob
"Plot data extraction failed. Please see " else:
"https://man.dvc.org/plots for supported data formats." for _, value in blob.items():
) if isinstance(value, dict):
yield from _lists(value)
elif isinstance(value, list):
yield value
def _filter_fields( def _file_field(*args):
datapoints: List[Dict], fields: Set, **kwargs for axis_def in args:
) -> List[Dict]: if axis_def is not None:
if not fields: for file, val in axis_def.items():
return datapoints if isinstance(val, str):
assert isinstance(fields, set) yield file, val
elif isinstance(val, list):
new_data = [] for field in val:
for data_point in datapoints: yield file, field
keys = set(data_point.keys())
if not fields <= keys:
raise FieldsNotFoundError(fields, keys)
new_data.append(project(data_point, fields))
return new_data
def _lists(dictionary: Dict): def _find(
for _, value in dictionary.items(): filename: str,
if isinstance(value, dict): field: str,
yield from _lists(value) data_series: List[Tuple[str, str, Any]],
elif isinstance(value, list): ):
yield value for data_file, data_field, data in data_series:
if data_file == filename and data_field == field:
return data_file, data_field, data
return None
def _find_first_list( def _verify_field(file2datapoints: Dict[str, List], filename: str, field: str):
data: Union[Dict, List], fields: Set, **kwargs if filename in file2datapoints:
) -> List[Dict]: datapoint = first(file2datapoints[filename])
fields = fields or set() if field not in datapoint:
raise FieldNotFoundError(field, datapoint.keys())
if not isinstance(data, dict): return
return data
for lst in _lists(data):
if (
all(isinstance(dp, dict) for dp in lst)
# if fields is empty, it will match any set
and set(first(lst).keys()) & fields == fields
):
return lst
raise PlotDataStructureError()
def _append_index(datapoints: List[Dict], **kwargs) -> List[Dict]: def _get_x(properties: Dict, file2datapoints: Dict[str, List[Dict]]):
if INDEX_FIELD in first(datapoints).keys(): x = properties.get("x", None)
return datapoints if x is not None and isinstance(x, dict):
filename, field = first(_file_field(x))
_verify_field(file2datapoints, filename, field)
return filename, field
return None
for index, data_point in enumerate(datapoints):
data_point[INDEX_FIELD] = index def _get_ys(properties, file2datapoints: Dict[str, List[Dict]]):
return datapoints y = properties.get("y", None)
if y is not None:
for filename, field in _file_field(y):
_verify_field(file2datapoints, filename, field)
yield filename, field
def _is_datapoints(lst: List[Dict]):
"""
check if dict keys match, datapoints with different keys mgiht lead
to unexpected behavior
"""
return all(isinstance(item, dict) for item in lst) and set(
first(lst).keys()
) == {key for keys in lst for key in keys}
def get_datapoints(file_content: Dict):
result: List[Dict[str, Any]] = []
for lst in _lists(file_content):
if _is_datapoints(lst):
for index, datapoint in enumerate(lst):
if len(result) <= index:
result.append({})
result[index].update(datapoint)
return result
class VegaConverter(Converter): class VegaConverter(Converter):
@@ -95,228 +106,213 @@ class VegaConverter(Converter):
('x', 'y') it will attempt to fill in the blanks. ('x', 'y') it will attempt to fill in the blanks.
""" """
def __init__(self, plot_properties: Optional[Dict] = None): def __init__(
super().__init__(plot_properties) self, plot_id: str, data: Dict = None, properties: Dict = None
):
super().__init__(plot_id, data, properties)
self.plot_id = plot_id
self.inferred_properties: Dict = {} self.inferred_properties: Dict = {}
self.steps = [] # TODO we should be handling that in `convert`,
# to avoid stateful `self.inferred_properties`
self._infer_x_y()
self._infer_x() def _infer_y_from_data(self):
self._infer_fields() if self.plot_id in self.data:
for lst in _lists(self.data[self.plot_id]):
if all(isinstance(item, dict) for item in lst):
datapoint = first(lst)
field = last(datapoint.keys())
self.inferred_properties["y"] = {self.plot_id: field}
break
self.steps.append( def _infer_x_y(self):
( def _infer_files(from_name, to_name):
"find_data", from_value = self.properties.get(from_name, None)
partial( to_value = self.properties.get(to_name, None)
_find_first_list,
fields=self.inferred_properties.get("fields", set())
- {INDEX_FIELD},
),
)
)
if not self.plot_properties.get("x", None): if isinstance(to_value, str):
self.steps.append(("append_index", partial(_append_index))) self.inferred_properties[to_name] = {}
if isinstance(from_value, dict):
self.steps.append( for file in from_value.keys():
( self.inferred_properties[to_name][file] = to_value
"filter_fields", else:
partial( self.inferred_properties[to_name][self.plot_id] = to_value
_filter_fields,
fields=self.inferred_properties.get("fields", set()),
),
)
)
self.steps.append(
(
"infer_y",
partial(
self._infer_y,
),
)
)
self.steps.append(
(
"generate_y",
partial(
self._generate_y_values,
),
)
)
def _infer_x(self):
if not self.plot_properties.get("x", None):
self.inferred_properties["x"] = INDEX_FIELD
def skip_step(self, name: str):
self.steps = [(_name, fn) for _name, fn in self.steps if _name != name]
def _infer_fields(self):
fields = self.plot_properties.get("fields", set())
if fields:
fields = {
*fields,
self.plot_properties.get("x", None),
self.plot_properties.get("y", None),
self.inferred_properties.get("x", None),
} - {None}
self.inferred_properties["fields"] = fields
def _infer_y(self, datapoints: List[Dict], **kwargs):
if "y" not in self.plot_properties:
data_fields = list(first(datapoints))
skip = (
REVISION_FIELD,
self.plot_properties.get("x", None)
or self.inferred_properties.get("x"),
FILENAME_FIELD,
VERSION_FIELD,
)
inferred_y = first(
f for f in reversed(data_fields) if f not in skip
)
if "y" in self.inferred_properties:
previous_y = self.inferred_properties["y"]
if previous_y != inferred_y:
raise DvcException(
f"Inferred y ('{inferred_y}' value does not match"
f"previously matched one ('f{previous_y}')."
)
else: else:
self.inferred_properties["y"] = inferred_y self.inferred_properties[to_name] = to_value
return datapoints
_infer_files("y", "x")
_infer_files("x", "y")
if self.inferred_properties.get("y", None) is None:
self._infer_y_from_data()
def _find_datapoints(self):
result = {}
for file, content in self.data.items():
result[file] = get_datapoints(content)
return result
@staticmethod
def infer_y_label(properties):
y_label = properties.get("y_label", None)
if y_label is None:
y = properties.get("y", None)
if isinstance(y, dict):
fields = {field for _, field in _file_field(y)}
if len(fields) == 1:
y_label = first(fields)
else:
y_label = "y"
elif isinstance(y, list):
y_label = "y"
elif isinstance(y, str):
y_label = y
return y_label
@staticmethod
def infer_x_label(properties):
x_label = properties.get("x_label", None)
if x_label is None:
x = properties.get("x", None)
if isinstance(x, dict):
fields = {field for _, field in _file_field(x)}
if len(fields) == 1:
x_label = first(fields)
else:
x_label = INDEX_FIELD
return x_label
def flat_datapoints(self, revision):
file2datapoints, properties = self.convert()
props_update = {}
x = _get_x(properties, file2datapoints)
# assign "step" if no x provided
if not x:
x_file, x_field = (
None,
INDEX_FIELD,
)
else:
x_file, x_field = x
props_update["x"] = x_field
ys = list(_get_ys(properties, file2datapoints))
all_datapoints = []
all_y_fields = {y_field for _, y_field in ys}
# override to unified y field name if there are different y fields
if len(all_y_fields) > 1:
props_update["y"] = "dvc_inferred_y_value"
else:
props_update["y"] = first(all_y_fields)
for y_file, y_field in ys:
datapoints = deepcopy(file2datapoints.get(y_file, []))
if props_update.get("y", None) == "dvc_inferred_y_value":
_update_from_field(
datapoints,
field="dvc_inferred_y_value",
source_field=y_field,
)
if x_field == INDEX_FIELD and x_file is None:
_update_from_index(datapoints, INDEX_FIELD)
else:
x_datapoints = file2datapoints.get(x_file, [])
try:
_update_from_field(
datapoints,
field=x_field,
source_datapoints=x_datapoints,
)
except IndexError:
raise DvcException(
f"Cannot join '{x_field}' from '{x_file}' and "
"'{y_field}' from '{y_file}'. "
"They have to have same length."
)
_update_all(
datapoints,
update_dict={
VERSION_FIELD: {
"revision": revision,
FILENAME_FIELD: y_file,
"field": y_field,
}
},
)
all_datapoints.extend(datapoints)
if not all_datapoints:
return [], {}
properties = {**properties, **props_update}
return all_datapoints, properties
def convert( def convert(
self, self,
data,
revision: str,
filename: str,
skip: List = None,
**kwargs,
): ):
""" """
Convert the data. Fill necessary fields ('x', 'y') and return both Convert the data. Fill necessary fields ('x', 'y') and return both
generated datapoints and updated properties. generated datapoints and updated properties. If `x` is not provided,
leave it as None, fronteds should handle it.
NOTE: Studio uses this method.
The only thing studio FE handles is filling `x` and `y`.
`x/y_label` should be filled here.
Datapoints are not stripped according to config, because users
might be utilizing other fields in their custom plots.
""" """
if not skip: datapoints = self._find_datapoints()
skip = [] properties = {**self.properties, **self.inferred_properties}
processed = deepcopy(data) properties["y_label"] = self.infer_y_label(properties)
properties["x_label"] = self.infer_x_label(properties)
for step_name, step in self.steps: return datapoints, properties
if step_name not in skip:
processed = step( # type: ignore
processed,
revision=revision,
filename=filename,
)
return processed, {**self.plot_properties, **self.inferred_properties}
def _generate_y_values( # noqa: C901 def _update_from_field(
self, target_datapoints: List[Dict],
datapoints: List[Dict], field: str,
revision: str, source_datapoints: List[Dict] = None,
filename: str, source_field: str = None,
**kwargs, ):
) -> List[Dict]: if source_datapoints is None:
source_datapoints = target_datapoints
if source_field is None:
source_field = field
y_values = self.plot_properties.get( if len(source_datapoints) != len(target_datapoints):
"y", None raise IndexError(
) or self.inferred_properties.get("y", None) "Source and target datapoints must have the same length"
)
assert y_values is not None for index, datapoint in enumerate(target_datapoints):
source_datapoint = source_datapoints[index]
if source_field in source_datapoint:
datapoint[field] = source_datapoint[source_field]
result = []
properties_update = {}
def _add_version_info(datapoint, version_info): def _update_from_index(datapoints: List[Dict], new_field: str):
tmp = datapoint.copy() for index, datapoint in enumerate(datapoints):
tmp[VERSION_FIELD] = version_info datapoint[new_field] = index
return tmp
def _version_info(revision, filename=None, field=None):
res = {"revision": revision}
if filename is not None:
res["filename"] = filename
if field is not None:
res["field"] = field
return res
def _generate_y(datapoint, field): def _update_all(datapoints: List[Dict], update_dict: Dict):
tmp = datapoint.copy()
tmp["dvc_inferred_y_value"] = datapoint[field]
tmp = _add_version_info(
tmp, _version_info(revision, filename, field)
)
if (
"y_label" not in properties_update
and "y_label" not in self.plot_properties
):
properties_update["y_label"] = "y"
properties_update["y"] = "dvc_inferred_y_value" for datapoint in datapoints:
datapoint.update(update_dict)
return tmp
if isinstance(y_values, str):
for datapoint in datapoints:
result.append(
_add_version_info(
datapoint, _version_info(revision, filename, y_values)
)
)
if isinstance(y_values, list):
for datapoint in datapoints:
for y_val in y_values:
if y_val in datapoint:
result.append(_generate_y(datapoint, y_val))
if isinstance(y_values, dict):
def _to_set(values: Iterable):
result = set()
for val in values:
if isinstance(val, list):
for elem in val:
result.add(elem)
else:
result.add(val)
return result
all_fields = _to_set(y_values.values())
if (
all([isinstance(field, str) for field in all_fields])
and len(all_fields) == 1
):
# if we use the same field from all files,
# we dont have to generate it
y_field = all_fields.pop()
for datapoint in datapoints:
result.append(
_add_version_info(
datapoint,
_version_info(revision, filename, y_field),
)
)
properties_update.update({"y": y_field})
else:
for def_filename, val in y_values.items():
if isinstance(val, str):
fields = [val]
if isinstance(val, list):
fields = val
for datapoint in datapoints:
for field in fields:
if field in datapoint and def_filename in filename:
result.append(_generate_y(datapoint, field))
self.inferred_properties = {
**self.inferred_properties,
**properties_update,
}
return result
+9 -10
View File
@@ -42,7 +42,7 @@ class PlotsData:
return dict(groups) return dict(groups)
def get_definition_data(self, target_files, rev): def get_definition_data(self, target_files, rev):
result = [] result = {}
for file in target_files: for file in target_files:
file_content = ( file_content = (
self.data.get(rev, {}) self.data.get(rev, {})
@@ -52,7 +52,7 @@ class PlotsData:
.get("data", {}) .get("data", {})
) )
if file_content: if file_content:
result.append((file, file_content)) result[file] = file_content
return result return result
@@ -91,15 +91,14 @@ def match_defs_renderers(
renderer_cls = VegaRenderer renderer_cls = VegaRenderer
renderer_id = plot_id renderer_id = plot_id
converter = _get_converter(renderer_cls, props) converter = _get_converter(
renderer_cls, inner_id, props, definitions_data
)
for filename, plot_data in definitions_data: dps, rev_props = converter.flat_datapoints(rev)
dps, final_props = converter.convert( if not final_props and rev_props:
revision=rev, final_props = rev_props
filename=filename, plot_datapoints.extend(dps)
data=plot_data,
)
plot_datapoints.extend(dps)
if "title" not in final_props: if "title" not in final_props:
final_props["title"] = renderer_id final_props["title"] = renderer_id
+7 -5
View File
@@ -19,7 +19,7 @@ from typing import (
import dpath.options import dpath.options
import dpath.util import dpath.util
from funcy import cached_property, first, project from funcy import cached_property, distinct, first, project
from dvc.exceptions import DvcException from dvc.exceptions import DvcException
from dvc.utils import error_handler, errored_revisions, onerror_collect from dvc.utils import error_handler, errored_revisions, onerror_collect
@@ -314,16 +314,18 @@ def _get_data_targets(definitions: Dict):
def infer_data_sources(plot_id, config=None): def infer_data_sources(plot_id, config=None):
def _deduplicate(lst: List):
return list({elem: None for elem in lst}.keys())
y = config.get("y", None) y = config.get("y", None)
if isinstance(y, dict): if isinstance(y, dict):
sources = list(y.keys()) sources = list(y.keys())
else: else:
sources = [plot_id] sources = [plot_id]
return _deduplicate(source for source in sources) x = config.get("x", None)
if isinstance(x, dict):
sources.append(first(x.keys()))
return distinct(source for source in sources)
def _matches(targets, config_file, plot_id): def _matches(targets, config_file, plot_id):
+1 -1
View File
@@ -104,7 +104,7 @@ def either_or(primary, fallback, fallback_includes=None):
PLOT_DEFINITION = { PLOT_DEFINITION = {
Output.PARAM_PLOT_X: str, Output.PARAM_PLOT_X: Any(str, {str: str}),
Output.PARAM_PLOT_Y: Any(str, [str], {str: Any(str, [str])}), Output.PARAM_PLOT_Y: Any(str, [str], {str: Any(str, [str])}),
Output.PARAM_PLOT_X_LABEL: str, Output.PARAM_PLOT_X_LABEL: str,
Output.PARAM_PLOT_Y_LABEL: str, Output.PARAM_PLOT_Y_LABEL: str,
+6 -2
View File
@@ -1,2 +1,6 @@
def get_plot_id(plot_id: str, config_file_path: str = ""): def get_plot_id(config_plot_id: str, config_file_path: str = ""):
return f"{config_file_path}::{plot_id}" if config_file_path else plot_id return (
f"{config_file_path}::{config_plot_id}"
if config_file_path
else config_plot_id
)
+9 -10
View File
@@ -336,16 +336,15 @@ def test_collect_non_existing_dir(tmp_dir, dvc, run_copy_metrics):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"plot_config,expected_datafiles", "plot_config,expected_datafiles",
[ [
# TODO - enable providing data files for x (
# ( {
# { "comparison": {
# "comparison": { "x": {"data1.json": "a"},
# "x": {"data1.json": "a"}, "y": {"sub/dir/data2.json": "b"},
# "y": {"sub/dir/data2.json": "b"}, }
# } },
# }, ["data1.json", os.path.join("sub", "dir", "data2.json")],
# ["data1.json", os.path.join("sub", "dir", "data2.json")], ),
# ),
( (
{"data1.json": {"x": "c", "y": "a", "title": "File as key test"}}, {"data1.json": {"x": "c", "y": "a", "title": "File as key test"}},
["data1.json"], ["data1.json"],
+4 -3
View File
@@ -456,9 +456,7 @@ def test_repo_with_config_plots(tmp_dir, capsys, repo_with_config_plots):
"dvc.yaml::confusion_train_vs_test", "dvc.yaml::confusion_train_vs_test",
], ],
) )
assert html_result["dvc.yaml::linear_train_vs_test"]["data"][ ble = _update_datapoints(
"values"
] == _update_datapoints(
plots["data"]["linear_train.json"], plots["data"]["linear_train.json"],
{ {
REVISION_FIELD: "linear_train.json", REVISION_FIELD: "linear_train.json",
@@ -470,4 +468,7 @@ def test_repo_with_config_plots(tmp_dir, capsys, repo_with_config_plots):
}, },
) )
assert (
html_result["dvc.yaml::linear_train_vs_test"]["data"]["values"] == ble
)
# TODO check json results once vscode is able to handle flexible plots # TODO check json results once vscode is able to handle flexible plots
+9 -10
View File
@@ -3,10 +3,9 @@ from dvc.render.converter.image import ImageConverter
def test_image_converter_no_out(): def test_image_converter_no_out():
data = b"content" data = {"image.png": b"content"}
converter = ImageConverter() converter = ImageConverter("image.png", data)
datapoints, _ = converter.flat_datapoints("r")
datapoints, _ = converter.convert(data, "r", "image.png")
assert datapoints[0] == { assert datapoints[0] == {
REVISION_FIELD: "r", REVISION_FIELD: "r",
@@ -16,10 +15,10 @@ def test_image_converter_no_out():
def test_image_converter_with_out(tmp_dir): def test_image_converter_with_out(tmp_dir):
data = b"content" data = {"image.png": b"content"}
converter = ImageConverter({"out": tmp_dir / "foo"}) converter = ImageConverter("image.png", data, {"out": tmp_dir / "foo"})
datapoints, _ = converter.convert(data, "r", "image.png") datapoints, _ = converter.flat_datapoints("r")
assert datapoints[0] == { assert datapoints[0] == {
REVISION_FIELD: "r", REVISION_FIELD: "r",
@@ -32,10 +31,10 @@ def test_image_converter_with_out(tmp_dir):
def test_image_converter_with_slash_in_revision(tmp_dir): def test_image_converter_with_slash_in_revision(tmp_dir):
"""Regression test for #7934""" """Regression test for #7934"""
data = b"content" data = {"image.png": b"content"}
converter = ImageConverter({"out": tmp_dir / "foo"}) converter = ImageConverter("image.png", data, {"out": tmp_dir / "foo"})
datapoints, _ = converter.convert(data, "feature/r", "image.png") datapoints, _ = converter.flat_datapoints("feature/r")
assert datapoints[0] == { assert datapoints[0] == {
REVISION_FIELD: "feature/r", REVISION_FIELD: "feature/r",
+2
View File
@@ -111,6 +111,8 @@ def test_match_renderers(mocker):
"title": "config_file_1::plot_id_1", "title": "config_file_1::plot_id_1",
"x": "x", "x": "x",
"y": "y", "y": "y",
"x_label": "x",
"y_label": "y",
} }
+103 -149
View File
@@ -3,38 +3,7 @@ from collections import OrderedDict
import pytest import pytest
from dvc.render import VERSION_FIELD from dvc.render import VERSION_FIELD
from dvc.render.converter.vega import ( from dvc.render.converter.vega import VegaConverter, _lists
FieldsNotFoundError,
PlotDataStructureError,
VegaConverter,
_filter_fields,
_find_first_list,
_lists,
)
def test_find_first_list_in_dict():
m1 = [{"accuracy": 1, "loss": 2}, {"accuracy": 3, "loss": 4}]
m2 = [{"x": 1}, {"x": 2}]
dmetric = OrderedDict([("t1", m1), ("t2", m2)])
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}]
assert _filter_fields(m, fields=set()) == m
expected = [{"accuracy": 1}, {"accuracy": 3}]
assert _filter_fields(m, fields={"accuracy"}) == expected
with pytest.raises(FieldsNotFoundError):
_filter_fields(m, fields={"bad_field"})
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -58,7 +27,7 @@ def test_finding_lists(dictionary, expected_result):
"input_data,properties,expected_datapoints,expected_properties", "input_data,properties,expected_datapoints,expected_properties",
[ [
pytest.param( pytest.param(
{"metric": [{"v": 1}, {"v": 2}]}, {"f": {"metric": [{"v": 1}, {"v": 2}]}},
{}, {},
[ [
{ {
@@ -80,41 +49,11 @@ def test_finding_lists(dictionary, expected_result):
}, },
}, },
], ],
{"x": "step", "y": "v"}, {"x": "step", "y": "v", "x_label": "step", "y_label": "v"},
id="default_x_y", id="default_x_y",
), ),
pytest.param( pytest.param(
{"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]}, {"f": {"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]}},
{"fields": {"v"}},
[
{
"v": 1,
"step": 0,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v",
},
},
{
"v": 2,
"step": 1,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v",
},
},
],
{
"x": "step",
"y": "v",
"fields": {"v", "step"},
},
id="filter_fields",
),
pytest.param(
{"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]},
{"x": "v", "y": "v2"}, {"x": "v", "y": "v2"},
[ [
{ {
@@ -136,52 +75,24 @@ def test_finding_lists(dictionary, expected_result):
}, },
}, },
], ],
{"x": "v", "y": "v2"}, {"x": "v", "y": "v2", "x_label": "v", "y_label": "v2"},
id="choose_x_y", id="choose_x_y",
), ),
pytest.param( pytest.param(
{ {
"metric": [ "f": {
{"v": 1, "v2": 0.1, "v3": 0.01, "v4": 0.001}, "some": "noise",
{"v": 2, "v2": 0.2, "v3": 0.02, "v4": 0.002}, "very": {
] "nested": {
}, "metric": [
{"x": "v3", "y": "v4", "fields": {"v"}}, {"v": 1, "v2": 0.1},
[ {"v": 2, "v2": 0.2},
{ ]
"v": 1, }
"v3": 0.01,
"v4": 0.001,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v4",
}, },
}, }
{
"v": 2,
"v3": 0.02,
"v4": 0.002,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v4",
},
},
],
{"x": "v3", "y": "v4", "fields": {"v", "v3", "v4"}},
id="append_x_y_to_fields",
),
pytest.param(
{
"some": "noise",
"very": {
"nested": {
"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]
}
},
}, },
{"x": "v", "y": "v2"}, {"x": "v", "y": "v2", "x_label": "x", "y_label": "y"},
[ [
{ {
"v": 1, "v": 1,
@@ -202,61 +113,128 @@ def test_finding_lists(dictionary, expected_result):
}, },
}, },
], ],
{"x": "v", "y": "v2"}, {"x": "v", "y": "v2", "x_label": "x", "y_label": "y"},
id="find_in_nested_structure", id="find_in_nested_structure",
), ),
pytest.param( pytest.param(
{"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]}, {"f": {"metric": [{"v": 1, "v2": 0.1}, {"v": 2, "v2": 0.2}]}},
{"y": {"f": ["v", "v2"]}}, {"y": {"f": ["v", "v2"]}},
[ [
{ {
"v": 1,
"v2": 0.1,
VERSION_FIELD: { VERSION_FIELD: {
"revision": "r", "revision": "r",
"filename": "f", "filename": "f",
"field": "v", "field": "v",
}, },
"dvc_inferred_y_value": 1, "dvc_inferred_y_value": 1,
"step": 0,
},
{
"v": 1, "v": 1,
"v2": 0.1, "v2": 0.1,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v2",
},
"dvc_inferred_y_value": 0.1,
"step": 0, "step": 0,
}, },
{ {
"v": 2,
"v2": 0.2,
VERSION_FIELD: { VERSION_FIELD: {
"revision": "r", "revision": "r",
"filename": "f", "filename": "f",
"field": "v", "field": "v",
}, },
"dvc_inferred_y_value": 2, "dvc_inferred_y_value": 2,
"v": 2,
"v2": 0.2,
"step": 1, "step": 1,
}, },
{ {
"v": 2,
"v2": 0.2,
VERSION_FIELD: { VERSION_FIELD: {
"revision": "r", "revision": "r",
"filename": "f", "filename": "f",
"field": "v2", "field": "v2",
}, },
"dvc_inferred_y_value": 0.1,
"v2": 0.1,
"v": 1,
"step": 0,
},
{
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v2",
},
"v": 2,
"v2": 0.2,
"dvc_inferred_y_value": 0.2, "dvc_inferred_y_value": 0.2,
"step": 1, "step": 1,
}, },
], ],
{"x": "step", "y": "dvc_inferred_y_value", "y_label": "y"}, {
"x": "step",
"y": "dvc_inferred_y_value",
"y_label": "y",
"x_label": "step",
},
id="y_def_list", id="y_def_list",
), ),
pytest.param(
{
"f": {
"metric": [{"v": 1}, {"v": 2}],
"other_metric": [{"z": 3}, {"z": 4}],
}
},
{"y": {"f": ["v", "z"]}},
[
{
"dvc_inferred_y_value": 1,
"z": 3,
"v": 1,
"step": 0,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v",
},
},
{
"dvc_inferred_y_value": 2,
"z": 4,
"step": 1,
"v": 2,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "v",
},
},
{
"dvc_inferred_y_value": 3,
"v": 1,
"z": 3,
"step": 0,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "z",
},
},
{
"dvc_inferred_y_value": 4,
"v": 2,
"z": 4,
"step": 1,
VERSION_FIELD: {
"revision": "r",
"filename": "f",
"field": "z",
},
},
],
{
"x": "step",
"y": "dvc_inferred_y_value",
"y_label": "y",
"x_label": "step",
},
id="multi_source_json",
),
], ],
) )
def test_convert( def test_convert(
@@ -265,32 +243,8 @@ def test_convert(
expected_datapoints, expected_datapoints,
expected_properties, expected_properties,
): ):
converter = VegaConverter(properties) converter = VegaConverter("f", input_data, properties)
datapoints, resolved_properties = converter.convert( datapoints, resolved_properties = converter.flat_datapoints("r")
data=input_data, revision="r", filename="f"
)
assert datapoints == expected_datapoints assert datapoints == expected_datapoints
assert resolved_properties == expected_properties assert resolved_properties == expected_properties
def test_convert_skip_step():
converter = VegaConverter()
converter.skip_step("append_index")
converter.skip_step("generate_y")
datapoints, resolved_properties = converter.convert(
data={"a": "b", "metric": [{"v": 1}, {"v": 2}]},
revision="r",
filename="f",
)
assert datapoints == [
{
"v": 1,
},
{
"v": 2,
},
]
assert resolved_properties == {"x": "step", "y": "v"}