diff --git a/dvc/cli.py b/dvc/cli.py index 5f406ac36..27dd57050 100644 --- a/dvc/cli.py +++ b/dvc/cli.py @@ -8,6 +8,7 @@ from multiprocessing import cpu_count from dvc.logger import Logger from dvc.command.init import CmdInit from dvc.command.remove import CmdRemove +from dvc.command.move import CmdMove from dvc.command.run import CmdRun from dvc.command.repro import CmdRepro from dvc.command.data_sync import CmdDataPush, CmdDataPull, CmdDataFetch @@ -289,6 +290,19 @@ def parse_args(argv=None): help='DVC files.') remove_parser.set_defaults(func=CmdRemove) + # Move + move_parser = subparsers.add_parser( + 'move', + parents=[parent_parser], + help='Move output of DVC file.') + move_parser.add_argument( + 'src', + help='Source') + move_parser.add_argument( + 'dst', + help='Destination') + move_parser.set_defaults(func=CmdMove) + # Garbage collector gc_parser = subparsers.add_parser( 'gc', diff --git a/dvc/command/move.py b/dvc/command/move.py new file mode 100644 index 000000000..bef31165b --- /dev/null +++ b/dvc/command/move.py @@ -0,0 +1,13 @@ +from dvc.exceptions import DvcException +from dvc.command.common.base import CmdBase + + +class CmdMove(CmdBase): + def run(self): + try: + self.project.move(self.args.src, self.args.dst) + except DvcException as ex: + self.project.logger.error('Failed to move \'{}\' -> \'{}\''.format(self.args.src, + self.args.dst), ex) + return 1 + return 0 diff --git a/dvc/dependency/local.py b/dvc/dependency/local.py index 0d7532107..47a903929 100644 --- a/dvc/dependency/local.py +++ b/dvc/dependency/local.py @@ -33,7 +33,7 @@ class DependencyLOCAL(DependencyBase): if not os.path.isabs(path): path = self.ospath(path) path = os.path.join(stage.cwd, path) - self.path = os.path.normpath(path) + self.path = os.path.abspath(os.path.normpath(path)) self.path_info = {'scheme': 'local', 'path': self.path} diff --git a/dvc/output/local.py b/dvc/output/local.py index b482e350e..703dee528 100644 --- a/dvc/output/local.py +++ b/dvc/output/local.py @@ -82,3 +82,15 @@ class OutputLOCAL(DependencyLOCAL): self.remote.remove(self.path_info) if ignore_remove and self.use_cache: self.project.scm.ignore_remove(self.path) + + def move(self, out): + if self.use_cache: + self.project.scm.ignore_remove(self.path) + + self.remote.move(self.path_info, out.path_info) + self.path = out.path + self.path_info = out.path_info + self.save() + + if self.use_cache: + self.project.scm.ignore(self.path) diff --git a/dvc/project.py b/dvc/project.py index 370306421..582f3e7af 100644 --- a/dvc/project.py +++ b/dvc/project.py @@ -5,6 +5,7 @@ import json import networkx as nx from jsonpath_rw import parse +import dvc.output as Output from dvc.logger import Logger from dvc.exceptions import DvcException from dvc.stage import Stage @@ -137,6 +138,35 @@ class Project(object): return stage + def move(self, from_path, to_path): + from_out = Output.loads_from(Stage(self, cwd=os.curdir), [from_path])[0] + + found = False + for stage in self.stages(): + for out in stage.outs: + if out.path != from_out.path: + continue + + if not stage.is_data_source: + raise DvcException('Dvcfile \'{}\' is not a data source.'.format(stage.rel_path)) + + found = True + to_out = Output.loads_from(out.stage, [to_path], out.cache, out.metric)[0] + out.move(to_out) + + stage_base = os.path.basename(stage.path).rstrip(Stage.STAGE_FILE_SUFFIX) + stage_dir = os.path.dirname(stage.path) + from_base = os.path.basename(from_path) + to_base = os.path.basename(to_path) + if stage_base == from_base: + os.unlink(stage.path) + stage.path = os.path.join(stage_dir, to_base + Stage.STAGE_FILE_SUFFIX) + + stage.dump() + + if not found: + raise DvcException('Unable to find dvcfile with output \'{}\''.format(from_path)) + def run(self, cmd=None, deps=[], diff --git a/dvc/remote/base.py b/dvc/remote/base.py index 11e6fbd43..6fb0a791f 100644 --- a/dvc/remote/base.py +++ b/dvc/remote/base.py @@ -79,6 +79,12 @@ class RemoteBase(object): def upload(self, path, path_info, name=None): raise NotImplementedError + def remove(self, path_info): + raise NotImplementedError + + def move(self, path_info): + raise NotImplementedError + def cache_file_key(self, fname): """ Key of a file within the bucket """ relpath = os.path.relpath(fname, self.project.cache.local.cache_dir) diff --git a/dvc/remote/local.py b/dvc/remote/local.py index ff7b97b1b..7b887209f 100644 --- a/dvc/remote/local.py +++ b/dvc/remote/local.py @@ -246,6 +246,12 @@ class RemoteLOCAL(RemoteBase): remove(path_info['path']) + def move(self, from_info, to_info): + if from_info['scheme'] != 'local' or to_info['scheme'] != 'local': + raise NotImplementedError + + move(from_info['path'], to_info['path']) + def cache_file_key(self, path): relpath = os.path.relpath(os.path.abspath(path), self.project.cache.local.cache_dir) return os.path.join(self.prefix, relpath) diff --git a/tests/test_move.py b/tests/test_move.py new file mode 100644 index 000000000..9b81b7bb6 --- /dev/null +++ b/tests/test_move.py @@ -0,0 +1,41 @@ +import os + +from dvc.main import main +from dvc.exceptions import DvcException + +from tests.basic_env import TestDvc + + +class TestMove(TestDvc): + def test(self): + dst = self.FOO + '1' + self.dvc.add(self.FOO) + self.dvc.move(self.FOO, dst) + + self.assertFalse(os.path.isfile(self.FOO)) + self.assertTrue(os.path.isfile(dst)) + + +class TestMoveNonExistentFile(TestDvc): + def test(self): + with self.assertRaises(DvcException) as cx: + self.dvc.move('non_existent_file', 'dst') + + +class TestMoveDirectory(TestDvc): + def test(self): + dst = 'dst' + stage_add = self.dvc.add(self.DATA_DIR) + self.dvc.move(self.DATA_DIR, dst) + self.assertFalse(os.path.exists(self.DATA_DIR)) + self.assertTrue(os.path.exists(dst)) + + +class TestCmdMove(TestDvc): + def test(self): + stage = self.dvc.add(self.FOO) + ret = main(['move', self.FOO, self.FOO + '1']) + self.assertEqual(ret, 0) + + ret = main(['move', 'non-existing-file', 'dst']) + self.assertNotEqual(ret, 0)