Repository: iterative/dvc

Issue:
no repro metrics error
```
$ dvc -V
1.7.9+7b590e
```

I created a metrics file and added it to `dvc.yaml` without rerunning a pipeline. The metrics diff gives me a strange error:

```
$ dvc metrics diff
ERROR: unexpected error - while constructing a mapping
  in "<unicode string>", line 3, column 5
found duplicate key "metrics" with value "[]" (original value: "[]")
  in "<unicode string>", line 20, column 5

To suppress this check see:
    http://yaml.readthedocs.io/en/latest/api.html#duplicate-keys

Duplicate keys will become an error in future releases, and are errors
by default when using the new API.


Having any troubles? Hit us up at https://dvc.org/support, we are always happy to help!
DVC version: 1.7.9+7b590e
---------------------------------
Platform: Python 3.7.3 on Darwin-19.6.0-x86_64-i386-64bit
Supports: azure, gdrive, gs, hdfs, http, https, s3, ssh, oss
Cache types: reflink, hardlink, symlink
Cache directory: apfs on /dev/disk1s5
Workspace directory: apfs on /dev/disk1s5
Repo: dvc, git
```

```
$ dvc metrics diff -v
2020-10-05 09:00:38,005 DEBUG: Check for update is enabled.
2020-10-05 09:00:38,039 DEBUG: fetched: [(3,)]
2020-10-05 09:00:38,048 DEBUG: fetched: [(682,)]
2020-10-05 09:00:38,049 ERROR: unexpected error - while constructing a mapping
  in "<unicode string>", line 3, column 5
found duplicate key "metrics" with value "[]" (original value: "[]")
  in "<unicode string>", line 20, column 5

To suppress this check see:
    http://yaml.readthedocs.io/en/latest/api.html#duplicate-keys

Duplicate keys will become an error in future releases, and are errors
by default when using the new API.

------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/dmitry/src/dvc/dvc/main.py", line 76, in main
    ret = cmd.run()
  File "/Users/dmitry/src/dvc/dvc/command/metrics.py", line 119, in run
    all=self.args.all,
  File "/Users/dmitry/src/dvc/dvc/repo/metrics/__init__.py", line 13, in diff
    return diff(self.repo, *args, **kwargs)
  File "/Users/dmitry/src/dvc/dvc/repo/metrics/diff.py", line 22, in diff
    old = _get_metrics(repo, *args, **kwargs, rev=(a_rev or "HEAD"))
  File "/Users/dmitry/src/dvc/dvc/repo/metrics/diff.py", line 9, in _get_metrics
    *args, **kwargs, revs=[rev] if rev else None
  File "/Users/dmitry/src/dvc/dvc/repo/metrics/__init__.py", line 8, in show
    return show(self.repo, *args, **kwargs)
  File "/Users/dmitry/src/dvc/dvc/repo/__init__.py", line 51, in wrapper
    return f(repo, *args, **kwargs)
  File "/Users/dmitry/src/dvc/dvc/repo/metrics/show.py", line 108, in show
    metrics = _collect_metrics(repo, targets, recursive)
  File "/Users/dmitry/src/dvc/dvc/repo/metrics/show.py", line 33, in _collect_metrics
    for stage in repo.stages:
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/funcy/objects.py", line 28, in __get__
    res = instance.__dict__[self.fget.__name__] = self.fget(instance)
  File "/Users/dmitry/src/dvc/dvc/repo/__init__.py", line 554, in stages
    return self._collect_stages()
  File "/Users/dmitry/src/dvc/dvc/repo/__init__.py", line 562, in _collect_stages
    new_stages = self.get_stages(os.path.join(root, file_name))
  File "/Users/dmitry/src/dvc/dvc/repo/__init__.py", line 254, in get_stages
    return list(dvcfile.stages.values())
  File "/Users/dmitry/src/dvc/dvc/dvcfile.py", line 228, in stages
    data, _ = self._load()
  File "/Users/dmitry/src/dvc/dvc/dvcfile.py", line 107, in _load
    d = parse_yaml(stage_text, self.path)
  File "/Users/dmitry/src/dvc/dvc/utils/serialize/_yaml.py", line 24, in parse_yaml
    return yaml.load(text) or {}
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/main.py", line 343, in load
    return constructor.get_single_data()
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 113, in get_single_data
    return self.construct_document(node)
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 123, in construct_document
    for _dummy in generator:
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 723, in construct_yaml_map
    value = self.construct_mapping(node)
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 440, in construct_mapping
    return BaseConstructor.construct_mapping(self, node, deep=deep)
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 257, in construct_mapping
    if self.check_mapping_key(node, key_node, mapping, key, value):
  File "/Users/dmitry/virtenvs/ml/lib/python3.7/site-packages/ruamel/yaml/constructor.py", line 295, in check_mapping_key
    raise DuplicateKeyError(*args)
ruamel.yaml.constructor.DuplicateKeyError: while constructing a mapping
  in "<unicode string>", line 3, column 5
found duplicate key "metrics" with value "[]" (original value: "[]")
  in "<unicode string>", line 20, column 5

To suppress this check see:
    http://yaml.readthedocs.io/en/latest/api.html#duplicate-keys

Duplicate keys will become an error in future releases, and are errors
by default when using the new API.

------------------------------------------------------------

Having any troubles? Hit us up at https://dvc.org/support, we are always happy to help!
DVC version: 1.7.9+7b590e
---------------------------------
Platform: Python 3.7.3 on Darwin-19.6.0-x86_64-i386-64bit
Supports: azure, gdrive, gs, hdfs, http, https, s3, ssh, oss
Cache types: reflink, hardlink, symlink
Cache directory: apfs on /dev/disk1s5
Workspace directory: apfs on /dev/disk1s5
Repo: dvc, git
2020-10-05 09:00:38,885 DEBUG: Analytics is enabled.
2020-10-05 09:00:39,063 DEBUG: Trying to spawn '['daemon', '-q', 'analytics', '/var/folders/hq/h82mgrhx37nc89_1rcg6gm280000gn/T/tmpivw63jns']'
2020-10-05 09:00:39,065 DEBUG: Spawned '['daemon', '-q', 'analytics', '/var/folders/hq/h82mgrhx37nc89_1rcg6gm280000gn/T/tmpivw63jns']
```

PS: why there is a `dvc version` output in the error message?


Discussion:
@dmpetrov, can you post the dvc.yaml file? From the error,  it seems there are 2 "metrics" keys in the file. 
```
stages:
  train:
    cmd: python train.py
    deps:
    - users.csv
    - train.py
    - mymodel.py
    - elog
    params:
    - epochs
    - log_file
    - dvc_logs_dir
    - dropout
    outs:
    - logs
    - dvc_logs
    metrics:
    - summary.json:
        cache: false
    metrics:
    - cust_metr.json:
        cache: false
    plots:
    - logs.csv:
        cache: false
```
There are 2 metrics key, just merge them under one "metrics".

```yaml
    metrics:
    - summary.json:
           cache: false
    - cust_metr.json:
            cache: false
```
@skshetry Good catch! Could we catch exceptions like that and raise something friendlier so it is not an "unexpected error"?

EDIT: I see we do wrap them https://github.com/iterative/dvc/blob/52fe76cefcc7a1eafe6f8a11b5863a64425b5e26/dvc/utils/serialize/_yaml.py#L23 , but this exception simply doesn't inherit from YAMLError, so it just falls through the wrapper. :slightly_frowning_face: 
It would be great to handle the error properly. Ideally to say something like `'dvc.yaml' parsing error: 'metrics' duplication in 'train' stage`

Resolving patch:
diff --git a/dvc/command/status.py b/dvc/command/status.py
--- a/dvc/command/status.py
+++ b/dvc/command/status.py
@@ -72,6 +72,6 @@ def run(self):
                 logger.info(self.UP_TO_DATE_MSG)
 
         except DvcException:
-            logger.exception("failed to obtain data status")
+            logger.exception("")
             return 1
         return 0
diff --git a/dvc/utils/serialize/_yaml.py b/dvc/utils/serialize/_yaml.py
--- a/dvc/utils/serialize/_yaml.py
+++ b/dvc/utils/serialize/_yaml.py
@@ -4,12 +4,17 @@
 
 from funcy import reraise
 from ruamel.yaml import YAML
-from ruamel.yaml.error import YAMLError
+from ruamel.yaml.constructor import DuplicateKeyError
+from ruamel.yaml.error import YAMLError as _YAMLError
 
 from ._common import ParseError, _dump_data, _load_data, _modify_data
 
 
-class YAMLFileCorruptedError(ParseError):
+class YAMLError(ParseError):
+    pass
+
+
+class YAMLFileCorruptedError(YAMLError):
     def __init__(self, path):
         super().__init__(path, "YAML file structure is corrupted")
 
@@ -20,8 +25,14 @@ def load_yaml(path, tree=None):
 
 def parse_yaml(text, path, typ="safe"):
     yaml = YAML(typ=typ)
-    with reraise(YAMLError, YAMLFileCorruptedError(path)):
-        return yaml.load(text) or {}
+    try:
+        with reraise(_YAMLError, YAMLFileCorruptedError(path)):
+            return yaml.load(text) or {}
+    except DuplicateKeyError as exc:
+        # NOTE: unfortunately this one doesn't inherit from YAMLError, so we
+        # have to catch it by-hand. See
+        # https://yaml.readthedocs.io/en/latest/api.html#duplicate-keys
+        raise YAMLError(path, exc.problem)
 
 
 def parse_yaml_for_update(text, path):


Test patch:
diff --git a/tests/unit/utils/serialize/__init__.py b/tests/unit/utils/serialize/__init__.py
new file mode 100644
diff --git a/tests/unit/utils/serialize/test_yaml.py b/tests/unit/utils/serialize/test_yaml.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/utils/serialize/test_yaml.py
@@ -0,0 +1,14 @@
+import pytest
+
+from dvc.utils.serialize._yaml import YAMLError, parse_yaml
+
+
+def test_parse_yaml_duplicate_key_error():
+    text = """\
+    mykey:
+    - foo
+    mykey:
+    - bar
+    """
+    with pytest.raises(YAMLError, match='found duplicate key "mykey"'):
+        parse_yaml(text, "mypath")