diff --git a/dvc/cli.py b/dvc/cli.py index d8af5717a..7fe70dbef 100644 --- a/dvc/cli.py +++ b/dvc/cli.py @@ -32,6 +32,7 @@ import dvc.command.pipeline as pipeline import dvc.command.daemon as daemon import dvc.command.commit as commit import dvc.command.tag as tag +import dvc.command.diff as diff from dvc.exceptions import DvcParserError from dvc import VERSION @@ -62,6 +63,7 @@ COMMANDS = [ daemon, commit, tag, + diff, ] diff --git a/dvc/command/diff.py b/dvc/command/diff.py new file mode 100644 index 000000000..2eb73bd05 --- /dev/null +++ b/dvc/command/diff.py @@ -0,0 +1,153 @@ +from __future__ import unicode_literals + +import humanize +import inflect + + +import dvc.logger as logger +from dvc.exceptions import DvcException +from dvc.command.base import CmdBase +from dvc.utils.collections import compact +import dvc.repo.diff as diff + + +class CmdDiff(CmdBase): + def _print_size(self, size): + if size < 0: + change = "decreased by {}" + elif size > 0: + change = "increased by {}" + else: + change = "not changed" + natur_size = humanize.naturalsize(abs(size)) + return change.format(natur_size) + + def _get_md5_string(self, sign, file_name, checksum): + sample_msg = "" + if file_name: + sample_msg = "{}{} with md5 {}\n" + sample_msg = sample_msg.format(sign, file_name, checksum) + return sample_msg + + def _get_dir_changes(self, dct): + engine = inflect.engine() + changes_msg = ( + "{} {} not changed, {} {} modified, {} {} added, " + "{} {} deleted, size was {}" + ) + changes_msg = changes_msg.format( + dct[diff.DIFF_IDENT], + engine.plural("file", dct[diff.DIFF_IDENT]), + dct[diff.DIFF_CHANGE], + engine.plural("file", dct[diff.DIFF_CHANGE]), + dct[diff.DIFF_NEW], + engine.plural("file", dct[diff.DIFF_NEW]), + dct[diff.DIFF_DEL], + engine.plural("file", dct[diff.DIFF_DEL]), + self._print_size(dct[diff.DIFF_SIZE]), + ) + return changes_msg + + def _get_file_changes(self, dct): + if ( + dct.get(diff.DIFF_OLD_FILE) + and dct.get(diff.DIFF_NEW_FILE) + and dct[diff.DIFF_SIZE] == 0 + ): + msg = "file size was not changed" + elif dct.get(diff.DIFF_NEW_FILE): + msg = "added file with size {}".format( + humanize.naturalsize(dct[diff.DIFF_SIZE]) + ) + elif dct.get(diff.DIFF_OLD_FILE): + msg = "deleted file with size {}".format( + humanize.naturalsize(abs(dct[diff.DIFF_SIZE])) + ) + else: + msg = "file was modified, file size {}".format( + self._print_size(dct[diff.DIFF_SIZE]) + ) + return msg + + def _get_royal_changes(self, dct): + if dct[diff.DIFF_SIZE] != diff.DIFF_SIZE_UNKNOWN: + if dct.get("is_dir"): + return self._get_dir_changes(dct) + else: + return self._get_file_changes(dct) + return "size is ?" + + def _show(self, diff_dct): + msg = "dvc diff from {} to {}".format( + diff_dct[diff.DIFF_A_REF], diff_dct[diff.DIFF_B_REF] + ) + if diff_dct.get(diff.DIFF_EQUAL): + logger.info(msg) + return + for dct in diff_dct[diff.DIFF_LIST]: + msg += "\n\ndiff for '{}'\n".format(dct[diff.DIFF_TARGET]) + msg += self._get_md5_string( + "-", + dct.get(diff.DIFF_OLD_FILE), + dct.get(diff.DIFF_OLD_CHECKSUM), + ) + msg += self._get_md5_string( + "+", + dct.get(diff.DIFF_NEW_FILE), + dct.get(diff.DIFF_NEW_CHECKSUM), + ) + msg += "\n" + msg += self._get_royal_changes(dct) + logger.info(msg) + return msg + + def run(self): + try: + msg = self.repo.diff( + self.args.a_ref, target=self.args.target, b_ref=self.args.b_ref + ) + self._show(msg) + except DvcException: + msg = "failed to get 'diff {}'" + args = " ".join( + compact([self.args.target, self.args.a_ref, self.args.b_ref]) + ) + msg = msg.format(args) + logger.error(msg) + return 1 + return 0 + + +def add_parser(subparsers, parent_parser): + description = ( + "Show diff of a data file or a directory that " + "is under DVC control. Some basic statistics " + "summary, how many files were deleted/changed." + ) + help = "Show a diff of a DVC controlled data file or a directory." + diff_parser = subparsers.add_parser( + "diff", parents=[parent_parser], description=description, help=help + ) + diff_parser.add_argument( + "-t", + "--target", + default=None, + help=( + "Source path to a data file or directory. Default None," + "If not specified, compares all files and directories " + "that are under DVC control in the current working space." + ), + ) + diff_parser.add_argument( + "a_ref", help="Git reference from which diff calculates" + ) + diff_parser.add_argument( + "b_ref", + help=( + "Git reference till which diff calculates, if omitted " + "diff shows the difference between current HEAD and a_ref" + ), + nargs="?", + default=None, + ) + diff_parser.set_defaults(func=CmdDiff) diff --git a/dvc/repo/__init__.py b/dvc/repo/__init__.py index 43ecbe600..a082e7c0b 100644 --- a/dvc/repo/__init__.py +++ b/dvc/repo/__init__.py @@ -33,6 +33,7 @@ class Repo(object): from dvc.repo.gc import gc from dvc.repo.commit import commit from dvc.repo.pkg import install_pkg + from dvc.repo.diff import diff from dvc.repo.brancher import brancher def __init__(self, root_dir=None): diff --git a/dvc/repo/diff.py b/dvc/repo/diff.py new file mode 100644 index 000000000..534385f77 --- /dev/null +++ b/dvc/repo/diff.py @@ -0,0 +1,252 @@ +from __future__ import unicode_literals + +import os +from errno import ENOENT + + +import dvc.logger as logger +from dvc.scm.base import FileNotInCommitError +from dvc.scm.git import ( + DIFF_A_TREE, + DIFF_B_TREE, + DIFF_A_REF, + DIFF_B_REF, + DIFF_EQUAL, +) + + +DIFF_TARGET = "target" +DIFF_IS_DIR = "is_dir" +DIFF_OLD_FILE = "old_file" +DIFF_OLD_CHECKSUM = "old_checksum" +DIFF_NEW_FILE = "new_file" +DIFF_NEW_CHECKSUM = "new_checksum" +DIFF_SIZE = "size_diff" +DIFF_DEL = "del" +DIFF_IDENT = "ident" +DIFF_CHANGE = "changes" +DIFF_NEW = "new" +DIFF_MOVE = "moves" +DIFF_LIST = "diffs" +DIFF_SIZE_UNKNOWN = "?" +DIFF_A_OUTPUT = "a_output" +DIFF_B_OUTPUT = "b_output" +DIFF_DELETED = "deleted_file" +DIFF_IS_NEW = "created_file" + + +def _file_not_exists(error, result): + if error.errno == ENOENT: + result.update({DIFF_SIZE: DIFF_SIZE_UNKNOWN}) + else: + raise error + + +def _extract_dir(self, dir_not_exists, output): + """Extract the content of dvc tree file + Args: + self(object) - Repo class instance + dir_not_exists(bool) - flag for directory existence + output(object) - OutputLOCAL class instance + Returns: + dict - dictionary with keys - paths to file in .dvc/cache + values -checksums for that files + """ + if not dir_not_exists: + lst = output.dir_cache + return {i["relpath"]: i["md5"] for i in lst} + return {} + + +def _get_dir_info(dir_not_exists, a_output): + if not dir_not_exists: + return str(a_output), a_output.checksum + return "", "" + + +def _ident_files(a_entries, b_entries): + keys = [ + key for key in a_entries.keys() if b_entries.get(key) == a_entries[key] + ] + return len(keys) + + +def _modified_files(self, a_entries, b_entries): + keys = [key for key in a_entries.keys() if key in b_entries] + diff_size = 0 + modified_count = 0 + for key in keys: + if a_entries[key] != b_entries[key]: + modified_count + diff_size += os.path.getsize( + self.cache.local.get(b_entries[key]) + ) - os.path.getsize(self.cache.local.get(a_entries[key])) + return modified_count, diff_size + + +def _deleted_files(self, a_entries, b_entries): + diff_size = 0 + deleted_count = 0 + for key, value in a_entries.items(): + if key not in b_entries: + deleted_count += 1 + diff_size -= os.path.getsize(self.cache.local.get(a_entries[key])) + return deleted_count, diff_size + + +def _new_files(self, a_entries, b_entries): + diff_size = 0 + new_count = 0 + for key, value in b_entries.items(): + if key not in a_entries: + new_count += 1 + diff_size += os.path.getsize(self.cache.local.get(b_entries[key])) + return new_count, diff_size + + +def _get_tree_changes(self, a_entries, b_entries): + result = { + DIFF_DEL: 0, + DIFF_IDENT: 0, + DIFF_CHANGE: 0, + DIFF_NEW: 0, + DIFF_MOVE: 0, + } + result[DIFF_IDENT] = _ident_files(a_entries, b_entries) + result[DIFF_CHANGE], diff_size = _modified_files( + self, a_entries, b_entries + ) + result[DIFF_SIZE] = diff_size + result[DIFF_DEL], diff_size = _deleted_files(self, a_entries, b_entries) + result[DIFF_SIZE] += diff_size + result[DIFF_NEW], diff_size = _new_files(self, a_entries, b_entries) + result[DIFF_SIZE] += diff_size + return result + + +def _check_local_cache(a_out, is_checked): + if a_out is not None and a_out.scheme != "local": + is_checked.append(str(a_out)) + return True + return False + + +def _is_dir(path, a_outs, b_outs): + if a_outs.get(path): + return a_outs[path].is_dir_cache + else: + return b_outs[path].is_dir_cache + + +def _get_diff_outs(self, diff_dct): + self.tree = diff_dct[DIFF_A_TREE] + a_outs = {str(out): out for st in self.stages() for out in st.outs} + self.tree = diff_dct[DIFF_B_TREE] + b_outs = {str(out): out for st in self.stages() for out in st.outs} + outs_paths = set(a_outs.keys()) + outs_paths.update(b_outs.keys()) + results = {} + non_local_cache = [] + for path in outs_paths: + check1 = _check_local_cache(a_outs.get(path), non_local_cache) + check2 = _check_local_cache(b_outs.get(path), non_local_cache) + # skip files/directories with non-local cache for now + if check1 or check2: + continue + results[path] = {} + results[path][DIFF_A_OUTPUT] = a_outs.get(path) + results[path][DIFF_B_OUTPUT] = b_outs.get(path) + results[path][DIFF_IS_NEW] = path not in a_outs + results[path][DIFF_DELETED] = path not in b_outs + results[path][DIFF_IS_DIR] = _is_dir(path, a_outs, b_outs) + if non_local_cache: + logger.warning( + "Diff is not supported for non-local outputs. Ignoring: {}".format( + non_local_cache + ) + ) + + return results + + +def _diff_dir(self, target, diff_dct): + result = {DIFF_TARGET: target} + result[DIFF_IS_DIR] = True + a_entries, b_entries = {}, {} + try: + a_entries = _extract_dir( + self, diff_dct[DIFF_IS_NEW], diff_dct[DIFF_A_OUTPUT] + ) + b_entries = _extract_dir( + self, diff_dct[DIFF_DELETED], diff_dct[DIFF_B_OUTPUT] + ) + result[DIFF_OLD_FILE], result[DIFF_OLD_CHECKSUM] = _get_dir_info( + diff_dct[DIFF_IS_NEW], diff_dct[DIFF_A_OUTPUT] + ) + result[DIFF_NEW_FILE], result[DIFF_NEW_CHECKSUM] = _get_dir_info( + diff_dct[DIFF_DELETED], diff_dct[DIFF_B_OUTPUT] + ) + result.update(_get_tree_changes(self, a_entries, b_entries)) + except IOError as e: + _file_not_exists(e, result) + return result + + +def _diff_file(self, target, diff_dct): + result = {DIFF_TARGET: target} + size = 0 + try: + if not diff_dct[DIFF_IS_NEW]: + result[DIFF_OLD_FILE] = target + result[DIFF_OLD_CHECKSUM] = diff_dct[DIFF_A_OUTPUT].checksum + size -= os.path.getsize( + self.cache.local.get(diff_dct[DIFF_A_OUTPUT].checksum) + ) + if not diff_dct[DIFF_DELETED]: + result[DIFF_NEW_FILE] = target + result[DIFF_NEW_CHECKSUM] = diff_dct[DIFF_B_OUTPUT].checksum + size += os.path.getsize( + self.cache.local.get(diff_dct[DIFF_B_OUTPUT].checksum) + ) + result[DIFF_SIZE] = size + except IOError as e: + _file_not_exists(e, result) + return result + + +def _diff_royal(self, target, diff_dct): + if diff_dct[DIFF_IS_DIR]: + return _diff_dir(self, target, diff_dct) + return _diff_file(self, target, diff_dct) + + +def diff(self, a_ref, target=None, b_ref=None): + """Gerenates diff message string output + + Args: + target(str) - file/directory to check diff of + a_ref(str) - first tag + (optional) b_ref(str) - second git tag + + Returns: + string: string of output message with diff info + """ + result = {} + diff_dct = self.scm.get_diff_trees(a_ref, b_ref=b_ref) + result[DIFF_A_REF] = diff_dct[DIFF_A_REF] + result[DIFF_B_REF] = diff_dct[DIFF_B_REF] + if diff_dct[DIFF_EQUAL]: + result[DIFF_EQUAL] = True + return result + result[DIFF_LIST] = [] + diff_outs = _get_diff_outs(self, diff_dct) + if target is None: + result[DIFF_LIST] = [ + _diff_royal(self, path, diff_outs[path]) for path in diff_outs + ] + elif target in diff_outs: + result[DIFF_LIST] = [_diff_royal(self, target, diff_outs[target])] + else: + msg = "Have not found file/directory '{}' in the commits" + raise FileNotInCommitError(msg.format(target)) + return result diff --git a/dvc/scm/base.py b/dvc/scm/base.py index dc2dde69e..d7f5333e3 100644 --- a/dvc/scm/base.py +++ b/dvc/scm/base.py @@ -17,6 +17,12 @@ class FileNotInRepoError(DvcException): """ +class FileNotInCommitError(DvcException): + """Thrown when trying to find a file/directory that is not + in the specified commit in the repository. + """ + + class FileNotInTargetSubdirError(DvcException): """Thrown when trying to place .gitignore for a file that not in the file subdirectory.""" diff --git a/dvc/scm/git/__init__.py b/dvc/scm/git/__init__.py index cd585f841..9eb49f096 100644 --- a/dvc/scm/git/__init__.py +++ b/dvc/scm/git/__init__.py @@ -16,6 +16,13 @@ from dvc.scm.git.tree import GitTree import dvc.logger as logger +DIFF_A_TREE = "a_tree" +DIFF_B_TREE = "b_tree" +DIFF_A_REF = "a_ref" +DIFF_B_REF = "b_ref" +DIFF_EQUAL = "equal" + + class Git(Base): """Class for managing Git.""" @@ -235,3 +242,48 @@ class Git(Base): def get_tree(self, rev): return GitTree(self.git, rev) + + def _get_diff_trees(self, a_ref, b_ref): + + from gitdb.exc import BadObject, BadName + + trees = {DIFF_A_TREE: None, DIFF_B_TREE: None} + commits = [] + try: + if b_ref is not None: + a_commit = self.git.commit(a_ref) + b_commit = self.git.commit(b_ref) + commits.append(str(a_commit)) + commits.append(str(b_commit)) + else: + a_commit = self.git.commit(a_ref) + b_commit = self.git.head.commit + commits.append(str(a_commit)) + commits.append(str(b_commit)) + trees[DIFF_A_TREE] = self.get_tree(commits[0]) + trees[DIFF_B_TREE] = self.get_tree(commits[1]) + except (BadName, BadObject) as e: + raise SCMError("git problem", cause=e) + return trees, commits + + def get_diff_trees(self, a_ref, b_ref=None): + """Method for getting two repo trees between two git tag commits + returns the dvc hash names of changed file/directory + + Args: + a_ref(str) - git reference + b_ref(str) - optional second git reference, default None + + Returns: + dict - dictionary with keys: (a_tree, b_tree, a_ref, b_ref, equal) + """ + diff_dct = {DIFF_EQUAL: False} + trees, commit_refs = self._get_diff_trees(a_ref, b_ref) + diff_dct[DIFF_A_REF] = commit_refs[0] + diff_dct[DIFF_B_REF] = commit_refs[1] + if commit_refs[0] == commit_refs[1]: + diff_dct[DIFF_EQUAL] = True + return diff_dct + diff_dct[DIFF_A_TREE] = trees[DIFF_A_TREE] + diff_dct[DIFF_B_TREE] = trees[DIFF_B_TREE] + return diff_dct diff --git a/dvc/utils/collections.py b/dvc/utils/collections.py new file mode 100644 index 000000000..46d8c9d44 --- /dev/null +++ b/dvc/utils/collections.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals + + +# just simple check for Nones and emtpy strings +def compact(args): + return list(filter(bool, args)) diff --git a/requirements.txt b/requirements.txt index 80c639d86..374f7baed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,3 +26,5 @@ asciimatics>=1.10.0 distro>=1.3.0 appdirs>=1.4.3 treelib>=1.5.5 +inflect>=2.1.0 +humanize>=0.5.1 diff --git a/setup.py b/setup.py index 07fe236ba..e01c045d5 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,8 @@ install_requires = [ "distro>=1.3.0", "appdirs>=1.4.3", "treelib>=1.5.5", + "inflect>=2.1.0", + "humanize>=0.5.1", ] # Extra dependencies for remote integrations diff --git a/tests/test_diff.py b/tests/test_diff.py new file mode 100644 index 000000000..8407fc42e --- /dev/null +++ b/tests/test_diff.py @@ -0,0 +1,189 @@ +from __future__ import unicode_literals + +import os +from mock import patch, Mock + +from dvc.main import main + + +from dvc.scm.base import FileNotInCommitError +import dvc.repo.diff as diff +from dvc.command.diff import CmdDiff + + +from tests.basic_env import TestDvc + + +def _get_checksum(repo, file_name): + outs = [out for s in repo.stages() for out in s.outs] + for out in outs: + if out.url == file_name: + return out.checksum + + +class TestDiff(TestDvc): + def setUp(self): + super(TestDiff, self).setUp() + + self.new_file = "new_test_file" + self.create(self.new_file, self.new_file) + self.dvc.add(self.new_file) + self.a_ref = self.git.head.commit + self.new_checksum = _get_checksum(self.dvc, self.new_file) + self.git.index.add([self.new_file + ".dvc"]) + self.git.index.commit("adds new_file") + self.test_dct = { + diff.DIFF_A_REF: str(self.a_ref), + diff.DIFF_B_REF: str(self.git.head.commit), + diff.DIFF_LIST: [ + { + diff.DIFF_TARGET: self.new_file, + diff.DIFF_NEW_FILE: self.new_file, + diff.DIFF_NEW_CHECKSUM: self.new_checksum, + diff.DIFF_SIZE: 13, + } + ], + } + + def test(self): + out = self.dvc.scm.get_diff_trees(self.a_ref) + self.assertFalse(out[diff.DIFF_EQUAL]) + self.assertEqual(str(self.a_ref), out[diff.DIFF_A_REF]) + self.assertEqual(str(self.git.head.commit), out[diff.DIFF_B_REF]) + + +class TestDiffRepo(TestDiff): + def test(self): + result = self.dvc.diff(self.a_ref, target=self.new_file) + self.assertEqual(self.test_dct, result) + + +class TestDiffCmdLine(TestDiff): + def test(self): + with patch("dvc.cli.diff.CmdDiff._show", autospec=True): + with patch("dvc.repo.Repo", config="testing") as MockRepo: + MockRepo.return_value.diff.return_value = "testing" + ret = main(["diff", "-t", self.new_file, str(self.a_ref)]) + self.assertEqual(ret, 0) + + +class TestDiffCmdMessage(TestDiff): + maxDiff = None + + def test(self): + with patch("dvc.repo.Repo", config="testing"): + m = Mock() + cmd_diff = CmdDiff(m) + msg = cmd_diff._show(self.test_dct) + test_msg = ( + "dvc diff from {0} to {1}\n\n" + "diff for '{2}'\n" + "+{2} with md5 {3}\n\n" + "added file with size 13 Bytes" + ) + test_msg = test_msg.format( + self.test_dct[diff.DIFF_A_REF], + self.test_dct[diff.DIFF_B_REF], + self.test_dct[diff.DIFF_LIST][0][diff.DIFF_TARGET], + self.test_dct[diff.DIFF_LIST][0][diff.DIFF_NEW_CHECKSUM], + ) + self.assertEqual(test_msg, msg) + + +class TestDiffDir(TestDvc): + def setUp(self): + super(TestDiffDir, self).setUp() + + self.dvc.add(self.DATA_DIR) + self.git.index.add([self.DATA_DIR + ".dvc"]) + self.git.index.commit("adds data_dir") + self.a_ref = str(self.dvc.scm.git.head.commit) + self.old_checksum = _get_checksum(self.dvc, self.DATA_DIR) + self.new_file = os.path.join(self.DATA_SUB_DIR, diff.DIFF_NEW_FILE) + self.create(self.new_file, self.new_file) + self.dvc.add(self.DATA_DIR) + self.git.index.add([self.DATA_DIR + ".dvc"]) + self.git.index.commit(message="adds data_dir with new_file") + self.new_checksum = _get_checksum(self.dvc, self.DATA_DIR) + + def test(self): + out = self.dvc.scm.get_diff_trees(self.a_ref) + self.assertFalse(out[diff.DIFF_EQUAL]) + self.assertEqual(str(self.a_ref), out[diff.DIFF_A_REF]) + self.assertEqual(str(self.git.head.commit), out[diff.DIFF_B_REF]) + + +class TestDiffDirRepo(TestDiffDir): + maxDiff = None + + def test(self): + result = self.dvc.diff(self.a_ref, target=self.DATA_DIR) + test_dct = { + diff.DIFF_A_REF: str(self.a_ref), + diff.DIFF_B_REF: str(self.git.head.commit), + diff.DIFF_LIST: [ + { + diff.DIFF_CHANGE: 0, + diff.DIFF_DEL: 0, + diff.DIFF_IDENT: 2, + diff.DIFF_MOVE: 0, + diff.DIFF_NEW: 1, + diff.DIFF_IS_DIR: True, + diff.DIFF_TARGET: self.DATA_DIR, + diff.DIFF_NEW_FILE: self.DATA_DIR, + diff.DIFF_NEW_CHECKSUM: self.new_checksum, + diff.DIFF_OLD_FILE: self.DATA_DIR, + diff.DIFF_OLD_CHECKSUM: self.old_checksum, + diff.DIFF_SIZE: 30, + } + ], + } + self.assertEqual(test_dct, result) + + +class TestDiffDirRepoDeletedFile(TestDiffDir): + maxDiff = None + + def setUp(self): + super(TestDiffDirRepoDeletedFile, self).setUp() + + self.b_ref = self.a_ref + self.new_checksum = self.old_checksum + self.a_ref = str(self.dvc.scm.git.head.commit) + self.old_checksum = _get_checksum(self.dvc, self.DATA_DIR) + + def test(self): + result = self.dvc.diff( + self.a_ref, b_ref=self.b_ref, target=self.DATA_DIR + ) + test_dct = { + diff.DIFF_A_REF: str(self.a_ref), + diff.DIFF_B_REF: str(self.b_ref), + diff.DIFF_LIST: [ + { + diff.DIFF_CHANGE: 0, + diff.DIFF_DEL: 1, + diff.DIFF_IDENT: 2, + diff.DIFF_MOVE: 0, + diff.DIFF_NEW: 0, + diff.DIFF_IS_DIR: True, + diff.DIFF_TARGET: self.DATA_DIR, + diff.DIFF_NEW_FILE: self.DATA_DIR, + diff.DIFF_NEW_CHECKSUM: self.new_checksum, + diff.DIFF_OLD_FILE: self.DATA_DIR, + diff.DIFF_OLD_CHECKSUM: self.old_checksum, + diff.DIFF_SIZE: -30, + } + ], + } + self.assertEqual(test_dct, result) + + +class TestDiffFileNotFound(TestDiffDir): + def setUp(self): + super(TestDiffFileNotFound, self).setUp() + self.unknown_file = "unknown_file_" + str(id(self)) + + def test(self): + with self.assertRaises(FileNotInCommitError): + self.dvc.diff(self.a_ref, target=self.unknown_file)