diff --git a/dvc/command/run.py b/dvc/command/run.py index f72ac71ab..f487523da 100644 --- a/dvc/command/run.py +++ b/dvc/command/run.py @@ -4,26 +4,11 @@ from dvc.exceptions import DvcException class CmdRun(CmdBase): - def _joined_cmd(self): - if len(self.args.command) == 0: - return '' - - if len(self.args.command) == 1: - return self.args.command[0] - - cmd = '' - for chunk in self.args.command: - if len(chunk.split()) != 1: - fmt = ' "{}"' - else: - fmt = ' {}' - cmd += fmt.format(chunk) - return cmd - def run(self): overwrite = (self.args.yes or self.args.overwrite_dvcfile) + try: - self.project.run(cmd=self._joined_cmd(), + self.project.run(cmd=self._parsed_cmd(), outs=self.args.outs, outs_no_cache=self.args.outs_no_cache, metrics_no_cache=self.args.metrics_no_cache, @@ -39,3 +24,24 @@ class CmdRun(CmdBase): return 1 return 0 + + def _parsed_cmd(self): + """ + We need to take into account two cases: + + - ['python code.py foo bar']: Used mainly with dvc as a library + - ['echo', 'foo bar']: List of arguments received from the CLI + + The second case would need quoting, as it was passed through: + dvc run echo "foo bar" + """ + if len(self.args.command) < 2: + return ' '.join(self.args.command) + + return ' '.join(self._quote_argument(arg) for arg in self.args.command) + + def _quote_argument(self, argument): + if ' ' not in argument or '"' in argument: + return argument + + return '"{}"'.format(argument) diff --git a/tests/test_run.py b/tests/test_run.py index c6dbfd75d..71f64ce45 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -7,6 +7,7 @@ import subprocess from dvc.main import main from dvc.utils import file_md5 from dvc.system import System +from dvc.stage import Stage from dvc.stage import StageFileBadNameError, MissingDep from dvc.stage import StageBadCwdError, StageFileAlreadyExistsError from dvc.exceptions import (OutputDuplicationError, @@ -173,33 +174,30 @@ class TestCmdRun(TestDvc): '-o', 'out', '-f', 'out.dvc', 'python', self.CODE, self.FOO, 'out']) + + stage = Stage.load(self.dvc, fname='out.dvc') + self.assertEqual(ret, 0) self.assertTrue(os.path.isfile('out')) self.assertTrue(os.path.isfile('out.dvc')) self.assertTrue(filecmp.cmp(self.FOO, 'out', shallow=False)) + self.assertEqual(stage.cmd, 'python code.py foo out') + + def test_run_args_from_cli(self): + ret = main(['run', 'echo', 'foo']) + stage = Stage.load(self.dvc, fname='Dvcfile') + self.assertEqual(ret, 0) + self.assertEqual(stage.cmd, 'echo foo') def test_run_bad_command(self): - ret = main(['run', - 'non-existing-command']) + ret = main(['run', 'non-existing-command']) self.assertNotEqual(ret, 0) def test_run_args_with_spaces(self): - with open(self.CODE, 'w') as fobj: - fobj.write( - "import sys\nopen(sys.argv[1], 'w+').write(sys.argv[2])") - - arg = 'arg1 arg2' - log = 'log' - ret = main(['run', - 'python', - self.CODE, - log, - 'arg1 arg2']) - + ret = main(['run', 'echo', 'foo bar']) + stage = Stage.load(self.dvc, fname='Dvcfile') self.assertEqual(ret, 0) - - with open(log, 'r') as fobj: - self.assertEqual(fobj.read(), arg) + self.assertEqual(stage.cmd, 'echo "foo bar"') @mock.patch.object(subprocess, 'Popen', side_effect=KeyboardInterrupt) def test_keyboard_interrupt(self, mock_popen):