Repository: iterative/dvc

Issue:
Cannot plot with windows paths syntax
# Bug Report

## Windows path syntax

I work on windows and tried to display some plots, here is my `dvc.yaml` file :

```stages:
  train:
    cmd: python .\src\train.py
    deps:
    - .\data\train.csv
    params:
    - train.batch_size
    - train.seed
    outs:
    - .\train_out
    metrics:
    - .\dvclive\metrics.json:
        cache: false
    plots:
    - .\dvclive\plots\metrics\train_loss.tsv:
        cache: false
        x: step
        y: train_loss
    - .\dvclive\plots\metrics\train_acc.tsv:
        cache: false
        x: step
        y: train_acc
```
Notice that my paths have a Windows syntax here.

If I run `dvc plots show -v --json` it outputs :

```
{
  ".\\dvclive\\plots\\metrics\\train_loss.tsv": [],
  ".\\dvclive\\plots\\metrics\\train_acc.tsv": []
}
2022-12-13 13:48:49,678 DEBUG: Analytics is enabled.
2022-12-13 13:48:49,681 DEBUG: Trying to spawn '['daemon', '-q', 'analytics', 'C:\\Users\\sansel\\AppData\\Local\\Temp\\tmpleidc5tk']'
2022-12-13 13:48:49,684 DEBUG: Spawned '['daemon', '-q', 'analytics', 'C:\\Users\\sansel\\AppData\\Local\\Temp\\tmpleidc5tk']'
```
Then if I replace `\` with `/` in my paths like so :

```
stages:
  train:
    cmd: python ./src/train.py
    deps:
    - ./data/train.csv
    params:
    - train.batch_size
    - train.seed
    outs:
    - train_out
    metrics:
    - ./dvclive/metrics.json:
        cache: false
    plots:
    - ./dvclive/plots/metrics/train_loss.tsv:
        cache: false
        x: step
        y: train_loss
    - ./dvclive/plots/metrics/train_acc.tsv:
        cache: false
        x: step
        y: train_acc
```
Now DVC is able to correctly display plots.

### Expected

You would expect Windows path syntax to work on Windows

### Environment information

**Output of `dvc doctor`:**

```console
$ dvc doctor
```

```
DVC version: 2.34.0 (exe)
---------------------------------
Platform: Python 3.10.8 on Windows-10-10.0.19044-SP0
Subprojects:

Supports:
        azure (adlfs = 2022.10.0, knack = 0.10.0, azure-identity = 1.12.0),
        gdrive (pydrive2 = 1.14.0),
        gs (gcsfs = 2022.11.0),
        hdfs (fsspec = 2022.11.0, pyarrow = 10.0.0),
        http (aiohttp = 3.8.3, aiohttp-retry = 2.8.3),
        https (aiohttp = 3.8.3, aiohttp-retry = 2.8.3),
        oss (ossfs = 2021.8.0),
        s3 (s3fs = 2022.11.0, boto3 = 1.24.59),
        ssh (sshfs = 2022.6.0),
        webdav (webdav4 = 0.9.8),
        webdavs (webdav4 = 0.9.8),
        webhdfs (fsspec = 2022.11.0)
Cache types: hardlink
Cache directory: NTFS on C:\
Caches: local
Remotes: None
Workspace directory: NTFS on C:\
Repo: dvc, git

You are using dvc version 2.34.0; however, version 2.37.0 is available.
To upgrade, uninstall dvc and reinstall from https://dvc.org.
```


Discussion:
See my [original comment](https://github.com/iterative/dvc/issues/2059#issuecomment-1308197586) where I provided a repro for a related issue. I have since renamed that repro to reflect its relationship to the current issue number 8689. Here's that [repro](https://github.com/blakeNaccarato/iterative-dvc-8689) again. It breaks a plot when there's a `\` in the path. The plot `path\fail.png` doesn't show up in rendered HTML of `dvc plots show`, while `path/pass.png` does. The same goes for the plot rendering in the DVC VSCode extension. In [fix-offending-plot](https://github.com/blakeNaccarato/iterative-dvc-8689/tree/fix-offending-plot) the `\` is swapped for `/`, then in [remove-offending-plot](https://github.com/blakeNaccarato/iterative-dvc-8689/tree/remove-offending-plot) the offending plot is commented out of `dvc.yaml`. Each branch has the output of `dvc plots show` committed as well.

It is not an exhaustive reproduction for this now broader issue, but represents one way in which Windows paths can mess things up.

### Workaround

Here's an updated, generalized workaround since I last commented over in the other issue. Supply all paths in `params.yaml` under a key of your choosing (e.g. `dirs`), then inject them into `dvc.yaml` using DVC's built-in [templating support](https://dvc.org/doc/user-guide/project-structure/dvcyaml-files#templating) (e.g. `${dirs.<...>}`). Now you can watch your `params.yaml` for changes and always run a script like the `repl_path_in_params.py` below to normalize any `\` to `/` _before_ those parameters get injected into `dvc.yaml`. It's a bit of a bandaid solution, but it makes sure your paths are acceptable by DVC.

See below for an example normalizing script, parameters file, and `dvc.yaml` file.

<details><summary>repl_path_in_params.py</summary>

```Python
"""Replace Windows path separators with POSIX paths in configuration file."""

from pathlib import Path

from ruamel.yaml import YAML

PARAMS_FILE = "params.yaml"
DIRS_KEY = "dirs"

yaml = YAML()
yaml.indent(offset=2)


def main():
    params = yaml.load(PARAMS_FILE)
    params[DIRS_KEY] = repl_path(params[DIRS_KEY])
    yaml.dump(params, PARAMS_FILE)


def repl_path(dirs_dict: dict[str, Path]):
    """Replace Windows path separator with POSIX separator."""
    return {k: str(v).replace("\\", "/") for k, v in dirs_dict.items()}


if __name__ == "__main__":
    main()

```

</details>

<details><summary>params.yaml</summary>

```YAML
dirs:
  plot_new_fit_0: data/metrics/plots/new_fit_0.png
  plot_new_fit_1: data/metrics/plots/new_fit_1.png
  plot_new_fit_2: data/metrics/plots/new_fit_2.png
  file_pipeline_metrics: data/metrics/tables/pipeline_metrics.json
other_params:
  ...
```

</details>

</details>

<details><summary>dvc.yaml</summary>

```YAML
  ...
  metrics:
    cmd: "<metrics_cmd>"
    deps:
      - ...
    metrics:
      - "${dirs.file_pipeline_metrics}"
    plots:
      - "${dirs.plot_new_fit_0}"
      - "${dirs.plot_new_fit_1}"
      - "${dirs.plot_new_fit_2}"
    params:
      - ...
  ...
```

</details>
Thanks for the workaround @blakeNaccarato 🙏 
I will try to look into a proper fix when I find the time
The most tricky thing about this bug is that `dvc stage add` happily adds stages with backslashes in the path and `dvc repro` and `dvc exp run` run the stages without errors but then, suddenly, there are no plots. 

Resolving patch:
diff --git a/dvc/commands/plots.py b/dvc/commands/plots.py
--- a/dvc/commands/plots.py
+++ b/dvc/commands/plots.py
@@ -76,10 +76,6 @@ def _props(self):
         props = {p: getattr(self.args, p) for p in PLOT_PROPS}
         return {k: v for k, v in props.items() if v is not None}
 
-    def _config_files(self):
-        if self.args.from_config:
-            return {self.args.from_config}
-
     def _html_template_path(self):
         html_template_path = self.args.html_template
         if not html_template_path:
@@ -114,7 +110,6 @@ def run(self) -> int:  # noqa: C901, PLR0911, PLR0912
             plots_data = self._func(
                 targets=self.args.targets,
                 props=self._props(),
-                config_files=self._config_files(),
             )
 
             if not plots_data:
@@ -412,9 +407,3 @@ def _add_ui_arguments(parser):
         help="Custom HTML template for VEGA visualization.",
         metavar="<path>",
     )
-    parser.add_argument(
-        "--from-config",
-        default=None,
-        metavar="<path>",
-        help=argparse.SUPPRESS,
-    )
diff --git a/dvc/render/match.py b/dvc/render/match.py
--- a/dvc/render/match.py
+++ b/dvc/render/match.py
@@ -1,3 +1,4 @@
+import os
 from collections import defaultdict
 from typing import TYPE_CHECKING, Dict, List, Optional
 
@@ -5,7 +6,7 @@
 import dpath.options
 from funcy import last
 
-from dvc.repo.plots import infer_data_sources
+from dvc.repo.plots import _normpath, infer_data_sources
 from dvc.utils.plots import get_plot_id
 
 from .convert import _get_converter
@@ -43,17 +44,20 @@ def group_definitions(self):
 
     def get_definition_data(self, target_files, rev):
         result = {}
-        for file in target_files:
+        for definition_file in target_files:
+            if os.name == "nt":
+                source_file = _normpath(definition_file).replace("\\", "/")
+            else:
+                source_file = definition_file
             file_content = (
                 self.data.get(rev, {})
                 .get("sources", {})
                 .get("data", {})
-                .get(file, {})
+                .get(source_file, {})
                 .get("data", {})
             )
             if file_content:
-                result[file] = file_content
-
+                result[definition_file] = file_content
         return result
 
 
diff --git a/dvc/repo/plots/__init__.py b/dvc/repo/plots/__init__.py
--- a/dvc/repo/plots/__init__.py
+++ b/dvc/repo/plots/__init__.py
@@ -2,7 +2,7 @@
 import io
 import logging
 import os
-from collections import OrderedDict, defaultdict
+from collections import defaultdict
 from copy import deepcopy
 from functools import partial
 from multiprocessing import cpu_count
@@ -74,7 +74,6 @@ def collect(
         recursive: bool = False,
         onerror: Optional[Callable] = None,
         props: Optional[Dict] = None,
-        config_files: Optional[Set[str]] = None,
     ) -> Iterator[Dict]:
         """Collects plots definitions and data sources.
 
@@ -131,7 +130,6 @@ def collect(
                 targets=targets,
                 revision=rev,
                 onerror=onerror,
-                config_files=config_files,
                 props=props,
             )
             if definitions:
@@ -186,7 +184,6 @@ def show(
         props=None,
         recursive=False,
         onerror=None,
-        config_files: Optional[Set[str]] = None,
     ):
         if onerror is None:
             onerror = onerror_collect
@@ -198,7 +195,6 @@ def show(
             recursive,
             onerror=onerror,
             props=props,
-            config_files=config_files,
         ):
             _resolve_data_sources(data)
             result.update(data)
@@ -482,7 +478,6 @@ def _collect_pipeline_files(repo, targets: List[str], props, onerror=None):
 def _collect_definitions(
     repo: "Repo",
     targets=None,
-    config_files: Optional[Set[str]] = None,
     props: Optional[Dict] = None,
     onerror: Optional[Callable] = None,
     **kwargs,
@@ -494,34 +489,15 @@ def _collect_definitions(
 
     fs = DVCFileSystem(repo=repo)
 
-    if not config_files:
-        dpath.merge(
-            result,
-            _collect_pipeline_files(repo, targets, props, onerror=onerror),
-        )
-
-    if targets or (not targets and not config_files):
-        dpath.merge(
-            result,
-            _collect_output_plots(repo, targets, props, onerror=onerror),
-        )
+    dpath.merge(
+        result,
+        _collect_pipeline_files(repo, targets, props, onerror=onerror),
+    )
 
-    if config_files:
-        for path in config_files:
-            definitions = parse(fs, path).get("data", {})
-            if definitions:
-                resolved = _resolve_definitions(
-                    repo.dvcfs,
-                    targets,
-                    props,
-                    path,
-                    definitions,
-                    onerror=onerror,
-                )
-                dpath.merge(
-                    result,
-                    {path: resolved},
-                )
+    dpath.merge(
+        result,
+        _collect_output_plots(repo, targets, props, onerror=onerror),
+    )
 
     for target in targets:
         if not result or fs.exists(target):
@@ -581,21 +557,14 @@ def _load_sv(path, fs, delimiter=",", header=True):
     with fs.open(path, "r") as fd:
         content = fd.read()
 
-    first_row = first(csv.reader(io.StringIO(content)))
-
     if header:
         reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
     else:
+        first_row = first(csv.reader(io.StringIO(content)))
         reader = csv.DictReader(
             io.StringIO(content),
             delimiter=delimiter,
             fieldnames=[str(i) for i in range(len(first_row))],
         )
 
-    fieldnames = reader.fieldnames or []
-    data = list(reader)
-
-    return [
-        OrderedDict([(field, data_point[field]) for field in fieldnames])
-        for data_point in data
-    ]
+    return list(reader)


Test patch:
diff --git a/tests/func/plots/test_show.py b/tests/func/plots/test_show.py
--- a/tests/func/plots/test_show.py
+++ b/tests/func/plots/test_show.py
@@ -1,3 +1,4 @@
+import json
 import os
 
 import pytest
@@ -9,7 +10,7 @@
 from dvc.repo.plots import PlotMetricTypeError
 from dvc.utils import onerror_collect
 from dvc.utils.fs import remove
-from dvc.utils.serialize import EncodingError, YAMLFileCorruptedError
+from dvc.utils.serialize import EncodingError, YAMLFileCorruptedError, modify_yaml
 from tests.utils.plots import get_plot
 
 
@@ -351,14 +352,11 @@ def test_collect_non_existing_dir(tmp_dir, dvc, run_copy_metrics):
         ),
     ],
 )
-@pytest.mark.parametrize("separate_config", [True, False])
-def test_load_from_config(
+def test_top_level_plots(
     tmp_dir,
     dvc,
     plot_config,
     expected_datafiles,
-    separate_config,
-    run_copy_metrics,
 ):
     data = {
         "data1.json": [
@@ -377,23 +375,11 @@ def test_load_from_config(
             os.makedirs(dirname)
         (tmp_dir / filename).dump_json(content, sort_keys=True)
 
-    config_files = None
-    if separate_config:
-        (tmp_dir / "plot_config.json").dump_json(plot_config, sort_keys=True)
-        config_file = "plot_config.json"
-        config_files = {config_file}
-    else:
-        # TODO we need that to create any stage, as dvc.yaml plots
-        #     collections bases on existing stages - fix collection
-        run_copy_metrics("data1.json", "copy.json", name="train")
-
-        from dvc.utils.serialize import modify_yaml
-
-        config_file = "dvc.yaml"
-        with modify_yaml(config_file) as dvcfile_content:
-            dvcfile_content["plots"] = plot_config
+    config_file = "dvc.yaml"
+    with modify_yaml(config_file) as dvcfile_content:
+        dvcfile_content["plots"] = plot_config
 
-    result = dvc.plots.show(config_files=config_files)
+    result = dvc.plots.show()
 
     assert plot_config == get_plot(
         result, "workspace", typ="definitions", file=config_file
@@ -404,3 +390,33 @@ def test_load_from_config(
             assert content == get_plot(result, "workspace", file=filename)
         else:
             assert filename not in get_plot(result, "workspace")
+
+
+def test_show_plots_defined_with_native_os_path(tmp_dir, dvc, scm, capsys):
+    """Regression test for #8689"""
+    top_level_plot = os.path.join("subdir", "top_level_plot.csv")
+    stage_plot = os.path.join("subdir", "stage_plot.csv")
+    (tmp_dir / "subdir").mkdir()
+    (tmp_dir / top_level_plot).write_text("foo,bar\n1,2")
+    (tmp_dir / stage_plot).write_text("foo,bar\n1,2")
+    (tmp_dir / "dvc.yaml").dump({"plots": [top_level_plot]})
+
+    dvc.stage.add(name="foo", plots=[stage_plot], cmd="echo foo")
+
+    plots = dvc.plots.show()
+
+    # sources are in posixpath format
+    sources = plots["workspace"]["sources"]["data"]
+    assert sources["subdir/top_level_plot.csv"]["data"] == [{"foo": "1", "bar": "2"}]
+    assert sources["subdir/stage_plot.csv"]["data"] == [{"foo": "1", "bar": "2"}]
+    # definitions are in native os format
+    definitions = plots["workspace"]["definitions"]["data"]
+    assert top_level_plot in definitions["dvc.yaml"]["data"]
+    assert stage_plot in definitions[""]["data"]
+
+    capsys.readouterr()
+    assert main(["plots", "show", "--json"]) == 0
+    out, _ = capsys.readouterr()
+    json_out = json.loads(out)
+    assert json_out[f"dvc.yaml::{top_level_plot}"]
+    assert json_out[stage_plot]
diff --git a/tests/unit/command/test_plots.py b/tests/unit/command/test_plots.py
--- a/tests/unit/command/test_plots.py
+++ b/tests/unit/command/test_plots.py
@@ -53,8 +53,6 @@ def test_plots_diff(dvc, mocker, plots_data):
             "HEAD",
             "tag1",
             "tag2",
-            "--from-config",
-            "path_to_config",
         ]
     )
     assert cli_args.func == CmdPlotsDiff
@@ -78,7 +76,6 @@ def test_plots_diff(dvc, mocker, plots_data):
             "y_label": "y_title",
         },
         experiment=True,
-        config_files={"path_to_config"},
     )
     render_mock.assert_not_called()
 
@@ -104,7 +101,6 @@ def test_plots_show_vega(dvc, mocker, plots_data):
     m = mocker.patch(
         "dvc.repo.plots.Plots.show",
         return_value=plots_data,
-        config_files=None,
     )
     render_mock = mocker.patch("dvc_render.render_html", return_value="html_path")
 
@@ -113,7 +109,6 @@ def test_plots_show_vega(dvc, mocker, plots_data):
     m.assert_called_once_with(
         targets=["datafile"],
         props={"template": "template", "header": False},
-        config_files=None,
     )
     render_mock.assert_not_called()