plots: add plot markers to DVC files (#3807)

* run: add --plot/--plot-no-cache

Part of #3409

* plot: move show/diff to API

* plots: show marked plots by default

Involves some refactoring to make plots comply with the rest of the code
base.

* dvc.yaml: use top-level metrics and plots

* dvc.yaml: add support for plot template

* plots: add support for templates in dvc.yaml

* dvc.yaml: allow metrics/plots to have persist/cache

* plots: render single page by-default and introduce --show-json

* plot: use "working tree" instead of "workspace"

Just to sync this with the rest of the codebase. We should consider
renaming it to "workspace" everywhere, but for now I opt to not touch
it to not break something that depends on it.

* plots: rename -f|--file to -o|--out

Better corresponds to other commands like `import`.

* plots: use RepoTree and brancher instead of api

* stage: get rid of OutputParams

* tests: plots: add simple diff test

* plots: use `--targets` instead of `--datafile`

Makes it comply with `dvc metrics/params` CLI options.

* tests: unit: plots: add --show-json test

* stage: cleanup exception for format error

Based on feedback from the users.

* lockfile: use relpath in corruption error
This commit is contained in:
Ruslan Kuprieiev
2020-05-20 04:48:53 +03:00
committed by GitHub
parent aaca1401ea
commit 09136f2378
26 changed files with 631 additions and 468 deletions
+56 -72
View File
@@ -4,37 +4,33 @@ import os
from dvc.command.base import CmdBase, append_doc_link, fix_subparsers
from dvc.exceptions import DvcException
from dvc.repo.plots.data import WORKSPACE_REVISION_NAME
logger = logging.getLogger(__name__)
PAGE_HTML = """<!DOCTYPE html>
<html>
<head>
<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>
</head>
<body>
{divs}
</body>
</html>"""
DIV_HTML = """<div id = "{id}"></div>
<script type = "text/javascript">
var spec = {vega_json};
vegaEmbed('#{id}', spec);
</script>"""
class CmdPlots(CmdBase):
def _revisions(self):
def _func(self, *args, **kwargs):
raise NotImplementedError
def _result_file(self):
if self.args.file:
return self.args.file
extension = self._result_extension()
base = self._result_basename()
result_file = base + extension
return result_file
def _result_basename(self):
if self.args.datafile:
return self.args.datafile
return "plot"
def _result_extension(self):
if not self.args.no_html:
return ".html"
elif self.args.template:
return os.path.splitext(self.args.template)[-1]
return ".json"
def run(self):
fields = None
jsonpath = None
@@ -44,33 +40,38 @@ class CmdPlots(CmdBase):
else:
fields = set(self.args.select.split(","))
try:
plot_string = self.repo.plot(
datafile=self.args.datafile,
plots = self._func(
targets=self.args.targets,
template=self.args.template,
revisions=self._revisions(),
fields=fields,
x_field=self.args.x,
y_field=self.args.y,
path=jsonpath,
embed=not self.args.no_html,
csv_header=not self.args.no_csv_header,
title=self.args.title,
x_title=self.args.xlab,
y_title=self.args.ylab,
)
if self.args.stdout:
logger.info(plot_string)
else:
result_path = self._result_file()
with open(result_path, "w") as fobj:
fobj.write(plot_string)
if self.args.show_json:
import json
logger.info(
"file://{}".format(
os.path.join(self.repo.root_dir, result_path)
)
)
logger.info(json.dumps(plots))
return 0
divs = [
DIV_HTML.format(id=f"plot{i}", vega_json=plot)
for i, plot in enumerate(plots.values())
]
html = PAGE_HTML.format(divs="\n".join(divs))
path = self.args.out or "plots.html"
with open(path, "w") as fobj:
fobj.write(html)
logger.info(
"file://{}".format(os.path.join(self.repo.root_dir, path))
)
except DvcException:
logger.exception("")
@@ -80,18 +81,13 @@ class CmdPlots(CmdBase):
class CmdPlotsShow(CmdPlots):
def _revisions(self):
return None
def _func(self, *args, **kwargs):
return self.repo.plots.show(*args, **kwargs)
class CmdPlotsDiff(CmdPlots):
def _revisions(self):
revisions = self.args.revisions or []
if len(revisions) <= 1:
if len(revisions) == 0 and self.repo.scm.is_dirty():
revisions.append("HEAD")
revisions.append(WORKSPACE_REVISION_NAME)
return revisions
def _func(self, *args, **kwargs):
return self.repo.plots.diff(*args, revs=self.args.revisions, **kwargs)
def add_parser(subparsers, parent_parser):
@@ -130,7 +126,7 @@ def add_parser(subparsers, parent_parser):
help="File to be injected with data.",
)
plots_show_parser.add_argument(
"-f", "--file", default=None, help="Name of the generated file."
"-o", "--out", default=None, help="Destination path to save plots to.",
)
plots_show_parser.add_argument(
"-s",
@@ -144,12 +140,6 @@ def add_parser(subparsers, parent_parser):
plots_show_parser.add_argument(
"-y", default=None, help="Field name for y axis."
)
plots_show_parser.add_argument(
"--stdout",
action="store_true",
default=False,
help="Print plots specification to stdout.",
)
plots_show_parser.add_argument(
"--no-csv-header",
action="store_true",
@@ -157,10 +147,10 @@ def add_parser(subparsers, parent_parser):
help="Required when CSV or TSV datafile does not have a header.",
)
plots_show_parser.add_argument(
"--no-html",
"--show-json",
action="store_true",
default=False,
help="Do not wrap Vega plot JSON with HTML.",
help="Show output in JSON format.",
)
plots_show_parser.add_argument("--title", default=None, help="Plot title.")
plots_show_parser.add_argument(
@@ -170,7 +160,9 @@ def add_parser(subparsers, parent_parser):
"--ylab", default=None, help="Y axis title."
)
plots_show_parser.add_argument(
"datafile", nargs="?", default=None, help="Metrics file to visualize",
"targets",
nargs="*",
help="Metrics files to visualize. Shows all plots by default.",
)
plots_show_parser.set_defaults(func=CmdPlotsShow)
@@ -193,14 +185,12 @@ def add_parser(subparsers, parent_parser):
help="File to be injected with data.",
)
plots_diff_parser.add_argument(
"-d",
"--datafile",
nargs="?",
default=None,
help="Metrics file to visualize",
"--targets",
nargs="*",
help="Metrics file to visualize. Shows all plots by default.",
)
plots_diff_parser.add_argument(
"-f", "--file", default=None, help="Name of the generated file."
"-o", "--out", default=None, help="Destination path to save plots to.",
)
plots_diff_parser.add_argument(
"-s",
@@ -214,12 +204,6 @@ def add_parser(subparsers, parent_parser):
plots_diff_parser.add_argument(
"-y", default=None, help="Field name for y axis."
)
plots_diff_parser.add_argument(
"--stdout",
action="store_true",
default=False,
help="Print plot specification to stdout.",
)
plots_diff_parser.add_argument(
"--no-csv-header",
action="store_true",
@@ -227,10 +211,10 @@ def add_parser(subparsers, parent_parser):
help="Provided CSV ot TSV datafile does not have a header.",
)
plots_diff_parser.add_argument(
"--no-html",
"--show-json",
action="store_true",
default=False,
help="Do not wrap Vega plot JSON with HTML.",
help="Show output in JSON format.",
)
plots_diff_parser.add_argument("--title", default=None, help="Plot title.")
plots_diff_parser.add_argument(
+20 -2
View File
@@ -16,6 +16,8 @@ class CmdRun(CmdBase):
self.args.outs_no_cache,
self.args.metrics,
self.args.metrics_no_cache,
self.args.plots,
self.args.plots_no_cache,
self.args.outs_persist,
self.args.outs_persist_no_cache,
self.args.params,
@@ -24,8 +26,8 @@ class CmdRun(CmdBase):
): # pragma: no cover
logger.error(
"too few arguments. Specify at least one: `-d`, `-o`, `-O`, "
"`-m`, `-M`, `-p`, `--outs-persist`, "
"`--outs-persist-no-cache`, `command`."
"`-m`, `-M`, `-p`, `--plots`, `--plots-no-cache`, "
"`--outs-persist`, `--outs-persist-no-cache`, `command`."
)
return 1
@@ -36,6 +38,8 @@ class CmdRun(CmdBase):
outs_no_cache=self.args.outs_no_cache,
metrics=self.args.metrics,
metrics_no_cache=self.args.metrics_no_cache,
plots=self.args.plots,
plots_no_cache=self.args.plots_no_cache,
deps=self.args.deps,
params=self.args.params,
fname=self.args.file,
@@ -140,6 +144,20 @@ def add_parser(subparsers, parent_parser):
"(do not put into DVC cache).",
metavar="<path>",
)
run_parser.add_argument(
"--plots",
action="append",
default=[],
help="Declare output plot file.",
metavar="<path>",
)
run_parser.add_argument(
"--plots-no-cache",
action="append",
default=[],
help="Declare output plot file (do not put into DVC cache).",
metavar="<path>",
)
run_parser.add_argument(
"-f",
"--file",
+7 -6
View File
@@ -32,8 +32,7 @@ PIPELINE_LOCK = "dvc.lock"
class LockfileCorruptedError(DvcException):
def __init__(self, path):
super().__init__(f"Lockfile '{path}' is corrupted.")
pass
def is_valid_filename(path):
@@ -103,7 +102,7 @@ class FileMixin:
with self.repo.tree.open(self.path) as fd:
stage_text = fd.read()
d = parse_stage(stage_text, self.path)
self.validate(d, self.path)
self.validate(d, self.relpath)
return d, stage_text
@classmethod
@@ -112,7 +111,7 @@ class FileMixin:
try:
cls.SCHEMA(d)
except MultipleInvalid as exc:
raise StageFileFormatError(fname, exc)
raise StageFileFormatError(f"'{fname}' format error: {exc}")
def remove_with_prompt(self, force=False):
raise NotImplementedError
@@ -238,9 +237,11 @@ class Lockfile(FileMixin):
with self.repo.tree.open(self.path) as fd:
data = parse_stage(fd.read(), self.path)
try:
self.validate(data, fname=self.path)
self.validate(data, fname=self.relpath)
except StageFileFormatError:
raise LockfileCorruptedError(self.path)
raise LockfileCorruptedError(
f"Lockfile '{self.relpath}' is corrupted."
)
return data
def dump(self, stage, **kwargs):
+23 -4
View File
@@ -54,10 +54,11 @@ SCHEMA = CHECKSUMS_SCHEMA.copy()
SCHEMA[Required(BaseOutput.PARAM_PATH)] = str
SCHEMA[BaseOutput.PARAM_CACHE] = bool
SCHEMA[BaseOutput.PARAM_METRIC] = BaseOutput.METRIC_SCHEMA
SCHEMA[BaseOutput.PARAM_PLOT] = bool
SCHEMA[BaseOutput.PARAM_PERSIST] = bool
def _get(stage, p, info, cache, metric, persist=False):
def _get(stage, p, info, cache, metric, plot=False, persist=False):
parsed = urlparse(p)
if parsed.scheme == "remote":
@@ -69,6 +70,7 @@ def _get(stage, p, info, cache, metric, persist=False):
cache=cache,
remote=remote,
metric=metric,
plot=plot,
persist=persist,
)
@@ -81,6 +83,7 @@ def _get(stage, p, info, cache, metric, persist=False):
cache=cache,
remote=None,
metric=metric,
plot=plot,
persist=persist,
)
return LocalOutput(
@@ -90,6 +93,7 @@ def _get(stage, p, info, cache, metric, persist=False):
cache=cache,
remote=None,
metric=metric,
plot=plot,
persist=persist,
)
@@ -100,19 +104,34 @@ def loadd_from(stage, d_list):
p = d.pop(BaseOutput.PARAM_PATH)
cache = d.pop(BaseOutput.PARAM_CACHE, True)
metric = d.pop(BaseOutput.PARAM_METRIC, False)
plot = d.pop(BaseOutput.PARAM_PLOT, False)
persist = d.pop(BaseOutput.PARAM_PERSIST, False)
ret.append(
_get(
stage, p, info=d, cache=cache, metric=metric, persist=persist,
stage,
p,
info=d,
cache=cache,
metric=metric,
plot=plot,
persist=persist,
)
)
return ret
def loads_from(stage, s_list, use_cache=True, metric=False, persist=False):
def loads_from(
stage, s_list, use_cache=True, metric=False, plot=False, persist=False
):
return [
_get(
stage, s, info={}, cache=use_cache, metric=metric, persist=persist,
stage,
s,
info={},
cache=use_cache,
metric=metric,
plot=plot,
persist=persist,
)
for s in s_list
]
+9 -1
View File
@@ -50,6 +50,8 @@ class BaseOutput:
PARAM_METRIC = "metric"
PARAM_METRIC_TYPE = "type"
PARAM_METRIC_XPATH = "xpath"
PARAM_PLOT = "plot"
PARAM_PLOT_TEMPLATE = "template"
PARAM_PERSIST = "persist"
METRIC_SCHEMA = Any(
@@ -75,6 +77,7 @@ class BaseOutput:
remote=None,
cache=True,
metric=False,
plot=False,
persist=False,
):
self._validate_output_path(path)
@@ -95,6 +98,7 @@ class BaseOutput:
self.remote = remote or self.REMOTE(self.repo, {})
self.use_cache = False if self.IS_DEPENDENCY else cache
self.metric = False if self.IS_DEPENDENCY else metric
self.plot = False if self.IS_DEPENDENCY else plot
self.persist = persist
self.path_info = self._parse_path(remote, path)
@@ -234,7 +238,7 @@ class BaseOutput:
if not self.use_cache:
self.info = self.save_info()
if self.metric:
if self.metric or self.plot:
self.verify_metric()
if not self.IS_DEPENDENCY:
logger.info(
@@ -273,6 +277,10 @@ class BaseOutput:
del self.metric[self.PARAM_METRIC_XPATH]
ret[self.PARAM_METRIC] = self.metric
if self.plot:
ret[self.PARAM_PLOT] = self.plot
ret[self.PARAM_PERSIST] = self.persist
return ret
+6 -5
View File
@@ -71,17 +71,18 @@ class LocalOutput(BaseOutput):
return ret
def verify_metric(self):
if not self.metric:
if not self.metric or self.plot:
return
path = os.fspath(self.path_info)
if not os.path.exists(path):
return
name = "metrics" if self.metric else "plot"
if os.path.isdir(path):
msg = "directory '{}' cannot be used as metrics."
raise DvcException(msg.format(self.path_info))
msg = "directory '{}' cannot be used as {}."
raise DvcException(msg.format(self.path_info, name))
if not istextfile(path):
msg = "binary file '{}' cannot be used as metrics."
raise DvcException(msg.format(self.path_info))
msg = "binary file '{}' cannot be used as {}."
raise DvcException(msg.format(self.path_info, name))
+2 -1
View File
@@ -56,7 +56,6 @@ class Repo:
from dvc.repo.get import get
from dvc.repo.get_url import get_url
from dvc.repo.update import update
from dvc.repo.plots import plot
def __init__(self, root_dir=None):
from dvc.state import State
@@ -65,6 +64,7 @@ class Repo:
from dvc.cache import Cache
from dvc.data_cloud import DataCloud
from dvc.repo.metrics import Metrics
from dvc.repo.plots import Plots
from dvc.repo.params import Params
from dvc.scm.tree import WorkingTree
from dvc.utils.fs import makedirs
@@ -104,6 +104,7 @@ class Repo:
self.stage_cache = StageCache(self)
self.metrics = Metrics(self)
self.plots = Plots(self)
self.params = Params(self)
self._ignore()
+4 -1
View File
@@ -25,13 +25,16 @@ def brancher( # noqa: E302
return
saved_tree = self.tree
revs = revs or []
revs = revs.copy() if revs else []
scm = self.scm
self.tree = WorkingTree(self.root_dir)
yield "working tree"
if revs and "working tree" in revs:
revs.remove("working tree")
if all_commits:
revs = scm.list_all_commits()
else:
+9 -162
View File
@@ -1,166 +1,13 @@
import logging
import os
class Plots:
def __init__(self, repo):
self.repo = repo
from funcy import first, last
def show(self, *args, **kwargs):
from .show import show
from dvc.exceptions import DvcException
from dvc.repo import locked
return show(self.repo, *args, **kwargs)
from .data import PlotData
from .template import NoDataForTemplateError, Template
def diff(self, *args, **kwargs):
from .diff import diff
logger = logging.getLogger(__name__)
PAGE_HTML = """<!DOCTYPE html>
<html>
<head>
<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>
</head>
<body>
{divs}
</body>
</html>"""
DIV_HTML = """<div id = "{id}"></div>
<script type = "text/javascript">
var spec = {vega_json};
vegaEmbed('#{id}', spec);
</script>"""
class TooManyDataSourcesError(DvcException):
def __init__(self, datafile, template_datafiles):
super().__init__(
"Unable to infer which of possible data sources: '{}' "
"should be replaced with '{}'.".format(
", ".join(template_datafiles), datafile
)
)
class NoDataOrTemplateProvided(DvcException):
def __init__(self):
super().__init__("Datafile or template is not specified.")
def _evaluate_templatepath(repo, template=None):
if not template:
return repo.plot_templates.default_template
if os.path.exists(template):
return template
return repo.plot_templates.get_template(template)
@locked
def fill_template(
repo,
datafile,
template_path,
revisions,
fields=None,
path=None,
csv_header=True,
x_field=None,
y_field=None,
**kwargs
):
if x_field and fields:
fields.add(x_field)
if y_field and fields:
fields.add(y_field)
template_datafiles, x_anchor, y_anchor = _parse_template(
template_path, datafile
)
append_index = x_anchor and not x_field
if append_index:
x_field = PlotData.INDEX_FIELD
template_data = {}
for template_datafile in template_datafiles:
from .data import _load_from_revisions
plot_datas = _load_from_revisions(repo, template_datafile, revisions)
tmp_data = []
for pd in plot_datas:
rev_data_points = pd.to_datapoints(
fields=fields,
path=path,
csv_header=csv_header,
append_index=append_index,
)
if y_anchor and not y_field:
y_field = _infer_y_field(rev_data_points, x_field)
tmp_data.extend(rev_data_points)
template_data[template_datafile] = tmp_data
if len(template_data) == 0:
raise NoDataForTemplateError(template_path)
return Template.fill(
template_path,
template_data,
priority_datafile=datafile,
x_field=x_field,
y_field=y_field,
**kwargs
)
def _infer_y_field(rev_data_points, x_field):
all_fields = list(first(rev_data_points).keys())
all_fields.remove(PlotData.REVISION_FIELD)
if x_field and x_field in all_fields:
all_fields.remove(x_field)
y_field = last(all_fields)
return y_field
def plot(
repo, datafile=None, template=None, revisions=None, embed=False, **kwargs
):
if revisions is None:
from .data import WORKSPACE_REVISION_NAME
revisions = [WORKSPACE_REVISION_NAME]
if not datafile and not template:
raise NoDataOrTemplateProvided()
template_path = _evaluate_templatepath(repo, template)
plot_content = fill_template(
repo, datafile, template_path, revisions, **kwargs
)
if embed:
div = DIV_HTML.format(id="plot", vega_json=plot_content)
plot_content = PAGE_HTML.format(divs=div)
return plot_content
def _parse_template(template_path, priority_datafile):
with open(template_path) as fobj:
tempalte_content = fobj.read()
template_datafiles = Template.parse_data_anchors(tempalte_content)
if priority_datafile:
if len(template_datafiles) > 1:
raise TooManyDataSourcesError(
priority_datafile, template_datafiles
)
template_datafiles = {priority_datafile}
return (
template_datafiles,
Template.X_ANCHOR in tempalte_content,
Template.Y_ANCHOR in tempalte_content,
)
return diff(self.repo, *args, **kwargs)
+7 -14
View File
@@ -14,8 +14,6 @@ from dvc.exceptions import DvcException, PathMissingError
logger = logging.getLogger(__name__)
WORKSPACE_REVISION_NAME = "workspace"
class PlotMetricTypeError(DvcException):
def __init__(self, file):
@@ -241,20 +239,12 @@ class YAMLPlotData(PlotData):
def _load_from_revision(repo, datafile, revision):
if revision is WORKSPACE_REVISION_NAME:
from dvc.repo.tree import RepoTree
def open_datafile():
return repo.tree.open(datafile, "r")
else:
def open_datafile():
from dvc import api
return api.open(datafile, repo.root_dir, revision)
tree = RepoTree(repo)
try:
with open_datafile() as fobj:
with tree.open(datafile) as fobj:
datafile_content = fobj.read()
except (FileNotFoundError, PathMissingError):
@@ -267,7 +257,10 @@ def _load_from_revisions(repo, datafile, revisions):
data = []
exceptions = []
for rev in revisions:
for rev in repo.brancher(revs=revisions):
if rev == "working tree" and rev not in revisions:
continue
try:
data.append(_load_from_revision(repo, datafile, rev))
except NoMetricOnRevisionError as e:
+13
View File
@@ -0,0 +1,13 @@
def _revisions(revs, is_dirty):
revisions = revs or []
if len(revisions) <= 1:
if len(revisions) == 0 and is_dirty:
revisions.append("HEAD")
revisions.append("working tree")
return revisions
def diff(repo, *args, revs=None, **kwargs):
return repo.plots.show(
*args, revs=_revisions(revs, repo.scm.is_dirty()), **kwargs
)
+214
View File
@@ -0,0 +1,214 @@
import logging
import os
from collections import defaultdict
from funcy import first, last
from dvc.exceptions import DvcException
from dvc.repo import locked
from .data import PlotData
from .template import NoDataForTemplateError, Template
logger = logging.getLogger(__name__)
class TooManyDataSourcesError(DvcException):
def __init__(self, datafile, template_datafiles):
super().__init__(
"Unable to infer which of possible data sources: '{}' "
"should be replaced with '{}'.".format(
", ".join(template_datafiles), datafile
)
)
class NoDataOrTemplateProvided(DvcException):
def __init__(self):
super().__init__("Datafile or template is not specified.")
class TooManyTemplatesError(DvcException):
pass
def _evaluate_templatepath(repo, template=None):
if not template:
return repo.plot_templates.default_template
if os.path.exists(template):
return template
return repo.plot_templates.get_template(template)
@locked
def fill_template(
repo,
datafile,
template_path,
revisions,
fields=None,
path=None,
csv_header=True,
x_field=None,
y_field=None,
**kwargs,
):
if x_field and fields:
fields.add(x_field)
if y_field and fields:
fields.add(y_field)
template_datafiles, x_anchor, y_anchor = _parse_template(
template_path, datafile
)
append_index = x_anchor and not x_field
if append_index:
x_field = PlotData.INDEX_FIELD
template_data = {}
for template_datafile in template_datafiles:
from .data import _load_from_revisions
plot_datas = _load_from_revisions(repo, template_datafile, revisions)
tmp_data = []
for pd in plot_datas:
rev_data_points = pd.to_datapoints(
fields=fields,
path=path,
csv_header=csv_header,
append_index=append_index,
)
if y_anchor and not y_field:
y_field = _infer_y_field(rev_data_points, x_field)
tmp_data.extend(rev_data_points)
template_data[template_datafile] = tmp_data
if len(template_data) == 0:
raise NoDataForTemplateError(template_path)
content = Template.fill(
template_path,
template_data,
priority_datafile=datafile,
x_field=x_field,
y_field=y_field,
**kwargs,
)
path = datafile or ",".join(template_datafiles)
return path, content
def _infer_y_field(rev_data_points, x_field):
all_fields = list(first(rev_data_points).keys())
all_fields.remove(PlotData.REVISION_FIELD)
if x_field and x_field in all_fields:
all_fields.remove(x_field)
y_field = last(all_fields)
return y_field
def _show(repo, datafile=None, template=None, revs=None, **kwargs):
if revs is None:
revs = ["working tree"]
if not datafile and not template:
raise NoDataOrTemplateProvided()
template_path = _evaluate_templatepath(repo, template)
plot_datafile, plot_content = fill_template(
repo, datafile, template_path, revs, **kwargs
)
return plot_datafile, plot_content
def _parse_template(template_path, priority_datafile):
with open(template_path) as fobj:
tempalte_content = fobj.read()
template_datafiles = Template.parse_data_anchors(tempalte_content)
if priority_datafile:
if len(template_datafiles) > 1:
raise TooManyDataSourcesError(
priority_datafile, template_datafiles
)
template_datafiles = {priority_datafile}
return (
template_datafiles,
Template.X_ANCHOR in tempalte_content,
Template.Y_ANCHOR in tempalte_content,
)
def _collect_plots(repo):
plots = defaultdict(set)
for stage in repo.stages:
for out in stage.outs:
if not out.plot:
continue
if isinstance(out.plot, dict):
template = out.plot[out.PARAM_PLOT_TEMPLATE]
else:
template = None
plots[str(out)].add(template)
return plots
def show(repo, targets=None, template=None, revs=None, **kwargs) -> dict:
if isinstance(targets, str):
targets = [targets]
if targets:
for target in targets:
return {
target: _show(
repo,
datafile=target,
template=template,
revs=revs,
**kwargs,
)[1]
}
if not revs:
plots = _collect_plots(repo)
else:
plots = defaultdict(set)
for rev in repo.brancher(revs=revs):
for plot, templates in _collect_plots(repo).items():
plots[plot].update(templates)
if not plots:
datafile, plot = _show(
repo, datafile=None, template=template, revs=revs, **kwargs
)
return {datafile: plot}
ret = {}
for plot, templates in plots.items():
tmplt = template
if len(templates) == 1:
tmplt = list(templates)[0]
elif not template:
raise TooManyTemplatesError(
f"'{plot}' uses multiple templates '{templates}'. "
"Use `-t|--template` to specify the template to use. "
)
ret[plot] = _show(
repo, datafile=plot, template=tmplt, revs=revs, **kwargs
)[1]
return ret
+2
View File
@@ -25,6 +25,8 @@ def _get_file_path(kwargs):
kwargs.get("outs_no_cache", []),
kwargs.get("metrics", []),
kwargs.get("metrics_no_cache", []),
kwargs.get("plots", []),
kwargs.get("plots_no_cache", []),
kwargs.get("outs_persist", []),
kwargs.get("outs_persist_no_cache", []),
)
+9 -5
View File
@@ -26,12 +26,12 @@ LOCK_FILE_STAGE_SCHEMA = {
LOCKFILE_SCHEMA = {str: LOCK_FILE_STAGE_SCHEMA}
OUT_PSTAGE_DETAILED_SCHEMA = {
str: {
BaseOutput.PARAM_CACHE: bool,
BaseOutput.PARAM_PERSIST: bool,
BaseOutput.PARAM_METRIC: bool,
}
str: {BaseOutput.PARAM_CACHE: bool, BaseOutput.PARAM_PERSIST: bool}
}
PLOT_PSTAGE_SCHEMA = OUT_PSTAGE_DETAILED_SCHEMA.copy()
PLOT_PSTAGE_SCHEMA[str][BaseOutput.PARAM_PLOT_TEMPLATE] = str
PARAM_PSTAGE_NON_DEFAULT_SCHEMA = {str: [str]}
SINGLE_PIPELINE_STAGE_SCHEMA = {
@@ -48,6 +48,10 @@ SINGLE_PIPELINE_STAGE_SCHEMA = {
Optional(StageParams.PARAM_OUTS): [
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
],
Optional(StageParams.PARAM_METRICS): [
Any(str, OUT_PSTAGE_DETAILED_SCHEMA)
],
Optional(StageParams.PARAM_PLOTS): [Any(str, PLOT_PSTAGE_SCHEMA)],
}
}
MULTI_STAGE_SCHEMA = {STAGES: SINGLE_PIPELINE_STAGE_SCHEMA}
+18 -4
View File
@@ -23,6 +23,7 @@ PARAM_OUTS = StageParams.PARAM_OUTS
PARAM_CACHE = BaseOutput.PARAM_CACHE
PARAM_METRIC = BaseOutput.PARAM_METRIC
PARAM_PLOT = BaseOutput.PARAM_PLOT
PARAM_PERSIST = BaseOutput.PARAM_PERSIST
DEFAULT_PARAMS_FILE = ParamsDependency.DEFAULT_PARAMS_FILE
@@ -35,10 +36,10 @@ def _get_out(out):
res = OrderedDict()
if not out.use_cache:
res[PARAM_CACHE] = False
if out.metric:
res[PARAM_METRIC] = True
elif out.persist:
if out.persist:
res[PARAM_PERSIST] = True
if out.plot and isinstance(out.plot, dict):
res.update(out.plot)
return out.def_path if not res else {out.def_path: res}
@@ -92,7 +93,20 @@ def to_pipeline_file(stage: "PipelineStage"):
(stage.PARAM_WDIR, resolve_wdir(stage.wdir, stage.path)),
(stage.PARAM_DEPS, sorted([d.def_path for d in deps])),
(stage.PARAM_PARAMS, serialized_params),
(PARAM_OUTS, _get_outs(stage.outs)),
(
PARAM_OUTS,
_get_outs(
[out for out in stage.outs if not (out.metric or out.plot)]
),
),
(
stage.PARAM_METRICS,
_get_outs([out for out in stage.outs if out.metric]),
),
(
stage.PARAM_PLOTS,
_get_outs([out for out in stage.outs if out.plot]),
),
(stage.PARAM_LOCKED, stage.locked),
(stage.PARAM_ALWAYS_CHANGED, stage.always_changed),
]
+1 -3
View File
@@ -10,9 +10,7 @@ class StageCmdFailedError(DvcException):
class StageFileFormatError(DvcException):
def __init__(self, fname, e):
msg = "DVC-file '{}' format error: {}".format(fname, str(e))
super().__init__(msg)
pass
class StageFileDoesNotExistError(DvcException):
+34 -8
View File
@@ -122,18 +122,42 @@ class StageLoader(Mapping):
)
@classmethod
def _load_outs(cls, stage, data):
def _load_outs(cls, stage, data, typ=None):
from dvc.output.base import BaseOutput
d = []
for key in data:
extra_kwargs = {}
if isinstance(key, str):
path = key
elif isinstance(key, dict):
path = first(key)
extra_kwargs = key[path]
else:
entry = {BaseOutput.PARAM_PATH: key}
if typ:
entry[typ] = True
d.append(entry)
continue
d.append({"path": path, **extra_kwargs})
assert isinstance(key, dict)
assert len(key) == 1
path = first(key)
extra = key[path]
if not typ:
d.append({BaseOutput.PARAM_PATH: path, **extra})
continue
entry = {BaseOutput.PARAM_PATH: path}
persist = extra.pop(BaseOutput.PARAM_PERSIST, False)
if persist:
entry[BaseOutput.PARAM_PERSIST] = persist
cache = extra.pop(BaseOutput.PARAM_CACHE, True)
if not cache:
entry[BaseOutput.PARAM_CACHE] = cache
entry[typ] = extra or True
d.append(entry)
stage.outs.extend(output.loadd_from(stage, d))
@classmethod
@@ -152,6 +176,8 @@ class StageLoader(Mapping):
stage.deps, stage.outs = [], []
cls._load_outs(stage, stage_data.get("outs", []))
cls._load_outs(stage, stage_data.get("metrics", []), "metric")
cls._load_outs(stage, stage_data.get("plots", []), "plot")
cls._load_deps(stage, stage_data.get("deps", []))
cls._load_params(stage, stage_data.get("params", []))
+2 -12
View File
@@ -1,6 +1,3 @@
from enum import Enum
class StageParams:
PARAM_MD5 = "md5"
PARAM_CMD = "cmd"
@@ -11,12 +8,5 @@ class StageParams:
PARAM_META = "meta"
PARAM_ALWAYS_CHANGED = "always_changed"
PARAM_PARAMS = "params"
class OutputParams(Enum):
PERSIST = "outs_persist"
PERSIST_NO_CACHE = "outs_persist_no_cache"
METRICS_NO_CACHE = "metrics_no_cache"
METRICS = "metrics"
NO_CACHE = "outs_no_cache"
OUTS = "outs"
PARAM_METRICS = "metrics"
PARAM_PLOTS = "plots"
+13 -2
View File
@@ -13,7 +13,6 @@ from .exceptions import (
StagePathNotFoundError,
StagePathOutsideError,
)
from .params import OutputParams
def check_stage_path(repo, path, is_wdir=False):
@@ -39,14 +38,26 @@ def check_stage_path(repo, path, is_wdir=False):
def fill_stage_outputs(stage, **kwargs):
assert not stage.outs
keys = [
"outs_persist",
"outs_persist_no_cache",
"metrics_no_cache",
"metrics",
"plots_no_cache",
"plots",
"outs_no_cache",
"outs",
]
stage.outs = []
for key in (p.value for p in OutputParams):
for key in keys:
stage.outs += output.loads_from(
stage,
kwargs.get(key, []),
use_cache="no_cache" not in key,
persist="persist" in key,
metric="metrics" in key,
plot="plots" in key,
)
+1 -1
View File
@@ -12,6 +12,6 @@ select=B,C,E,F,W,T4,B9
[isort]
include_trailing_comma=true
known_first_party=dvc,tests
known_third_party=PyInstaller,RangeHTTPServer,boto3,bs4,colorama,configobj,distro,dpath,flaky,flufl,funcy,git,google,grandalf,mock,mockssh,moto,nanotime,packaging,paramiko,pathspec,pytest,requests,ruamel,setuptools,shortuuid,tqdm,voluptuous,yaml,zc
known_third_party=PyInstaller,RangeHTTPServer,boto3,colorama,configobj,distro,dpath,flaky,flufl,funcy,git,google,grandalf,mock,mockssh,moto,nanotime,packaging,paramiko,pathspec,pytest,requests,ruamel,setuptools,shortuuid,tqdm,voluptuous,yaml,zc
line_length=79
multi_line_output=3
+29
View File
@@ -0,0 +1,29 @@
import json
from .test_plots import PlotData, _run_with, _write_json
def test_diff_dirty(tmp_dir, scm, dvc):
metric_1 = [{"y": 2}, {"y": 3}]
_write_json(tmp_dir, metric_1, "metric.json")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="init")
metric_2 = [{"y": 3}, {"y": 5}]
_write_json(tmp_dir, metric_2, "metric.json")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="second")
metric_3 = [{"y": 5}, {"y": 6}]
_write_json(tmp_dir, metric_3, "metric.json")
_run_with(tmp_dir, plots_no_cache=["metric.json"])
plot_string = dvc.plots.diff(fields={"y"})["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"y": 5, PlotData.INDEX_FIELD: 0, "rev": "working tree"},
{"y": 6, PlotData.INDEX_FIELD: 1, "rev": "working tree"},
{"y": 3, PlotData.INDEX_FIELD: 0, "rev": "HEAD"},
{"y": 5, PlotData.INDEX_FIELD: 1, "rev": "HEAD"},
]
assert plot_content["encoding"]["x"]["field"] == PlotData.INDEX_FIELD
assert plot_content["encoding"]["y"]["field"] == "y"
+100 -119
View File
@@ -7,15 +7,14 @@ from collections import OrderedDict
import pytest
import yaml
from bs4 import BeautifulSoup
from funcy import first
from dvc.repo.plots import NoDataOrTemplateProvided
from dvc.repo.plots.data import (
NoMetricInHistoryError,
PlotData,
PlotMetricTypeError,
)
from dvc.repo.plots.show import NoDataOrTemplateProvided
from dvc.repo.plots.template import (
NoDataForTemplateError,
NoFieldInDataError,
@@ -27,10 +26,14 @@ def _remove_whitespace(value):
return value.replace(" ", "").replace("\n", "")
def _run_with_metric(tmp_dir, metric_filename, commit=None, tag=None):
tmp_dir.dvc.run(metrics_no_cache=[metric_filename], single_stage=True)
def _run_with(tmp_dir, commit=None, tag=None, **kwargs):
stage = tmp_dir.dvc.run(single_stage=True, **kwargs)
if hasattr(tmp_dir.dvc, "scm"):
tmp_dir.dvc.scm.add([metric_filename, metric_filename + ".dvc"])
files = [stage.path]
files += [
os.fspath(out.path_info) for out in stage.outs if not out.use_cache
]
tmp_dir.dvc.scm.add(files)
if commit:
tmp_dir.dvc.scm.commit(commit)
if tag:
@@ -60,21 +63,20 @@ def test_plot_csv_one_column(tmp_dir, scm, dvc):
# no header
metric = [{"val": 2}, {"val": 3}]
_write_csv(metric, "metric.csv", header=False)
_run_with_metric(tmp_dir, metric_filename="metric.csv")
_run_with(tmp_dir, plots_no_cache=["metric.csv"])
plot_string = dvc.plot(
"metric.csv",
plot_string = dvc.plots.show(
csv_header=False,
x_title="x_title",
y_title="y_title",
title="mytitle",
)
)["metric.csv"]
plot_content = json.loads(plot_string)
assert plot_content["title"] == "mytitle"
assert plot_content["data"]["values"] == [
{"0": "2", PlotData.INDEX_FIELD: 0, "rev": "workspace"},
{"0": "3", PlotData.INDEX_FIELD: 1, "rev": "workspace"},
{"0": "2", PlotData.INDEX_FIELD: 0, "rev": "working tree"},
{"0": "3", PlotData.INDEX_FIELD: 1, "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == PlotData.INDEX_FIELD
assert plot_content["encoding"]["y"]["field"] == "0"
@@ -88,23 +90,23 @@ def test_plot_csv_multiple_columns(tmp_dir, scm, dvc):
OrderedDict([("first_val", 200), ("second_val", 300), ("val", 3)]),
]
_write_csv(metric, "metric.csv")
_run_with_metric(tmp_dir, metric_filename="metric.csv")
_run_with(tmp_dir, plots_no_cache=["metric.csv"])
plot_string = dvc.plot("metric.csv")
plot_string = dvc.plots.show()["metric.csv"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{
"val": "2",
PlotData.INDEX_FIELD: 0,
"rev": "workspace",
"rev": "working tree",
"first_val": "100",
"second_val": "100",
},
{
"val": "3",
PlotData.INDEX_FIELD: 1,
"rev": "workspace",
"rev": "working tree",
"first_val": "200",
"second_val": "300",
},
@@ -119,23 +121,23 @@ def test_plot_csv_choose_axes(tmp_dir, scm, dvc):
OrderedDict([("first_val", 200), ("second_val", 300), ("val", 3)]),
]
_write_csv(metric, "metric.csv")
_run_with_metric(tmp_dir, metric_filename="metric.csv")
_run_with(tmp_dir, plots_no_cache=["metric.csv"])
plot_string = dvc.plot(
"metric.csv", x_field="first_val", y_field="second_val"
)
plot_string = dvc.plots.show(x_field="first_val", y_field="second_val")[
"metric.csv"
]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{
"val": "2",
"rev": "workspace",
"rev": "working tree",
"first_val": "100",
"second_val": "100",
},
{
"val": "3",
"rev": "workspace",
"rev": "working tree",
"first_val": "200",
"second_val": "300",
},
@@ -147,14 +149,14 @@ def test_plot_csv_choose_axes(tmp_dir, scm, dvc):
def test_plot_json_single_val(tmp_dir, scm, dvc):
metric = [{"val": 2}, {"val": 3}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "first run")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="first run")
plot_string = dvc.plot("metric.json")
plot_string = dvc.plots.show()["metric.json"]
plot_json = json.loads(plot_string)
assert plot_json["data"]["values"] == [
{"val": 2, PlotData.INDEX_FIELD: 0, "rev": "workspace"},
{"val": 3, PlotData.INDEX_FIELD: 1, "rev": "workspace"},
{"val": 2, PlotData.INDEX_FIELD: 0, "rev": "working tree"},
{"val": 3, PlotData.INDEX_FIELD: 1, "rev": "working tree"},
]
assert plot_json["encoding"]["x"]["field"] == PlotData.INDEX_FIELD
assert plot_json["encoding"]["y"]["field"] == "val"
@@ -166,9 +168,9 @@ def test_plot_json_multiple_val(tmp_dir, scm, dvc):
{"first_val": 200, "val": 3},
]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "first run")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="first run")
plot_string = dvc.plot("metric.json")
plot_string = dvc.plots.show()["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
@@ -176,13 +178,13 @@ def test_plot_json_multiple_val(tmp_dir, scm, dvc):
"val": 2,
PlotData.INDEX_FIELD: 0,
"first_val": 100,
"rev": "workspace",
"rev": "working tree",
},
{
"val": 3,
PlotData.INDEX_FIELD: 1,
"first_val": 200,
"rev": "workspace",
"rev": "working tree",
},
]
assert plot_content["encoding"]["x"]["field"] == PlotData.INDEX_FIELD
@@ -195,19 +197,16 @@ def test_plot_confusion(tmp_dir, dvc):
{"predicted": "A", "actual": "A"},
]
_write_json(tmp_dir, confusion_matrix, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "first run")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="first run")
plot_string = dvc.plot(
datafile="metric.json",
template="confusion",
x_field="predicted",
y_field="actual",
)
plot_string = dvc.plots.show(
template="confusion", x_field="predicted", y_field="actual",
)["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"predicted": "B", "actual": "A", "rev": "workspace"},
{"predicted": "A", "actual": "A", "rev": "workspace"},
{"predicted": "B", "actual": "A", "rev": "working tree"},
{"predicted": "A", "actual": "A", "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == "predicted"
assert plot_content["encoding"]["y"]["field"] == "actual"
@@ -216,19 +215,21 @@ def test_plot_confusion(tmp_dir, dvc):
def test_plot_multiple_revs_default(tmp_dir, scm, dvc):
metric_1 = [{"y": 2}, {"y": 3}]
_write_json(tmp_dir, metric_1, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "init", "v1")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="init", tag="v1")
metric_2 = [{"y": 3}, {"y": 5}]
_write_json(tmp_dir, metric_2, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "second", "v2")
_run_with(
tmp_dir, plots_no_cache=["metric.json"], commit="second", tag="v2"
)
metric_3 = [{"y": 5}, {"y": 6}]
_write_json(tmp_dir, metric_3, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "third")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="third")
plot_string = dvc.plot(
"metric.json", fields={"y"}, revisions=["HEAD", "v2", "v1"],
)
plot_string = dvc.plots.show(fields={"y"}, revs=["HEAD", "v2", "v1"],)[
"metric.json"
]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
@@ -248,21 +249,21 @@ def test_plot_multiple_revs(tmp_dir, scm, dvc):
metric_1 = [{"y": 2}, {"y": 3}]
_write_json(tmp_dir, metric_1, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "init", "v1")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="init", tag="v1")
metric_2 = [{"y": 3}, {"y": 5}]
_write_json(tmp_dir, metric_2, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "second", "v2")
_run_with(
tmp_dir, plots_no_cache=["metric.json"], commit="second", tag="v2"
)
metric_3 = [{"y": 5}, {"y": 6}]
_write_json(tmp_dir, metric_3, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "third")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="third")
plot_string = dvc.plot(
"metric.json",
template="template.json",
revisions=["HEAD", "v2", "v1"],
)
plot_string = dvc.plots.show(
template="template.json", revs=["HEAD", "v2", "v1"],
)["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
@@ -283,11 +284,16 @@ def test_plot_even_if_metric_missing(tmp_dir, scm, dvc, caplog):
metric = [{"y": 2}, {"y": 3}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "there is metric", "v2")
_run_with(
tmp_dir,
plots_no_cache=["metric.json"],
commit="there is metric",
tag="v2",
)
caplog.clear()
with caplog.at_level(logging.WARNING, "dvc"):
plot_string = dvc.plot("metric.json", revisions=["v1", "v2"])
plot_string = dvc.plots.show(revs=["v1", "v2"])["metric.json"]
assert (
"File 'metric.json' was not found at: 'v1'. "
"It will not be plotted." in caplog.text
@@ -312,7 +318,7 @@ def test_throw_on_no_metric_at_all(tmp_dir, scm, dvc, caplog):
with pytest.raises(NoMetricInHistoryError) as error, caplog.at_level(
logging.WARNING, "dvc"
):
dvc.plot("metric.json", revisions=["v1"])
dvc.plots.show(targets="metric.json", revs=["v1"])
# do not warn if none found
assert len(caplog.messages) == 0
@@ -332,16 +338,16 @@ def custom_template(tmp_dir, dvc):
def test_custom_template(tmp_dir, scm, dvc, custom_template):
metric = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "init", "v1")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="init", tag="v1")
plot_string = dvc.plot(
"metric.json", os.fspath(custom_template), x_field="a", y_field="b"
)
plot_string = dvc.plots.show(
template=os.fspath(custom_template), x_field="a", y_field="b"
)["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"a": 1, "b": 2, "rev": "workspace"},
{"a": 2, "b": 3, "rev": "workspace"},
{"a": 1, "b": 2, "rev": "working tree"},
{"a": 2, "b": 3, "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == "a"
assert plot_content["encoding"]["y"]["field"] == "b"
@@ -360,19 +366,16 @@ def test_custom_template_with_specified_data(
metric = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "init", "v1")
_run_with(tmp_dir, outs_no_cache=["metric.json"], commit="init", tag="v1")
plot_string = dvc.plot(
datafile=None,
template=os.fspath(custom_template),
x_field="a",
y_field="b",
)
plot_string = dvc.plots.show(
template=os.fspath(custom_template), x_field="a", y_field="b",
)["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"a": 1, "b": 2, "rev": "workspace"},
{"a": 2, "b": 3, "rev": "workspace"},
{"a": 1, "b": 2, "rev": "working tree"},
{"a": 2, "b": 3, "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == "a"
assert plot_content["encoding"]["y"]["field"] == "b"
@@ -391,16 +394,16 @@ def test_plot_override_specified_data_source(tmp_dir, scm, dvc):
metric = [{"a": 1, "b": 2}, {"a": 2, "b": 3}]
_write_json(tmp_dir, metric, "metric2.json")
_run_with_metric(tmp_dir, "metric2.json", "init", "v1")
_run_with(tmp_dir, outs_no_cache=["metric2.json"], commit="init", tag="v1")
plot_string = dvc.plot(
datafile="metric2.json", template="newtemplate.json", x_field="a"
)
plot_string = dvc.plots.show(
targets=["metric2.json"], template="newtemplate.json", x_field="a"
)["metric2.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"a": 1, "b": 2, "rev": "workspace"},
{"a": 2, "b": 3, "rev": "workspace"},
{"a": 1, "b": 2, "rev": "working tree"},
{"a": 2, "b": 3, "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == "a"
assert plot_content["encoding"]["y"]["field"] == "b"
@@ -408,42 +411,41 @@ def test_plot_override_specified_data_source(tmp_dir, scm, dvc):
def test_should_raise_on_no_template_and_datafile(tmp_dir, dvc):
with pytest.raises(NoDataOrTemplateProvided):
dvc.plot()
dvc.plots.show()
def test_should_raise_on_no_template(tmp_dir, dvc):
with pytest.raises(TemplateNotFoundError):
dvc.plot("metric.json", "non_existing_template.json")
dvc.plots.show("metric.json", template="non_existing_template.json")
def test_plot_no_data(tmp_dir, dvc):
with pytest.raises(NoDataForTemplateError):
dvc.plot(template="default")
dvc.plots.show(template="default")
def test_plot_wrong_metric_type(tmp_dir, scm, dvc):
tmp_dir.scm_gen("metric.txt", "content", commit="initial")
with pytest.raises(PlotMetricTypeError):
dvc.plot(datafile="metric.txt")
dvc.plots.show(targets=["metric.txt"])
def test_plot_choose_columns(tmp_dir, scm, dvc, custom_template):
metric = [{"a": 1, "b": 2, "c": 3}, {"a": 2, "b": 3, "c": 4}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "init", "v1")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="init", tag="v1")
plot_string = dvc.plot(
"metric.json",
os.fspath(custom_template),
plot_string = dvc.plots.show(
template=os.fspath(custom_template),
fields={"b", "c"},
x_field="b",
y_field="c",
)
)["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"b": 2, "c": 3, "rev": "workspace"},
{"b": 3, "c": 4, "rev": "workspace"},
{"b": 2, "c": 3, "rev": "working tree"},
{"b": 3, "c": 4, "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == "b"
assert plot_content["encoding"]["y"]["field"] == "c"
@@ -452,63 +454,42 @@ def test_plot_choose_columns(tmp_dir, scm, dvc, custom_template):
def test_plot_default_choose_column(tmp_dir, scm, dvc):
metric = [{"a": 1, "b": 2, "c": 3}, {"a": 2, "b": 3, "c": 4}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "init", "v1")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="init", tag="v1")
plot_string = dvc.plot("metric.json", fields={"b"})
plot_string = dvc.plots.show(fields={"b"})["metric.json"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{PlotData.INDEX_FIELD: 0, "b": 2, "rev": "workspace"},
{PlotData.INDEX_FIELD: 1, "b": 3, "rev": "workspace"},
{PlotData.INDEX_FIELD: 0, "b": 2, "rev": "working tree"},
{PlotData.INDEX_FIELD: 1, "b": 3, "rev": "working tree"},
]
assert plot_content["encoding"]["x"]["field"] == PlotData.INDEX_FIELD
assert plot_content["encoding"]["y"]["field"] == "b"
def test_plot_embed(tmp_dir, scm, dvc):
metric = [{"val": 2}, {"val": 3}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "first run")
plot_string = dvc.plot("metric.json", embed=True, y_field="val")
page_content = BeautifulSoup(plot_string)
data_dump = json.dumps(
[
{"val": 2, PlotData.INDEX_FIELD: 0, "rev": "workspace"},
{"val": 3, PlotData.INDEX_FIELD: 1, "rev": "workspace"},
],
sort_keys=True,
)
assert _remove_whitespace(data_dump) in _remove_whitespace(
first(page_content.body.script.contents)
)
def test_plot_yaml(tmp_dir, scm, dvc):
metric = [{"val": 2}, {"val": 3}]
with open("metric.yaml", "w") as fobj:
yaml.dump(metric, fobj)
_run_with_metric(tmp_dir, metric_filename="metric.yaml")
_run_with(tmp_dir, plots_no_cache=["metric.yaml"])
plot_string = dvc.plot("metric.yaml",)
plot_string = dvc.plots.show()["metric.yaml"]
plot_content = json.loads(plot_string)
assert plot_content["data"]["values"] == [
{"val": 2, PlotData.INDEX_FIELD: 0, "rev": "workspace"},
{"val": 3, PlotData.INDEX_FIELD: 1, "rev": "workspace"},
{"val": 2, PlotData.INDEX_FIELD: 0, "rev": "working tree"},
{"val": 3, PlotData.INDEX_FIELD: 1, "rev": "working tree"},
]
def test_raise_on_wrong_field(tmp_dir, scm, dvc):
metric = [{"val": 2}, {"val": 3}]
_write_json(tmp_dir, metric, "metric.json")
_run_with_metric(tmp_dir, "metric.json", "first run")
_run_with(tmp_dir, plots_no_cache=["metric.json"], commit="first run")
with pytest.raises(NoFieldInDataError):
dvc.plot("metric.json", x_field="no_val")
dvc.plots.show("metric.json", x_field="no_val")
with pytest.raises(NoFieldInDataError):
dvc.plot("metric.json", y_field="no_val")
dvc.plots.show("metric.json", y_field="no_val")
+2 -5
View File
@@ -167,11 +167,8 @@ def test_run_dump_on_multistage(tmp_dir, dvc, run_head):
),
"wdir": "dir",
"deps": ["bar", "foo", "foobar"],
"outs": [
"bar-1",
{"foo-1": {"persist": True}},
{"foobar-1": {"metric": True, "cache": False}},
],
"outs": ["bar-1", {"foo-1": {"persist": True}}],
"metrics": [{"foobar-1": {"cache": False}}],
},
**data["stages"],
}
+24 -41
View File
@@ -1,24 +1,21 @@
import pytest
from dvc.cli import parse_args
from dvc.command.plots import CmdPlotsDiff, CmdPlotsShow
def test_metrics_diff(mocker):
def test_metrics_diff(dvc, mocker):
cli_args = parse_args(
[
"plots",
"diff",
"--file",
"--out",
"result.extension",
"-t",
"template",
"-d",
"--targets",
"datafile",
"--select",
"column1,column2",
"--no-html",
"--stdout",
"--show-json",
"-x",
"x_field",
"-y",
@@ -37,20 +34,17 @@ def test_metrics_diff(mocker):
assert cli_args.func == CmdPlotsDiff
cmd = cli_args.func(cli_args)
m = mocker.patch.object(cmd.repo, "plot", autospec=True)
mocker.patch("builtins.open")
mocker.patch("os.path.join")
m = mocker.patch("dvc.repo.plots.diff.diff", return_value={})
assert cmd.run() == 0
m.assert_called_once_with(
datafile="datafile",
cmd.repo,
targets=["datafile"],
template="template",
revisions=["HEAD", "tag1", "tag2"],
revs=["HEAD", "tag1", "tag2"],
fields={"column1", "column2"},
path=None,
embed=False,
x_field="x_field",
y_field="y_field",
csv_header=True,
@@ -60,19 +54,18 @@ def test_metrics_diff(mocker):
)
def test_metrics_show(mocker):
def test_metrics_show(dvc, mocker):
cli_args = parse_args(
[
"plots",
"show",
"-f",
"-o",
"result.extension",
"-t",
"template",
"-s",
"$.data",
"--no-html",
"--stdout",
"--show-json",
"--no-csv-header",
"datafile",
]
@@ -81,19 +74,16 @@ def test_metrics_show(mocker):
cmd = cli_args.func(cli_args)
m = mocker.patch.object(cmd.repo, "plot", autospec=True)
mocker.patch("builtins.open")
mocker.patch("os.path.join")
m = mocker.patch("dvc.repo.plots.show.show", return_value={})
assert cmd.run() == 0
m.assert_called_once_with(
datafile="datafile",
cmd.repo,
targets=["datafile"],
template="template",
revisions=None,
fields=None,
path="$.data",
embed=False,
x_field=None,
y_field=None,
csv_header=False,
@@ -103,20 +93,13 @@ def test_metrics_show(mocker):
)
@pytest.mark.parametrize(
"arg_revisions,is_dirty,expected_revisions",
[
([], False, ["workspace"]),
([], True, ["HEAD", "workspace"]),
(["v1", "v2", "workspace"], False, ["v1", "v2", "workspace"]),
(["v1", "v2", "workspace"], True, ["v1", "v2", "workspace"]),
],
)
def test_revisions(mocker, arg_revisions, is_dirty, expected_revisions):
args = mocker.MagicMock()
cmd = CmdPlotsDiff(args)
mocker.patch.object(args, "revisions", arg_revisions)
mocker.patch.object(cmd.repo.scm, "is_dirty", return_value=is_dirty)
assert cmd._revisions() == expected_revisions
def test_plots_show_json(dvc, mocker, caplog):
cli_args = parse_args(
["plots", "diff", "HEAD~10", "HEAD~1", "--show-json"]
)
cmd = cli_args.func(cli_args)
mocker.patch(
"dvc.repo.plots.diff.diff", return_value={"plots.csv": "plothtml"}
)
assert cmd.run() == 0
assert '{"plots.csv": "plothtml"}\n' in caplog.text
+10
View File
@@ -18,6 +18,10 @@ def test_run(mocker, dvc):
"metrics",
"--metrics-no-cache",
"metrics-no-cache",
"--plots",
"plots",
"--plots-no-cache",
"plots-no-cache",
"--file",
"file",
"--wdir",
@@ -51,6 +55,8 @@ def test_run(mocker, dvc):
outs_no_cache=["outs-no-cache"],
metrics=["metrics"],
metrics_no_cache=["metrics-no-cache"],
plots=["plots"],
plots_no_cache=["plots-no-cache"],
outs_persist=["outs-persist"],
outs_persist_no_cache=["outs-persist-no-cache"],
params=["file:param1,param2", "param3"],
@@ -78,6 +84,8 @@ def test_run_args_from_cli(mocker, dvc):
outs_no_cache=[],
metrics=[],
metrics_no_cache=[],
plots=[],
plots_no_cache=[],
outs_persist=[],
outs_persist_no_cache=[],
params=[],
@@ -105,6 +113,8 @@ def test_run_args_with_spaces(mocker, dvc):
outs_no_cache=[],
metrics=[],
metrics_no_cache=[],
plots=[],
plots_no_cache=[],
outs_persist=[],
outs_persist_no_cache=[],
params=[],
+16
View File
@@ -0,0 +1,16 @@
import pytest
from dvc.repo.plots.diff import _revisions
@pytest.mark.parametrize(
"arg_revisions,is_dirty,expected_revisions",
[
([], False, ["working tree"]),
([], True, ["HEAD", "working tree"]),
(["v1", "v2", "working tree"], False, ["v1", "v2", "working tree"]),
(["v1", "v2", "working tree"], True, ["v1", "v2", "working tree"]),
],
)
def test_revisions(mocker, arg_revisions, is_dirty, expected_revisions):
assert _revisions(arg_revisions, is_dirty) == expected_revisions