diff --git a/dvc/command/plots.py b/dvc/command/plots.py index 07761eef4..2de075cd1 100644 --- a/dvc/command/plots.py +++ b/dvc/command/plots.py @@ -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 = """ + + + DVC Plot + + + + + + {divs} + +""" + +DIV_HTML = """
+""" + 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( diff --git a/dvc/command/run.py b/dvc/command/run.py index a7a495091..77f653830 100644 --- a/dvc/command/run.py +++ b/dvc/command/run.py @@ -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="", ) + run_parser.add_argument( + "--plots", + action="append", + default=[], + help="Declare output plot file.", + metavar="", + ) + run_parser.add_argument( + "--plots-no-cache", + action="append", + default=[], + help="Declare output plot file (do not put into DVC cache).", + metavar="", + ) run_parser.add_argument( "-f", "--file", diff --git a/dvc/dvcfile.py b/dvc/dvcfile.py index 513ec76d4..c2722c7a7 100644 --- a/dvc/dvcfile.py +++ b/dvc/dvcfile.py @@ -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): diff --git a/dvc/output/__init__.py b/dvc/output/__init__.py index 43d2d2a28..492b0e486 100644 --- a/dvc/output/__init__.py +++ b/dvc/output/__init__.py @@ -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 ] diff --git a/dvc/output/base.py b/dvc/output/base.py index ce00bd653..fc9b52fdb 100644 --- a/dvc/output/base.py +++ b/dvc/output/base.py @@ -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 diff --git a/dvc/output/local.py b/dvc/output/local.py index 2ca840135..f84d3478b 100644 --- a/dvc/output/local.py +++ b/dvc/output/local.py @@ -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)) diff --git a/dvc/repo/__init__.py b/dvc/repo/__init__.py index 6c4b56f74..213cbdcb4 100644 --- a/dvc/repo/__init__.py +++ b/dvc/repo/__init__.py @@ -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() diff --git a/dvc/repo/brancher.py b/dvc/repo/brancher.py index 67a3b19b3..0fbf2f403 100644 --- a/dvc/repo/brancher.py +++ b/dvc/repo/brancher.py @@ -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: diff --git a/dvc/repo/plots/__init__.py b/dvc/repo/plots/__init__.py index 11219d801..5f06b7491 100644 --- a/dvc/repo/plots/__init__.py +++ b/dvc/repo/plots/__init__.py @@ -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 = """ - - - DVC Plot - - - - - - {divs} - -""" - -DIV_HTML = """
-""" - - -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) diff --git a/dvc/repo/plots/data.py b/dvc/repo/plots/data.py index 4994aea33..7fb811f1d 100644 --- a/dvc/repo/plots/data.py +++ b/dvc/repo/plots/data.py @@ -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: diff --git a/dvc/repo/plots/diff.py b/dvc/repo/plots/diff.py new file mode 100644 index 000000000..e03e47114 --- /dev/null +++ b/dvc/repo/plots/diff.py @@ -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 + ) diff --git a/dvc/repo/plots/show.py b/dvc/repo/plots/show.py new file mode 100644 index 000000000..225b1dc82 --- /dev/null +++ b/dvc/repo/plots/show.py @@ -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 diff --git a/dvc/repo/run.py b/dvc/repo/run.py index b8be523fa..0b6f0354f 100644 --- a/dvc/repo/run.py +++ b/dvc/repo/run.py @@ -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", []), ) diff --git a/dvc/schema.py b/dvc/schema.py index 429e006af..affc63d75 100644 --- a/dvc/schema.py +++ b/dvc/schema.py @@ -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} diff --git a/dvc/serialize.py b/dvc/serialize.py index 00487f92f..b3648f596 100644 --- a/dvc/serialize.py +++ b/dvc/serialize.py @@ -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), ] diff --git a/dvc/stage/exceptions.py b/dvc/stage/exceptions.py index 6c267a832..4be365238 100644 --- a/dvc/stage/exceptions.py +++ b/dvc/stage/exceptions.py @@ -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): diff --git a/dvc/stage/loader.py b/dvc/stage/loader.py index 4f30a9d44..d973f31a9 100644 --- a/dvc/stage/loader.py +++ b/dvc/stage/loader.py @@ -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", [])) diff --git a/dvc/stage/params.py b/dvc/stage/params.py index 9ae388636..a7885541e 100644 --- a/dvc/stage/params.py +++ b/dvc/stage/params.py @@ -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" diff --git a/dvc/stage/utils.py b/dvc/stage/utils.py index 38a68390c..6312c88da 100644 --- a/dvc/stage/utils.py +++ b/dvc/stage/utils.py @@ -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, ) diff --git a/setup.cfg b/setup.cfg index 0e5233000..723c7fa14 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/tests/func/plots/test_diff.py b/tests/func/plots/test_diff.py new file mode 100644 index 000000000..e488ec75f --- /dev/null +++ b/tests/func/plots/test_diff.py @@ -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" diff --git a/tests/func/plots/test_plots.py b/tests/func/plots/test_plots.py index a8be76a83..e8923a437 100644 --- a/tests/func/plots/test_plots.py +++ b/tests/func/plots/test_plots.py @@ -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") diff --git a/tests/func/test_run_multistage.py b/tests/func/test_run_multistage.py index 078afa888..75cad0d8a 100644 --- a/tests/func/test_run_multistage.py +++ b/tests/func/test_run_multistage.py @@ -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"], } diff --git a/tests/unit/command/test_plots.py b/tests/unit/command/test_plots.py index ac10e42b6..405492eff 100644 --- a/tests/unit/command/test_plots.py +++ b/tests/unit/command/test_plots.py @@ -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 diff --git a/tests/unit/command/test_run.py b/tests/unit/command/test_run.py index 00cd61fdf..87695291a 100644 --- a/tests/unit/command/test_run.py +++ b/tests/unit/command/test_run.py @@ -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=[], diff --git a/tests/unit/repo/plots/test_diff.py b/tests/unit/repo/plots/test_diff.py new file mode 100644 index 000000000..2b16e71e4 --- /dev/null +++ b/tests/unit/repo/plots/test_diff.py @@ -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