mirror of
https://github.com/Stone-Red-Code/dvc.git
synced 2026-11-02 07:18:25 +01:00
better exception messages for schema errors (#6679)
* Generate prettier schema error messages * add test for #6285
This commit is contained in:
+16
-31
@@ -1,7 +1,7 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Callable, Tuple, Type, TypeVar, Union
|
||||
from typing import TYPE_CHECKING, Any, Callable, Tuple, TypeVar, Union
|
||||
|
||||
from dvc.exceptions import DvcException
|
||||
from dvc.parsing.versions import LOCKFILE_VERSION, SCHEMA_KWD
|
||||
@@ -9,14 +9,12 @@ from dvc.stage import serialize
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileBadNameError,
|
||||
StageFileDoesNotExistError,
|
||||
StageFileFormatError,
|
||||
StageFileIsNotDvcFileError,
|
||||
)
|
||||
from dvc.types import AnyPath
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.collections import apply_diff
|
||||
from dvc.utils.serialize import dump_yaml, modify_yaml
|
||||
from dvc.utils.strictyaml import YAMLValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dvc.repo import Repo
|
||||
@@ -39,11 +37,6 @@ class FileIsGitIgnored(DvcException):
|
||||
)
|
||||
|
||||
|
||||
class LockfileCorruptedError(DvcException):
|
||||
def __init__(self, path):
|
||||
super().__init__(f"Lockfile '{path}' is corrupted.")
|
||||
|
||||
|
||||
class ParametrizedDumpError(DvcException):
|
||||
pass
|
||||
|
||||
@@ -92,7 +85,6 @@ def check_dvcfile_path(repo, path):
|
||||
|
||||
class FileMixin:
|
||||
SCHEMA: Callable[[_T], _T]
|
||||
ValidationError: Type[DvcException] = StageFileFormatError
|
||||
|
||||
def __init__(self, repo, path, verify=True, **kwargs):
|
||||
self.repo = repo
|
||||
@@ -134,7 +126,11 @@ class FileMixin:
|
||||
if self._is_git_ignored():
|
||||
raise FileIsGitIgnored(self.path)
|
||||
|
||||
def _load(self) -> Tuple[Any, str]:
|
||||
def load(self, **kwargs: Any) -> Any:
|
||||
d, _ = self._load(**kwargs)
|
||||
return d
|
||||
|
||||
def _load(self, **kwargs: Any) -> Tuple[Any, str]:
|
||||
# it raises the proper exceptions by priority:
|
||||
# 1. when the file doesn't exists
|
||||
# 2. filename is not a DVC file
|
||||
@@ -151,29 +147,23 @@ class FileMixin:
|
||||
raise StageFileIsNotDvcFileError(self.path)
|
||||
|
||||
self._check_gitignored()
|
||||
return self._load_yaml()
|
||||
return self._load_yaml(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, d: _T, fname: str = None) -> _T:
|
||||
from dvc.utils.strictyaml import validate
|
||||
|
||||
try:
|
||||
return validate(d, cls.SCHEMA) # type: ignore[arg-type]
|
||||
except YAMLValidationError as exc:
|
||||
raise cls.ValidationError(fname) from exc
|
||||
return validate(d, cls.SCHEMA, path=fname) # type: ignore[arg-type]
|
||||
|
||||
def _load_yaml(self, **kwargs: Any) -> Tuple[Any, str]:
|
||||
from dvc.utils import strictyaml
|
||||
|
||||
try:
|
||||
return strictyaml.load(
|
||||
self.path,
|
||||
self.SCHEMA, # type: ignore[arg-type]
|
||||
self.repo.fs,
|
||||
**kwargs,
|
||||
)
|
||||
except YAMLValidationError as exc:
|
||||
raise self.ValidationError(self.relpath) from exc
|
||||
return strictyaml.load(
|
||||
self.path,
|
||||
self.SCHEMA, # type: ignore[arg-type]
|
||||
self.repo.fs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def remove(self, force=False): # pylint: disable=unused-argument
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
@@ -354,24 +344,19 @@ def lockfile_schema(data: _T) -> _T:
|
||||
|
||||
class Lockfile(FileMixin):
|
||||
SCHEMA = staticmethod(lockfile_schema) # type: ignore[assignment]
|
||||
ValidationError = LockfileCorruptedError
|
||||
|
||||
def _verify_filename(self):
|
||||
pass # lockfile path is hardcoded, so no need to verify here
|
||||
|
||||
def _load(self):
|
||||
def _load(self, **kwargs: Any):
|
||||
try:
|
||||
return super()._load()
|
||||
return super()._load(**kwargs)
|
||||
except StageFileDoesNotExistError:
|
||||
# we still need to account for git-ignored dvc.lock file
|
||||
# even though it may not exist or have been .dvcignored
|
||||
self._check_gitignored()
|
||||
return {}, ""
|
||||
|
||||
def load(self):
|
||||
d, _ = self._load()
|
||||
return d
|
||||
|
||||
@property
|
||||
def latest_version_info(self):
|
||||
version = LOCKFILE_VERSION.V2.value # pylint:disable=no-member
|
||||
|
||||
+19
-1
@@ -1,3 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
from voluptuous import Any, Optional, Required, Schema
|
||||
|
||||
from dvc import dependency, output
|
||||
@@ -94,11 +96,27 @@ STAGE_DEFINITION = {
|
||||
Optional(StageParams.PARAM_LIVE): Any(str, LIVE_PSTAGE_SCHEMA),
|
||||
}
|
||||
|
||||
|
||||
def either_or(primary, fallback, fallback_includes=None):
|
||||
def validator(data):
|
||||
schema = primary
|
||||
if (
|
||||
isinstance(data, Mapping)
|
||||
and set(fallback_includes or []) & data.keys()
|
||||
):
|
||||
schema = fallback
|
||||
return Schema(schema)(data)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
FOREACH_IN = {
|
||||
Required(FOREACH_KWD): Any(dict, list, str),
|
||||
Required(DO_KWD): STAGE_DEFINITION,
|
||||
}
|
||||
SINGLE_PIPELINE_STAGE_SCHEMA = {str: Any(FOREACH_IN, STAGE_DEFINITION)}
|
||||
SINGLE_PIPELINE_STAGE_SCHEMA = {
|
||||
str: either_or(STAGE_DEFINITION, FOREACH_IN, [FOREACH_KWD, DO_KWD])
|
||||
}
|
||||
MULTI_STAGE_SCHEMA = {
|
||||
STAGES: SINGLE_PIPELINE_STAGE_SCHEMA,
|
||||
VARS_KWD: VARS_SCHEMA,
|
||||
|
||||
@@ -9,11 +9,6 @@ class StageCmdFailedError(DvcException):
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
class StageFileFormatError(DvcException):
|
||||
def __init__(self, path):
|
||||
super().__init__(f"'{path}' format error")
|
||||
|
||||
|
||||
class StageFileDoesNotExistError(DvcException):
|
||||
DVC_IGNORED = "is dvc-ignored"
|
||||
DOES_NOT_EXIST = "does not exist"
|
||||
|
||||
+157
-16
@@ -6,42 +6,67 @@ Used for parsing dvc.yaml, dvc.lock and .dvc files.
|
||||
|
||||
Not to be confused with strictyaml, a python library with similar motivations.
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, TypeVar
|
||||
import re
|
||||
import typing
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, TypeVar
|
||||
|
||||
from dvc.exceptions import DvcException, PrettyDvcException
|
||||
from dvc.exceptions import PrettyDvcException
|
||||
from dvc.ui import ui
|
||||
from dvc.utils.serialize import (
|
||||
EncodingError,
|
||||
YAMLFileCorruptedError,
|
||||
parse_yaml,
|
||||
parse_yaml_for_update,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rich.syntax import Syntax
|
||||
from ruamel.yaml import StreamMark
|
||||
from voluptuous import MultipleInvalid
|
||||
|
||||
from dvc.fs.base import BaseFileSystem
|
||||
from dvc.ui import RichText
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
merge_conflict_marker = re.compile("^([<=>]{7}) .*$", re.MULTILINE)
|
||||
|
||||
|
||||
def make_relpath(path: str) -> str:
|
||||
import os
|
||||
|
||||
from dvc.utils import relpath
|
||||
|
||||
rel = relpath(path)
|
||||
prefix = ""
|
||||
if not rel.startswith(".."):
|
||||
prefix = "./" if os.name == "posix" else ".\\"
|
||||
return prefix + rel
|
||||
|
||||
|
||||
def _prepare_message(message: str) -> "RichText":
|
||||
return ui.rich_text(message, style="red")
|
||||
|
||||
|
||||
def _prepare_cause(cause: str) -> "RichText":
|
||||
return ui.rich_text(cause, style="bold")
|
||||
|
||||
|
||||
def _prepare_code_snippets(code: str, start_line: int = 1) -> "Syntax":
|
||||
def _prepare_code_snippets(
|
||||
code: str, start_line: int = 1, **kwargs: Any
|
||||
) -> "Syntax":
|
||||
from rich.syntax import Syntax
|
||||
|
||||
kwargs.setdefault("start_line", start_line)
|
||||
return Syntax(
|
||||
code,
|
||||
"yaml",
|
||||
start_line=start_line,
|
||||
theme="ansi_dark",
|
||||
word_wrap=True,
|
||||
line_numbers=True,
|
||||
indent_guides=True,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,13 +75,14 @@ class YAMLSyntaxError(PrettyDvcException, YAMLFileCorruptedError):
|
||||
self.path: str = path
|
||||
self.yaml_text: str = yaml_text
|
||||
self.exc: Exception = exc
|
||||
|
||||
merge_conflicts = merge_conflict_marker.search(self.yaml_text)
|
||||
self.hint = " (possible merge conflicts)" if merge_conflicts else ""
|
||||
super().__init__(self.path)
|
||||
|
||||
def __pretty_exc__(self, **kwargs: Any) -> None:
|
||||
from ruamel.yaml.error import MarkedYAMLError
|
||||
|
||||
from dvc.utils import relpath
|
||||
|
||||
exc = self.exc.__cause__
|
||||
|
||||
if not isinstance(exc, MarkedYAMLError):
|
||||
@@ -101,25 +127,140 @@ class YAMLSyntaxError(PrettyDvcException, YAMLFileCorruptedError):
|
||||
if exc.problem_mark is not None:
|
||||
lines.append(prepare_code(exc.problem_mark))
|
||||
|
||||
if lines and lines[-1]:
|
||||
if lines:
|
||||
# we should not add a newline after the main message
|
||||
# if there are no other outputs
|
||||
lines.insert(0, "")
|
||||
lines.insert(0, f"[red]'{relpath(self.path)}' structure is corrupted.")
|
||||
for message in lines:
|
||||
ui.error_write(message, styled=True)
|
||||
|
||||
rel = make_relpath(self.path)
|
||||
lines.insert(0, _prepare_message(f"'{rel}' is invalid{self.hint}."))
|
||||
for line in lines:
|
||||
ui.error_write(line, styled=True)
|
||||
|
||||
|
||||
class YAMLValidationError(DvcException):
|
||||
def __init__(self, exc):
|
||||
super().__init__(str(exc))
|
||||
def determine_linecol(
|
||||
data, paths, max_steps=5
|
||||
) -> Tuple[Optional[int], Optional[int], int]:
|
||||
"""Determine linecol from the CommentedMap for the `paths` location.
|
||||
|
||||
CommentedMap from `ruamel.yaml` has `.lc` property from which we can read
|
||||
`.line` and `.col`. This is available in the collections type,
|
||||
i.e. list and dictionaries.
|
||||
|
||||
But this may fail on non-collection types. For example, if the `paths` is
|
||||
['stages', 'metrics'], metrics being a boolean type does not have `lc`
|
||||
prop.
|
||||
```
|
||||
stages:
|
||||
metrics: true
|
||||
```
|
||||
|
||||
To provide some context to the user, we step up to the
|
||||
path ['stages'], which being a collection type, will have `lc` prop
|
||||
with which we can find line and col.
|
||||
|
||||
This may end up being not accurate, so we try to show the same amount of
|
||||
lines of code for `n` number of steps taken upwards. In a worst case,
|
||||
it may be just 1 step (as non-collection item cannot have child items),
|
||||
but `schema validator` may provide us arbitrary path. So, this caps the
|
||||
number of steps upward to just 5. If it does not find any linecols, it'll
|
||||
abort.
|
||||
"""
|
||||
from dpath.util import get
|
||||
|
||||
step = 1
|
||||
line, col = None, None
|
||||
while paths and step < max_steps:
|
||||
value = get(data, paths, default=None)
|
||||
if value is not None:
|
||||
with suppress(AttributeError, TypeError):
|
||||
line = value.lc.line + 1
|
||||
col = value.lc.col + 1
|
||||
break
|
||||
step += 1
|
||||
*paths, _ = paths
|
||||
|
||||
return line, col, step
|
||||
|
||||
|
||||
def validate(data: _T, schema: Callable[[_T], _T], _text: str = None) -> _T:
|
||||
class YAMLValidationError(PrettyDvcException):
|
||||
def __init__(
|
||||
self,
|
||||
exc: "MultipleInvalid",
|
||||
path: str = None,
|
||||
text: str = None,
|
||||
) -> None:
|
||||
self.text = text or ""
|
||||
self.exc = exc
|
||||
|
||||
rel = make_relpath(path) if path else ""
|
||||
self.path = path or ""
|
||||
|
||||
message = f"'{rel}' validation failed"
|
||||
if len(self.exc.errors) > 1:
|
||||
message += f": {len(self.exc.errors)} errors"
|
||||
super().__init__(f"{message}")
|
||||
|
||||
def _prepare_context(self, data: typing.Mapping) -> List[object]:
|
||||
lines: List[object] = []
|
||||
for index, error in enumerate(self.exc.errors):
|
||||
if index and lines[-1]:
|
||||
lines.append("")
|
||||
line, col, step = determine_linecol(data, error.path)
|
||||
parts = [error.error_message]
|
||||
if error.path:
|
||||
parts.append("in " + " -> ".join(str(p) for p in error.path))
|
||||
if line:
|
||||
parts.append(f"line {line}")
|
||||
if col:
|
||||
parts.append(f"column {col}")
|
||||
lines.append(_prepare_cause(", ".join(parts)))
|
||||
|
||||
if line:
|
||||
# we show one line above the error
|
||||
# we try to show few more lines if we could not
|
||||
# reliably figure out where the error was
|
||||
lr = (line - 1, line + step - 1)
|
||||
code = _prepare_code_snippets(self.text, line_range=lr)
|
||||
lines.append(code)
|
||||
return lines
|
||||
|
||||
def __pretty_exc__(self, **kwargs: Any) -> None:
|
||||
"""Prettify exception message."""
|
||||
from collections.abc import Mapping
|
||||
|
||||
lines: List[object] = []
|
||||
data = parse_yaml_for_update(self.text, self.path)
|
||||
if isinstance(data, Mapping):
|
||||
lines.extend(self._prepare_context(data))
|
||||
|
||||
cause = ""
|
||||
if lines:
|
||||
# we should not add a newline after the main message
|
||||
# if there are no other outputs
|
||||
lines.insert(0, "")
|
||||
else:
|
||||
# if we don't have any context to show, we'll fallback to what we
|
||||
# got from voluptuous and print them in the same line.
|
||||
cause = f": {self.exc}"
|
||||
|
||||
lines.insert(0, _prepare_message(f"{self}{cause}."))
|
||||
for line in lines:
|
||||
ui.error_write(line, styled=True)
|
||||
|
||||
|
||||
def validate(
|
||||
data: _T,
|
||||
schema: Callable[[_T], _T],
|
||||
text: str = None,
|
||||
path: str = None,
|
||||
) -> _T:
|
||||
from voluptuous import MultipleInvalid
|
||||
|
||||
try:
|
||||
return schema(data)
|
||||
except MultipleInvalid as exc:
|
||||
raise YAMLValidationError(str(exc))
|
||||
raise YAMLValidationError(exc, path, text) from exc
|
||||
|
||||
|
||||
def load(
|
||||
@@ -143,5 +284,5 @@ def load(
|
||||
if schema:
|
||||
# not returning validated data, as it may remove
|
||||
# details from CommentedMap that we get from roundtrip parser
|
||||
validate(data, schema, text)
|
||||
validate(data, schema, text=text, path=path)
|
||||
return data, text
|
||||
|
||||
@@ -7,15 +7,12 @@ from dvc.dvcfile import (
|
||||
PIPELINE_FILE,
|
||||
PIPELINE_LOCK,
|
||||
Dvcfile,
|
||||
LockfileCorruptedError,
|
||||
ParametrizedDumpError,
|
||||
SingleStageFile,
|
||||
)
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileDoesNotExistError,
|
||||
StageFileFormatError,
|
||||
)
|
||||
from dvc.stage.exceptions import StageFileDoesNotExistError
|
||||
from dvc.stage.loader import StageNotFound
|
||||
from dvc.utils.strictyaml import YAMLValidationError
|
||||
|
||||
|
||||
def test_run_load_one_for_multistage(tmp_dir, dvc):
|
||||
@@ -246,14 +243,14 @@ def test_remove_stage_on_lockfile_format_error(tmp_dir, dvc, run_copy):
|
||||
lock_data["gibberish"] = True
|
||||
data["gibberish"] = True
|
||||
(tmp_dir / lock_file.relpath).dump(lock_data)
|
||||
with pytest.raises(LockfileCorruptedError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
dvc_file.remove_stage(stage)
|
||||
|
||||
lock_file.remove()
|
||||
dvc_file.dump(stage, update_pipeline=False)
|
||||
|
||||
(tmp_dir / dvc_file.relpath).dump(data)
|
||||
with pytest.raises(StageFileFormatError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
dvc_file.remove_stage(stage)
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,12 @@ from operator import itemgetter
|
||||
|
||||
import pytest
|
||||
|
||||
from dvc.dvcfile import PIPELINE_LOCK, Lockfile, LockfileCorruptedError
|
||||
from dvc.dvcfile import PIPELINE_LOCK, Lockfile
|
||||
from dvc.hash_info import HashInfo
|
||||
from dvc.stage.utils import split_params_deps
|
||||
from dvc.utils.fs import remove
|
||||
from dvc.utils.serialize import dumps_yaml, parse_yaml_for_update
|
||||
from dvc.utils.strictyaml import YAMLValidationError, make_relpath
|
||||
from tests.func.test_run_multistage import supported_params
|
||||
|
||||
FS_STRUCTURE = {
|
||||
@@ -207,10 +208,11 @@ def test_migrates_v1_lockfile_to_v2_during_dump(
|
||||
def test_lockfile_invalid_versions(tmp_dir, dvc, version_info):
|
||||
lockdata = {**version_info, "stages": {"foo": {"cmd": "echo foo"}}}
|
||||
(tmp_dir / "dvc.lock").dump(lockdata)
|
||||
with pytest.raises(LockfileCorruptedError) as exc_info:
|
||||
with pytest.raises(YAMLValidationError) as exc_info:
|
||||
Lockfile(dvc, tmp_dir / "dvc.lock").load()
|
||||
|
||||
assert str(exc_info.value) == "Lockfile 'dvc.lock' is corrupted."
|
||||
rel = make_relpath("dvc.lock")
|
||||
assert f"'{rel}' validation failed" in str(exc_info.value)
|
||||
assert (
|
||||
str(exc_info.value.__cause__)
|
||||
== f"invalid schema version {version_info['schema']}, "
|
||||
|
||||
@@ -9,14 +9,14 @@ from dvc.main import main
|
||||
from dvc.output import Output
|
||||
from dvc.repo import Repo, lock_repo
|
||||
from dvc.stage import PipelineStage, Stage
|
||||
from dvc.stage.exceptions import StageFileFormatError
|
||||
from dvc.stage.run import run_stage
|
||||
from dvc.utils.serialize import dump_yaml, load_yaml
|
||||
from dvc.utils.strictyaml import YAMLValidationError
|
||||
from tests.basic_env import TestDvc
|
||||
|
||||
|
||||
def test_cmd_obj():
|
||||
with pytest.raises(StageFileFormatError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
SingleStageFile.validate({Stage.PARAM_CMD: {}})
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ def test_cmd_str():
|
||||
|
||||
|
||||
def test_object():
|
||||
with pytest.raises(StageFileFormatError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
SingleStageFile.validate({Stage.PARAM_DEPS: {}})
|
||||
|
||||
with pytest.raises(StageFileFormatError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
SingleStageFile.validate({Stage.PARAM_OUTS: {}})
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ from dvc.path_info import PathInfo
|
||||
from dvc.repo import Repo
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileDoesNotExistError,
|
||||
StageFileFormatError,
|
||||
StageNameUnspecified,
|
||||
StageNotFound,
|
||||
)
|
||||
from dvc.utils import relpath
|
||||
from dvc.utils.fs import remove
|
||||
from dvc.utils.strictyaml import YAMLValidationError
|
||||
|
||||
|
||||
def test_collect(tmp_dir, scm, dvc, run_copy):
|
||||
@@ -450,7 +450,7 @@ def test_collect_repo_callback(tmp_dir, dvc, mocker):
|
||||
|
||||
file_path, exc = mock.call_args[0]
|
||||
assert file_path == PIPELINE_FILE
|
||||
assert isinstance(exc, StageFileFormatError)
|
||||
assert isinstance(exc, YAMLValidationError)
|
||||
|
||||
|
||||
def test_gitignored_collect_repo(tmp_dir, dvc, scm):
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import __with_libyaml__ as ruamel_clib
|
||||
|
||||
from dvc.main import main
|
||||
from dvc.ui import ui
|
||||
|
||||
DUPLICATE_KEYS = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: python train.py
|
||||
cmd: python train.py
|
||||
"""
|
||||
|
||||
DUPLICATE_KEYS_OUTPUT = """\
|
||||
'./dvc.yaml' is invalid.
|
||||
|
||||
While constructing a mapping, in line 3, column 5
|
||||
3 │ cmd: python train.py
|
||||
|
||||
Found duplicate key "cmd" with value "python train.py" (original value:\
|
||||
"python \ntrain.py"), in line 4, column 5
|
||||
4 │ cmd: python train.py"""
|
||||
|
||||
|
||||
MAPPING_VALUES_NOT_ALLOWED = """\
|
||||
stages:
|
||||
stage1
|
||||
cmd: python script.py
|
||||
"""
|
||||
|
||||
MAPPING_VALUES_NOT_ALLOWED_OUTPUT = """\
|
||||
'./dvc.yaml' is invalid.
|
||||
|
||||
Mapping values are not allowed {}, in line 3, column 8
|
||||
3 │ cmd: python script.py""".format(
|
||||
"in this context" if ruamel_clib else "here"
|
||||
)
|
||||
|
||||
|
||||
NO_HYPHEN_INDICATOR_IN_BLOCK = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: python script.py
|
||||
outs:
|
||||
- logs:
|
||||
cache: false
|
||||
metrics:
|
||||
"""
|
||||
|
||||
NO_HYPHEN_INDICATOR_IN_BLOCK_OUTPUT = """\
|
||||
'./dvc.yaml' is invalid.
|
||||
|
||||
While parsing a block collection, in line 5, column 7
|
||||
5 │ - logs:
|
||||
|
||||
{}, in line 7, column 7
|
||||
7 │ metrics:""".format(
|
||||
"Did not find expected '-' indicator"
|
||||
if ruamel_clib
|
||||
else "Expected <block end>, but found '?'"
|
||||
)
|
||||
|
||||
|
||||
UNCLOSED_SCALAR = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: python script.py
|
||||
desc: "this is my stage one
|
||||
"""
|
||||
|
||||
UNCLOSED_SCALAR_OUTPUT = """\
|
||||
'./dvc.yaml' is invalid.
|
||||
|
||||
While scanning a quoted scalar, in line 4, column 11
|
||||
4 │ desc: "this is my stage one
|
||||
|
||||
Found unexpected end of stream, in line 5, column 1
|
||||
5"""
|
||||
|
||||
|
||||
NOT_A_DICT = "3"
|
||||
NOT_A_DICT_OUTPUT = "'./dvc.yaml' validation failed: expected a dictionary.\n"
|
||||
|
||||
|
||||
EMPTY_STAGE = """\
|
||||
stages:
|
||||
stage1:
|
||||
"""
|
||||
|
||||
EMPTY_STAGE_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected a dictionary, in stages -> stage1, line 2, column 3
|
||||
1 stages:
|
||||
2 stage1:
|
||||
3"""
|
||||
|
||||
|
||||
MISSING_CMD = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: {}
|
||||
"""
|
||||
|
||||
MISSING_CMD_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected str, in stages -> stage1 -> cmd, line 3, column 10
|
||||
2 stage1:
|
||||
3 │ cmd: {}"""
|
||||
|
||||
|
||||
DEPS_AS_DICT = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: python script.py
|
||||
deps:
|
||||
- src:
|
||||
"""
|
||||
|
||||
DEPS_AS_DICT_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected str, in stages -> stage1 -> deps -> 0, line 5, column 9
|
||||
4 │ deps:
|
||||
5 │ - src:
|
||||
"""
|
||||
|
||||
OUTS_AS_STR = """\
|
||||
stages:
|
||||
train:
|
||||
cmd:
|
||||
- python train.py
|
||||
deps:
|
||||
- config.cfg
|
||||
outs:
|
||||
models/"""
|
||||
|
||||
OUTS_AS_STR_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected a list, in stages -> train -> outs, line 3, column 5
|
||||
2 train:
|
||||
3 │ cmd:
|
||||
4 │ - python train.py"""
|
||||
|
||||
|
||||
NULL_VALUE_ON_OUTS = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: python script.py
|
||||
outs:
|
||||
- logs:
|
||||
cache: false
|
||||
persist: true
|
||||
remote:
|
||||
"""
|
||||
|
||||
NULL_VALUE_ON_OUTS_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected str, in stages -> stage1 -> outs -> 0 -> logs -> remote, line 6,\
|
||||
column\n9
|
||||
5 │ - logs:
|
||||
6 │ │ cache: false
|
||||
7 │ │ persist: true"""
|
||||
|
||||
ADDITIONAL_KEY_ON_OUTS = """\
|
||||
stages:
|
||||
stage1:
|
||||
cmd: python script.py
|
||||
outs:
|
||||
- logs:
|
||||
cache: false
|
||||
not_existing_key: false
|
||||
"""
|
||||
|
||||
ADDITIONAL_KEY_ON_OUTS_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
extra keys not allowed, in stages -> stage1 -> outs -> 0 -> logs -> \n\
|
||||
not_existing_key, line 6, column 9
|
||||
5 │ - logs:
|
||||
6 │ │ cache: false
|
||||
7 │ │ not_existing_key: false"""
|
||||
|
||||
|
||||
FOREACH_SCALAR_VALUE = """\
|
||||
stages:
|
||||
group:
|
||||
foreach: 3
|
||||
do:
|
||||
cmd: python script${i}.py
|
||||
"""
|
||||
|
||||
FOREACH_SCALAR_VALUE_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected dict, in stages -> group -> foreach, line 3, column 5
|
||||
2 group:
|
||||
3 │ foreach: 3
|
||||
4 │ do:"""
|
||||
|
||||
FOREACH_DO_NULL = """\
|
||||
stages:
|
||||
stage1:
|
||||
foreach: [1,2,3]
|
||||
do:
|
||||
"""
|
||||
|
||||
|
||||
FOREACH_DO_NULL_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
expected a dictionary, in stages -> stage1 -> do, line 3, column 5
|
||||
2 stage1:
|
||||
3 │ foreach: [1,2,3]
|
||||
4 │ do:"""
|
||||
|
||||
|
||||
FOREACH_DO_MISSING_CMD = """\
|
||||
stages:
|
||||
stage1:
|
||||
foreach: [1,2,3]
|
||||
do:
|
||||
outs:
|
||||
- ${item}
|
||||
"""
|
||||
|
||||
|
||||
FOREACH_WITH_CMD_DO_MISSING = """\
|
||||
stages:
|
||||
stage1:
|
||||
foreach: [1,2,3]
|
||||
cmd: python script${item}.py
|
||||
"""
|
||||
|
||||
|
||||
FOREACH_WITH_CMD_DO_MISSING_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed: 2 errors.
|
||||
|
||||
extra keys not allowed, in stages -> stage1 -> cmd, line 3, column 5
|
||||
2 stage1:
|
||||
3 │ foreach: [1,2,3]
|
||||
4 │ cmd: python script${item}.py
|
||||
|
||||
required key not provided, in stages -> stage1 -> do, line 3, column 5
|
||||
2 stage1:
|
||||
3 │ foreach: [1,2,3]
|
||||
4 │ cmd: python script${item}.py"""
|
||||
|
||||
|
||||
FOREACH_DO_MISSING_CMD_OUTPUT = """\
|
||||
'./dvc.yaml' validation failed.
|
||||
|
||||
required key not provided, in stages -> stage1 -> do -> cmd, line 5, column 7
|
||||
4 │ do:
|
||||
5 │ outs:
|
||||
6 │ - ${item}"""
|
||||
|
||||
|
||||
MERGE_CONFLICTS = """\
|
||||
stages:
|
||||
load_data:
|
||||
<<<<<<< HEAD
|
||||
cmd: python src/load_data.py
|
||||
deps:
|
||||
- src/load_data.py
|
||||
=======
|
||||
cmd: python load_data.py
|
||||
deps:
|
||||
- load_data.py
|
||||
>>>>>>> branch
|
||||
outs:
|
||||
- data
|
||||
"""
|
||||
|
||||
MERGE_CONFLICTS_OUTPUT = """\
|
||||
'./dvc.yaml' is invalid (possible merge conflicts).
|
||||
|
||||
While scanning a simple key, in line 3, column 1
|
||||
3 <<<<<<< HEAD
|
||||
|
||||
Could not find expected ':', in line 4, column 8
|
||||
4 │ cmd: python src/load_data.py"""
|
||||
|
||||
|
||||
examples = {
|
||||
# on parse errors
|
||||
"duplicate_keys": (DUPLICATE_KEYS, DUPLICATE_KEYS_OUTPUT),
|
||||
"mapping_values_not_allowed": (
|
||||
MAPPING_VALUES_NOT_ALLOWED,
|
||||
MAPPING_VALUES_NOT_ALLOWED_OUTPUT,
|
||||
),
|
||||
"no_hyphen_block": (
|
||||
NO_HYPHEN_INDICATOR_IN_BLOCK,
|
||||
NO_HYPHEN_INDICATOR_IN_BLOCK_OUTPUT,
|
||||
),
|
||||
"unclosed_scalar": (UNCLOSED_SCALAR, UNCLOSED_SCALAR_OUTPUT),
|
||||
# schema validation errors
|
||||
"not_a_dict": (NOT_A_DICT, NOT_A_DICT_OUTPUT),
|
||||
"empty_stage": (EMPTY_STAGE, EMPTY_STAGE_OUTPUT),
|
||||
"missing_cmd": (MISSING_CMD, MISSING_CMD_OUTPUT),
|
||||
"deps_as_dict": (DEPS_AS_DICT, DEPS_AS_DICT_OUTPUT),
|
||||
"outs_as_str": (OUTS_AS_STR, OUTS_AS_STR_OUTPUT),
|
||||
"null_value_on_outs": (NULL_VALUE_ON_OUTS, NULL_VALUE_ON_OUTS_OUTPUT),
|
||||
"additional_key_on_outs": (
|
||||
ADDITIONAL_KEY_ON_OUTS,
|
||||
ADDITIONAL_KEY_ON_OUTS_OUTPUT,
|
||||
),
|
||||
"foreach_scalar": (FOREACH_SCALAR_VALUE, FOREACH_SCALAR_VALUE_OUTPUT),
|
||||
"foreach_do_do_null": (FOREACH_DO_NULL, FOREACH_DO_NULL_OUTPUT),
|
||||
"foreach_do_missing_cmd": (
|
||||
FOREACH_DO_MISSING_CMD,
|
||||
FOREACH_DO_MISSING_CMD_OUTPUT,
|
||||
),
|
||||
"foreach_unknown_cmd_missing_do": (
|
||||
FOREACH_WITH_CMD_DO_MISSING,
|
||||
FOREACH_WITH_CMD_DO_MISSING_OUTPUT,
|
||||
),
|
||||
# merge conflicts
|
||||
"merge_conflicts": (MERGE_CONFLICTS, MERGE_CONFLICTS_OUTPUT),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, expected", examples.values(), ids=examples.keys()
|
||||
)
|
||||
def test_exceptions(tmp_dir, dvc, capsys, text, expected, mocker):
|
||||
# make it always return posix path, easier for validating error messages
|
||||
mocker.patch(
|
||||
"dvc.utils.strictyaml.make_relpath", return_value="./dvc.yaml"
|
||||
)
|
||||
|
||||
console = ui.error_console
|
||||
original_printer = console.print
|
||||
|
||||
def print_with_fixed_width(*args, **kwargs):
|
||||
console.options.min_width = console.options.max_width = 80
|
||||
console.width = kwargs["width"] = 80
|
||||
return original_printer(*args, **kwargs)
|
||||
|
||||
mocker.patch.object(console, "print", print_with_fixed_width)
|
||||
|
||||
tmp_dir.gen("dvc.yaml", text)
|
||||
|
||||
capsys.readouterr() # clear outputs
|
||||
assert main(["stage", "list"]) != 0
|
||||
out, err = capsys.readouterr()
|
||||
|
||||
assert not out
|
||||
assert expected in err
|
||||
|
||||
|
||||
def test_make_relpath(tmp_dir, monkeypatch):
|
||||
from dvc.utils.strictyaml import make_relpath
|
||||
|
||||
path = tmp_dir / "dvc.yaml"
|
||||
expected_path = "./dvc.yaml" if os.name == "posix" else ".\\dvc.yaml"
|
||||
assert make_relpath(path) == expected_path
|
||||
|
||||
(tmp_dir / "dir").mkdir(exist_ok=True)
|
||||
monkeypatch.chdir("dir")
|
||||
|
||||
expected_path = "../dvc.yaml" if os.name == "posix" else "..\\dvc.yaml"
|
||||
assert make_relpath(path) == expected_path
|
||||
|
||||
|
||||
def test_fallback_exception_message(tmp_dir, dvc, mocker, caplog):
|
||||
# When trying to pretty print exception messages, we fallback to old way
|
||||
# of printing things.
|
||||
mocker.patch(
|
||||
"dvc.utils.strictyaml.YAMLSyntaxError.__pretty_exc__",
|
||||
side_effect=ValueError,
|
||||
)
|
||||
mocker.patch(
|
||||
"dvc.utils.strictyaml.YAMLValidationError.__pretty_exc__",
|
||||
side_effect=ValueError,
|
||||
)
|
||||
|
||||
# syntax errors
|
||||
dvc_file = tmp_dir / "dvc.yaml"
|
||||
dvc_file.write_text(MAPPING_VALUES_NOT_ALLOWED)
|
||||
assert main(["stage", "list"]) != 0
|
||||
assert (
|
||||
"unable to read: 'dvc.yaml', "
|
||||
"YAML file structure is corrupted" in caplog.text
|
||||
)
|
||||
|
||||
caplog.clear()
|
||||
# validation error
|
||||
dvc_file.dump({"stages": {"stage1": None}})
|
||||
assert main(["stage", "list"]) != 0
|
||||
assert "dvc.yaml' validation failed" in caplog.text
|
||||
@@ -11,11 +11,11 @@ from dvc.dvcfile import (
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.stage.exceptions import (
|
||||
StageFileDoesNotExistError,
|
||||
StageFileFormatError,
|
||||
StageFileIsNotDvcFileError,
|
||||
)
|
||||
from dvc.utils.fs import remove
|
||||
from dvc.utils.serialize import EncodingError
|
||||
from dvc.utils.strictyaml import YAMLValidationError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -68,9 +68,9 @@ def test_stage_load_on_invalid_data(tmp_dir, dvc, file):
|
||||
data = {"is_this_a_valid_dvcfile": False}
|
||||
(tmp_dir / file).dump(data)
|
||||
dvcfile = Dvcfile(dvc, file)
|
||||
with pytest.raises(StageFileFormatError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
assert dvcfile.stages
|
||||
with pytest.raises(StageFileFormatError):
|
||||
with pytest.raises(YAMLValidationError):
|
||||
assert dvcfile.validate(data, file)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from dvc.dvcfile import FileIsGitIgnored, Lockfile, LockfileCorruptedError
|
||||
from dvc.dvcfile import FileIsGitIgnored, Lockfile
|
||||
from dvc.stage import PipelineStage
|
||||
from dvc.utils.strictyaml import YAMLValidationError
|
||||
|
||||
|
||||
def test_stage_dump_no_outs_deps(tmp_dir, dvc):
|
||||
@@ -79,7 +80,7 @@ def test_load_when_lockfile_does_not_exist(tmp_dir, dvc):
|
||||
def test_load_when_lockfile_is_corrupted(tmp_dir, dvc, corrupt_data):
|
||||
(tmp_dir / "Dvcfile.lock").dump(corrupt_data)
|
||||
lockfile = Lockfile(dvc, "Dvcfile.lock")
|
||||
with pytest.raises(LockfileCorruptedError) as exc_info:
|
||||
with pytest.raises(YAMLValidationError) as exc_info:
|
||||
lockfile.load()
|
||||
assert "Dvcfile.lock" in str(exc_info.value)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user