ignore: introduce .dvcignore, apply when searching for stage files

This commit is contained in:
pared
2019-04-15 14:31:34 +02:00
parent 8f27c14a8c
commit c467ccebcb
14 changed files with 526 additions and 23 deletions
+134
View File
@@ -0,0 +1,134 @@
import os
from dulwich.ignore import match_pattern, read_ignore_patterns
from dvc.utils.compat import cast_bytes
from dvc.utils.fs import get_parent_dirs_up_to
class DvcIgnoreFileHandler(object):
def __init__(self, tree):
self.tree = tree
def read_patterns(self, path):
with self.tree.open(path, binary=True) as stream:
return self._read_patterns(stream)
def get_repo_root(self):
return self.tree.tree_root
def _read_patterns(self, binary_stream):
negate_patterns = []
patterns = []
for pattern in read_ignore_patterns(binary_stream):
if pattern.lstrip().startswith(b"!"):
negate_patterns.append(pattern)
else:
patterns.append(pattern)
return negate_patterns, patterns
class DvcIgnore(object):
DVCIGNORE_FILE = ".dvcignore"
def __call__(self, root, dirs, files):
raise NotImplementedError
class DvcIgnoreFromFile(DvcIgnore):
def __init__(self, ignore_file_path, ignore_handler):
self.ignore_file_path = ignore_file_path
self.dirname = os.path.normpath(os.path.dirname(ignore_file_path))
self.patterns = []
self.negate_patterns = []
self.negate_patterns, self.patterns = ignore_handler.read_patterns(
ignore_file_path
)
def __call__(self, root, dirs, files):
files = [f for f in files if not self.matches(root, f)]
dirs = [d for d in dirs if not self.matches(root, d)]
return dirs, files
def get_match(self, abs_path):
rel_path = os.path.relpath(abs_path, self.dirname)
if os.name == "nt":
rel_path = rel_path.replace("\\", "/")
rel_path = cast_bytes(rel_path, "utf-8")
for pattern in self.patterns:
if match_pattern(
rel_path, pattern
) and self._no_negate_pattern_matches(rel_path):
return (abs_path, pattern, self.ignore_file_path)
return None
def matches(self, dirname, basename):
if self.get_match(os.path.join(dirname, basename)):
return True
return False
def _no_negate_pattern_matches(self, path):
return all([not match_pattern(path, p) for p in self.negate_patterns])
def __hash__(self):
return hash(self.ignore_file_path)
class DvcIgnoreConstant(DvcIgnore):
def __init__(self, basename):
self.basename = basename
class DvcIgnoreDir(DvcIgnoreConstant):
def __call__(self, root, dirs, files):
dirs = [d for d in dirs if not d == self.basename]
return dirs, files
class DvcIgnoreFile(DvcIgnoreConstant):
def __call__(self, root, dirs, files):
files = [f for f in files if not f == self.basename]
return dirs, files
class DvcIgnoreFilter(object):
def __init__(self, wdir, ignore_file_handler=None):
self.ignores = [
DvcIgnoreDir(".git"),
DvcIgnoreDir(".hg"),
DvcIgnoreDir(".dvc"),
DvcIgnoreFile(".dvcignore"),
]
self.ignore_file_handler = ignore_file_handler
self._process_ignores_in_parent_dirs(wdir)
def _process_ignores_in_parent_dirs(self, wdir):
if self.ignore_file_handler:
wdir = os.path.normpath(os.path.abspath(wdir))
ignore_search_end_dir = self.ignore_file_handler.get_repo_root()
parent_dirs = get_parent_dirs_up_to(wdir, ignore_search_end_dir)
for d in parent_dirs:
self.update(d)
def update(self, wdir):
ignore_file_path = os.path.join(wdir, DvcIgnore.DVCIGNORE_FILE)
if os.path.exists(ignore_file_path):
file_ignore = DvcIgnoreFromFile(
ignore_file_path, ignore_handler=self.ignore_file_handler
)
self.ignores.append(file_ignore)
def __call__(self, root, dirs, files):
if self.ignore_file_handler:
self.update(root)
for ignore in self.ignores:
dirs, files = ignore(root, dirs, files)
return dirs, files
+7 -3
View File
@@ -11,7 +11,7 @@ from dvc.exceptions import (
OutputNotFoundError,
TargetNotDirectoryError,
)
from dvc.ignore import DvcIgnoreFileHandler
logger = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ class Repo(object):
self.config = Config(self.dvc_dir)
self.tree = WorkingTree()
self.tree = WorkingTree(self.root_dir)
self.scm = SCM(self.root_dir, repo=self)
self.lock = Lock(self.dvc_dir)
@@ -390,7 +390,11 @@ class Repo(object):
stages = []
outs = []
for root, dirs, files in self.tree.walk(from_directory):
ignore_file_handler = DvcIgnoreFileHandler(self.tree)
for root, dirs, files in self.tree.walk(
from_directory, ignore_file_handler=ignore_file_handler
):
for fname in files:
path = os.path.join(root, fname)
if not Stage.is_valid_filename(path):
+1 -1
View File
@@ -27,7 +27,7 @@ def brancher( # noqa: E302
if self.scm.is_dirty():
from dvc.scm.tree import WorkingTree
self.tree = WorkingTree()
self.tree = WorkingTree(self.root_dir)
yield "Working Tree"
if all_branches:
+32 -7
View File
@@ -1,7 +1,8 @@
import errno
import os
from dvc.utils.compat import StringIO
from dvc.ignore import DvcIgnoreFilter
from dvc.utils.compat import StringIO, BytesIO
from dvc.scm.tree import BaseTree
@@ -24,7 +25,11 @@ class GitTree(BaseTree):
self.git = git
self.rev = rev
def open(self, path):
@property
def tree_root(self):
return self.git.working_dir
def open(self, path, binary=False):
relpath = os.path.relpath(path, self.git.working_dir)
@@ -39,7 +44,10 @@ class GitTree(BaseTree):
# read it immediately, also it needs to be to decoded if we follow
# the `open()` behavior (since data_stream.read() returns bytes,
# and `open` with default "r" mode returns str)
return StringIO(obj.data_stream.read().decode("utf-8"))
data = obj.data_stream.read()
if binary:
return BytesIO(data)
return StringIO(data.decode("utf-8"))
def exists(self, path):
return self.git_object_by_path(path) is not None
@@ -81,8 +89,13 @@ class GitTree(BaseTree):
tree = tree[i]
return tree
def _walk(self, tree, topdown=True):
def _walk(
self,
tree,
topdown=True,
ignore_file_handler=None,
dvc_ignore_filter=None,
):
dirs, nondirs = [], []
for i in tree:
if i.mode == GIT_MODE_DIR:
@@ -91,14 +104,26 @@ class GitTree(BaseTree):
nondirs.append(i.name)
if topdown:
if not dvc_ignore_filter:
dvc_ignore_filter = DvcIgnoreFilter(
tree.abspath, ignore_file_handler=ignore_file_handler
)
dirs, nondirs = dvc_ignore_filter(tree.path, dirs, nondirs)
yield os.path.normpath(tree.path), dirs, nondirs
for i in dirs:
for x in self._walk(tree[i], topdown=True):
for x in self._walk(
tree[i],
topdown=True,
ignore_file_handler=ignore_file_handler,
dvc_ignore_filter=dvc_ignore_filter,
):
yield x
if not topdown:
yield os.path.normpath(tree.path), dirs, nondirs
def walk(self, top, topdown=True):
def walk(self, top, topdown=True, ignore_file_handler=None):
"""Directory tree generator.
See `os.walk` for the docs. Differences:
+23 -8
View File
@@ -1,12 +1,17 @@
import os
from dvc.utils import dvc_walk
from dvc.utils.compat import open
class BaseTree(object):
"""Abstract class to represent access to files"""
def open(self, path):
@property
def tree_root(self):
pass
def open(self, path, binary=False):
"""Open file and return a stream."""
def exists(self, path):
@@ -18,7 +23,7 @@ class BaseTree(object):
def isfile(self, path):
"""Test whether a path is a regular file"""
def walk(self, top, topdown=True):
def walk(self, top, topdown=True, ignore_file_handler=None):
"""Directory tree generator.
See `os.walk` for the docs. Differences:
@@ -30,8 +35,17 @@ class BaseTree(object):
class WorkingTree(BaseTree):
"""Proxies the repo file access methods to working tree files"""
def open(self, path):
def __init__(self, repo_root=os.getcwd()):
self.repo_root = repo_root
@property
def tree_root(self):
return self.repo_root
def open(self, path, binary=False):
"""Open file and return a stream."""
if binary:
return open(path, "rb")
return open(path, encoding="utf-8")
def exists(self, path):
@@ -46,7 +60,7 @@ class WorkingTree(BaseTree):
"""Test whether a path is a regular file"""
return os.path.isfile(path)
def walk(self, top, topdown=True):
def walk(self, top, topdown=True, ignore_file_handler=None):
"""Directory tree generator.
See `os.walk` for the docs. Differences:
@@ -57,9 +71,10 @@ class WorkingTree(BaseTree):
def onerror(e):
raise e
for root, dirs, files in os.walk(
top, topdown=topdown, onerror=onerror
for root, dirs, files in dvc_walk(
top,
topdown=topdown,
onerror=onerror,
ignore_file_handler=ignore_file_handler,
):
if topdown:
dirs[:] = [i for i in dirs if i not in (".git", ".hg", ".dvc")]
yield os.path.normpath(root), dirs, files
+33 -2
View File
@@ -257,8 +257,39 @@ def load_stage_file_fobj(fobj, path):
raise StageFileCorruptedError(path)
def walk_files(directory):
for root, _, files in os.walk(str(directory)):
def dvc_walk(
top,
topdown=True,
onerror=None,
followlinks=False,
ignore_file_handler=None,
):
"""
Proxy for `os.walk` directory tree generator.
Utilizes DvcIgnoreFilter functionality.
"""
ignore_filter = None
if topdown:
from dvc.ignore import DvcIgnoreFilter
ignore_filter = DvcIgnoreFilter(
top, ignore_file_handler=ignore_file_handler
)
for root, dirs, files in os.walk(
top, topdown=topdown, onerror=onerror, followlinks=followlinks
):
if ignore_filter:
dirs[:], files[:] = ignore_filter(root, dirs, files)
yield root, dirs, files
def walk_files(directory, ignore_file_handler=None):
for root, _, files in dvc_walk(
str(directory), ignore_file_handler=ignore_file_handler
):
for f in files:
yield os.path.join(root, f)
+2 -1
View File
@@ -93,6 +93,7 @@ def _makedirs(name, mode=0o777, exist_ok=False):
if is_py2:
from urlparse import urlparse, urljoin # noqa: F401
from StringIO import StringIO # noqa: F401
from io import BytesIO # noqa: F401
from BaseHTTPServer import HTTPServer # noqa: F401
from SimpleHTTPServer import SimpleHTTPRequestHandler # noqa: F401
import ConfigParser # noqa: F401
@@ -111,7 +112,7 @@ if is_py2:
elif is_py3:
from os import makedirs # noqa: F401
from urllib.parse import urlparse, urljoin # noqa: F401
from io import StringIO # noqa: F401
from io import StringIO, BytesIO # noqa: F401
from http.server import ( # noqa: F401
HTTPServer, # noqa: F401
SimpleHTTPRequestHandler, # noqa: F401
+21 -1
View File
@@ -7,6 +7,7 @@ import logging
from dvc.exceptions import DvcException
from dvc.system import System
from dvc.utils import dvc_walk
from dvc.utils.compat import str
@@ -24,7 +25,7 @@ def get_mtime_and_size(path):
mtime = os.path.getmtime(path)
if os.path.isdir(path):
for root, dirs, files in os.walk(str(path)):
for root, dirs, files in dvc_walk(str(path)):
for name in dirs + files:
entry = os.path.join(root, name)
try:
@@ -63,3 +64,22 @@ def contains_symlink_up_to(path, base_path):
if os.path.dirname(path) == path:
return False
return contains_symlink_up_to(os.path.dirname(path), base_path)
def get_parent_dirs_up_to(wdir, root_dir):
assert os.path.isabs(wdir)
assert os.path.isabs(root_dir)
wdir = os.path.normpath(wdir)
root_dir = os.path.normpath(root_dir)
if root_dir not in wdir:
return []
dirs = []
dirs.append(wdir)
while wdir != root_dir:
wdir = os.path.dirname(wdir)
dirs.append(wdir)
return dirs
+1
View File
@@ -28,3 +28,4 @@ appdirs>=1.4.3
treelib>=1.5.5
inflect>=2.1.0
humanize>=0.5.1
dulwich>=0.19.11
+1
View File
@@ -58,6 +58,7 @@ install_requires = [
"treelib>=1.5.5",
"inflect>=2.1.0",
"humanize>=0.5.1",
"dulwich>=0.19.11",
]
# Extra dependencies for remote integrations
+68
View File
@@ -0,0 +1,68 @@
import os
from dvc.ignore import DvcIgnoreFromFile, DvcIgnore, DvcIgnoreFileHandler
from dvc.utils.compat import cast_bytes
from tests.basic_env import TestDvc
class TestDvcIgnore(TestDvc):
def setUp(self):
super(TestDvcIgnore, self).setUp()
self.ignore_file_handler = DvcIgnoreFileHandler(self.dvc.tree)
def _get_all_paths(self):
paths = []
ignore_file_handler = DvcIgnoreFileHandler(self.dvc.tree)
for root, dirs, files in self.dvc.tree.walk(
self.dvc.root_dir, ignore_file_handler=ignore_file_handler
):
for dname in dirs:
paths.append(os.path.join(root, dname))
for fname in files:
paths.append(os.path.join(root, fname))
return paths
def test_ignore_comments(self):
ignore_file = os.path.join(self.dvc.root_dir, DvcIgnore.DVCIGNORE_FILE)
with open(ignore_file, "w") as fobj:
fobj.write(os.path.basename(self.DATA))
fobj.write(" #this is comment")
ignore = DvcIgnoreFromFile(ignore_file, self.ignore_file_handler)
self.assertEqual(1, len(ignore.patterns))
def test_ignore_in_child_dir(self):
ignore_file = os.path.join(self.dvc.root_dir, DvcIgnore.DVCIGNORE_FILE)
with open(ignore_file, "w") as fobj:
fobj.write("data_dir/data")
forbidden_path = os.path.join(self.dvc.root_dir, self.DATA)
all_paths = self._get_all_paths()
self.assertNotIn(forbidden_path, all_paths)
def test_ignore_in_child_dir_unicode(self):
ignore_file = os.path.join(self.dvc.root_dir, DvcIgnore.DVCIGNORE_FILE)
with open(ignore_file, "wb") as fobj:
fobj.write(cast_bytes(self.UNICODE, "utf-8"))
forbidden_path = os.path.join(self.dvc.root_dir, self.UNICODE)
all_paths = self._get_all_paths()
self.assertNotIn(forbidden_path, all_paths)
def test_ignore_in_parent_dir(self):
ignore_file = os.path.join(self.dvc.root_dir, DvcIgnore.DVCIGNORE_FILE)
with open(ignore_file, "w") as fobj:
fobj.write("data_dir/data")
os.chdir(self.DATA_DIR)
forbidden_path = os.path.join(self.dvc.root_dir, self.DATA)
all_paths = self._get_all_paths()
self.assertNotIn(forbidden_path, all_paths)
+26
View File
@@ -1,3 +1,5 @@
from dvc.main import main
from dvc.stage import Stage
from tests.basic_env import TestDvc
from dvc.scm.git import GitTree
@@ -44,3 +46,27 @@ class TestCollect(TestDvc):
)
result = self._check("new_branch", "buzz", False, [["buzz"]])
self.assertEqual([i.rel_path for i in result[0].deps], ["bar"])
class TestIgnore(TestDvc):
def _stage_name(self, file):
return file + Stage.STAGE_FILE_SUFFIX
def test_should_not_gather_stage_files_from_ignored_d(self):
ret = main(["add", self.FOO, self.BAR, self.DATA, self.DATA_SUB])
self.assertEqual(0, ret)
stages = self.dvc.stages()
self.assertEqual(4, len(stages))
with open(".dvcignore", "w") as fobj:
fobj.write("data_dir")
stages = self.dvc.stages()
self.assertEqual(2, len(stages))
stagenames = [s.relpath for s in stages]
self.assertIn(self._stage_name(self.FOO), stagenames)
self.assertIn(self._stage_name(self.BAR), stagenames)
self.assertNotIn(self._stage_name(self.DATA), stagenames)
self.assertNotIn(self._stage_name(self.DATA_SUB), stagenames)
+132
View File
@@ -0,0 +1,132 @@
import os
import pytest
from dvc.ignore import DvcIgnoreFromFile, DvcIgnoreDir, DvcIgnoreFile
from mock import patch, Mock
from dvc.utils.compat import cast_bytes
def read_pattern(p):
return cast_bytes(p, "utf-8")
def mock_dvcignore(dvcignore_path, negate_patterns, patterns):
negate_patterns = [read_pattern(p) for p in negate_patterns]
patterns = [read_pattern(p) for p in patterns]
mock_ignore_file_handler = Mock()
with patch.object(
mock_ignore_file_handler,
"read_patterns",
return_value=(negate_patterns, patterns),
):
ignore_file = DvcIgnoreFromFile(
dvcignore_path, mock_ignore_file_handler
)
return ignore_file
def test_ignore_from_file_should_filter_dirs_and_files():
dvcignore_path = os.path.join(
os.path.sep, "full", "path", "to", "ignore", "file", ".dvcignore"
)
negate_patterns = []
patterns = ["dir_to_ignore", "file_to_ignore"]
root = os.path.dirname(dvcignore_path)
dirs = ["dir1", "dir2", "dir_to_ignore"]
files = ["file1", "file2", "file_to_ignore"]
ignore = mock_dvcignore(dvcignore_path, negate_patterns, patterns)
new_dirs, new_files = ignore(root, dirs, files)
assert {"dir1", "dir2"} == set(new_dirs)
assert {"file1", "file2"} == set(new_files)
@pytest.mark.parametrize(
"file_to_ignore_relpath, negate_patterns,patterns, expected_match",
[
("to_ignore", [], ["to_ignore"], True),
("to_ignore.txt", [], ["to_ignore*"], True),
(
os.path.join("rel", "p", "p2", "to_ignore"),
[],
["rel/**/to_ignore"],
True,
),
(
os.path.join(
os.path.sep,
"full",
"path",
"to",
"ignore",
"file",
"to_ignore",
),
[],
["to_ignore"],
True,
),
("to_ignore.txt", [], ["/*.txt"], True),
(
os.path.join("rel", "path", "path2", "to_ignore"),
[],
["rel/*/to_ignore"],
False,
),
(os.path.join("path", "to_ignore.txt"), [], ["/*.txt"], False),
(
os.path.join("rel", "path", "path2", "dont_ignore"),
[],
["rel/**/to_ignore"],
False,
),
("dont_ignore.txt", [], ["dont_ignore"], False),
("dont_ignore.txt", ["!dont_ignore.txt"], ["dont*"], False),
],
)
def test_match_ignore_from_file(
file_to_ignore_relpath, negate_patterns, patterns, expected_match
):
dvcignore_path = os.path.join(
os.path.sep, "full", "path", "to", "ignore", "file", ".dvcignore"
)
dvcignore_dirname = os.path.dirname(dvcignore_path)
ignore_file = mock_dvcignore(dvcignore_path, negate_patterns, patterns)
assert (
ignore_file.matches(dvcignore_dirname, file_to_ignore_relpath)
== expected_match
)
@pytest.mark.parametrize("omit_dir", [".git", ".hg", ".dvc"])
def test_should_ignore_dir(omit_dir):
ignore = DvcIgnoreDir(omit_dir)
root = os.path.join(os.path.sep, "walk", "dir", "root")
dirs = [omit_dir, "dir1", "dir2"]
files = []
new_dirs, _ = ignore(root, dirs, files)
assert set(new_dirs) == {"dir1", "dir2"}
def test_should_ignore_file():
dvcignore = ".dvcignore"
ignore = DvcIgnoreFile(dvcignore)
root = os.path.join(os.path.sep, "walk", "dir", "root")
dirs = []
files = ["file1", "file2", dvcignore]
_, new_files = ignore(root, dirs, files)
assert set(new_files) == {"file1", "file2"}
+45
View File
@@ -2,12 +2,14 @@ import os
from unittest import TestCase
import dvc
import pytest
from dvc.system import System
from dvc.utils.compat import str
from dvc.utils.fs import (
get_mtime_and_size,
contains_symlink_up_to,
BasePathNotInCheckedPathException,
get_parent_dirs_up_to,
)
from mock import patch
from tests.basic_env import TestDir
@@ -90,3 +92,46 @@ class TestContainsLink(TestCase):
System, "is_symlink", side_effect=base_path_is_symlink
):
self.assertFalse(contains_symlink_up_to(target_path, base_path))
@pytest.mark.parametrize(
"path1, path2",
[
(
os.path.join("non", "abs", "path"),
os.path.join(os.sep, "full", "path"),
),
(
os.path.join(os.sep, "full", "path"),
os.path.join("non", "abs", "path"),
),
],
)
def test_get_parent_dirs_up_to_should_raise_on_no_absolute(path1, path2):
with pytest.raises(AssertionError):
get_parent_dirs_up_to(path1, path2)
@pytest.mark.parametrize(
"path1, path2, expected_dirs",
[
(
os.path.join(os.sep, "non", "matching", "path"),
os.path.join(os.sep, "other", "path"),
[],
),
(
os.path.join(os.sep, "some", "long", "path"),
os.path.join(os.sep, "some"),
[
os.path.join(os.sep, "some"),
os.path.join(os.sep, "some", "long"),
os.path.join(os.sep, "some", "long", "path"),
],
),
],
)
def test_get_parent_dirs_up_to(path1, path2, expected_dirs):
result = get_parent_dirs_up_to(path1, path2)
assert set(result) == set(expected_dirs)