Repository: iterative/dvc

Issue:
Feature request: Better support for folder dependencies
Say I have the following structure:

```
script.py
module/submodule/foo.py
```

Then:

```
dvc run script.py -d module -o bar.npy
```

If I modify foo.py, `dvc repro` does not notice. The reason is that neither the `inode` nor the `mtime` of `module` is updated when a modification is made to `foo.py`.

For now it is possible to add all the files in `module` as dependencies but it would surely be better to have a way to handle folders recursively.


Discussion:
Hi @tdeboissiere !

Great point! Preparing a patch right now.

Thanks,
Ruslan

Resolving patch:
diff --git a/dvc/config.py b/dvc/config.py
--- a/dvc/config.py
+++ b/dvc/config.py
@@ -171,7 +171,7 @@ def __init__(self, dvc_dir):
             # need to convert our config to dict before passing it to
             self._config = self._lower(self._config)
             local = self._lower(local)
-            self._config.update(local)
+            self._config = self._merge(self._config, local)
 
             self._config = Schema(self.SCHEMA).validate(self._config)
 
@@ -182,6 +182,17 @@ def __init__(self, dvc_dir):
         except Exception as ex:
             raise ConfigError(ex)
 
+    @staticmethod
+    def _merge(first, second):
+        res = {}
+        sections = list(first.keys()) + list(second.keys())
+        for section in sections:
+            f = first.get(section, {}).copy()
+            s = second.get(section, {}).copy()
+            f.update(s)
+            res[section] = f
+        return res
+
     @staticmethod
     def _lower(config):
         new_config = {}
diff --git a/dvc/state.py b/dvc/state.py
--- a/dvc/state.py
+++ b/dvc/state.py
@@ -96,7 +96,20 @@ def dump(self):
 
     @staticmethod
     def mtime(path):
-        return str(int(nanotime.timestamp(os.path.getmtime(path))))
+        if os.path.isdir(path):
+            mtime = -1
+            for root, dirs, files in os.walk(path):
+                for fname in files:
+                    f = os.path.join(root, fname)
+                    m = os.path.getmtime(f)
+                    if m > mtime:
+                        mtime = m
+            if mtime == -1:
+                mtime = os.path.getmtime(path)
+        else:
+            mtime = os.path.getmtime(path)
+
+        return str(int(nanotime.timestamp(mtime)))
 
     @staticmethod
     def inode(path):


Test patch:
diff --git a/tests/test_repro.py b/tests/test_repro.py
--- a/tests/test_repro.py
+++ b/tests/test_repro.py
@@ -255,6 +255,31 @@ def test(self):
         self.assertEqual(len(stages), 1)
 
 
+class TestReproChangedDirData(TestDvc):
+    def test(self):
+        dir_name = 'dir'
+        dir_code = 'dir_code.py'
+        with open(dir_code, 'w+') as fd:
+            fd.write("import os; import sys; import shutil; shutil.copytree(sys.argv[1], sys.argv[2])")
+
+        stage = self.dvc.run(outs=[dir_name],
+                             deps=[self.DATA_DIR, dir_code],
+                             cmd="python {} {} {}".format(dir_code,
+                                                          self.DATA_DIR,
+                                                          dir_name))
+        self.assertTrue(stage is not None)
+
+        stages = self.dvc.reproduce(stage.path)
+        self.assertEqual(len(stages), 0)
+
+        with open(self.DATA_SUB, 'a') as fd:
+            fd.write('add')
+
+        stages = self.dvc.reproduce(stage.path)
+        self.assertEqual(len(stages), 1)
+        self.assertTrue(stages[0] is not None)
+
+
 class TestReproMissingMd5InStageFile(TestRepro):
     def test(self):
         with open(self.file1_stage, 'r') as fd: