Repository: iterative/dvc
Pull request: add --no-exec mode to import url
fix #3513

* [x] ❗ I have followed the [Contributing to DVC](https://dvc.org/doc/user-guide/contributing/core) checklist.

* [x] 📖 If this PR requires [documentation](https://dvc.org/doc) updates, I have created a separate PR (or issue, at least) in [dvc.org](https://github.com/iterative/dvc.org) and linked it here.

* [x] ❌ I will check DeepSource, CodeClimate, and other sanity checks below. (We consider them recommendatory and don't expect everything to be addressed. Please fix things that actually improve code or fix bugs.)

Thank you for the contribution - we'll try to review it as soon as possible. 🙏


Problem statement:
Implement `--no-exec` option for `import-url` command
`dvc import-url` creates new `.dvc` file, just as `dvc run`. Sometimes files which would be imported are already present locally and it's quite inconvenient that they should be downloaded again in order to create a pipeline step.

Because of that it would be great to add `--no-exec` option: we create pipeline step, then use `dvc commit` to update its md5 with already downloaded file.

Hints from issue discussion:
Context: https://discordapp.com/channels/485586884165107732/485596304961962003/690194007816798243
taking a look

Commit message: Merge remote-tracking branch 'origin/master' into fix3513

Patch to review:
diff --git a/dvc/command/imp_url.py b/dvc/command/imp_url.py
index 18591891b0..aaed48a34a 100644
--- a/dvc/command/imp_url.py
+++ b/dvc/command/imp_url.py
@@ -12,7 +12,10 @@ class CmdImportUrl(CmdBase):
     def run(self):
         try:
             self.repo.imp_url(
-                self.args.url, out=self.args.out, fname=self.args.file
+                self.args.url,
+                out=self.args.out,
+                fname=self.args.file,
+                no_exec=self.args.no_exec,
             )
         except DvcException:
             logger.exception(
@@ -66,4 +69,10 @@ def add_parser(subparsers, parent_parser):
         metavar="<filename>",
         choices=completion.Optional.DIR,
     )
+    import_parser.add_argument(
+        "--no-exec",
+        action="store_true",
+        default=False,
+        help="Only create stage file without actually download it.",
+    )
     import_parser.set_defaults(func=CmdImportUrl)
diff --git a/dvc/repo/imp_url.py b/dvc/repo/imp_url.py
index b6aabfefcd..c20e57e653 100644
--- a/dvc/repo/imp_url.py
+++ b/dvc/repo/imp_url.py
@@ -10,7 +10,9 @@
 
 @locked
 @scm_context
-def imp_url(self, url, out=None, fname=None, erepo=None, frozen=True):
+def imp_url(
+    self, url, out=None, fname=None, erepo=None, frozen=True, no_exec=False
+):
     from dvc.dvcfile import Dvcfile
     from dvc.stage import Stage, create_stage
 
@@ -46,7 +48,10 @@ def imp_url(self, url, out=None, fname=None, erepo=None, frozen=True):
     except OutputDuplicationError as exc:
         raise OutputDuplicationError(exc.output, set(exc.stages) - {stage})
 
-    stage.run()
+    if no_exec:
+        stage.ignore_outs()
+    else:
+        stage.run()
 
     stage.frozen = frozen
 
diff --git a/tests/func/test_import_url.py b/tests/func/test_import_url.py
index 52e7edd2f7..e383dd4e4f 100644
--- a/tests/func/test_import_url.py
+++ b/tests/func/test_import_url.py
@@ -103,3 +103,12 @@ def test_import_stage_accompanies_target(tmp_dir, dvc, erepo_dir):
 def test_import_url_nonexistent(dvc, erepo_dir):
     with pytest.raises(DependencyDoesNotExistError):
         dvc.imp_url(os.fspath(erepo_dir / "non-existent"))
+
+
+def test_import_url_with_no_exec(tmp_dir, dvc, erepo_dir):
+    tmp_dir.gen({"data_dir": {"file": "file content"}})
+    src = os.path.join("data_dir", "file")
+
+    dvc.imp_url(src, "./", no_exec=True)
+    dst = tmp_dir / "file"
+    assert dst.exists() is False
diff --git a/tests/unit/command/test_imp_url.py b/tests/unit/command/test_imp_url.py
index 8e04d12686..6b5b5f7842 100644
--- a/tests/unit/command/test_imp_url.py
+++ b/tests/unit/command/test_imp_url.py
@@ -14,7 +14,7 @@ def test_import_url(mocker):
 
     assert cmd.run() == 0
 
-    m.assert_called_once_with("src", out="out", fname="file")
+    m.assert_called_once_with("src", out="out", fname="file", no_exec=False)
 
 
 def test_failed_import_url(mocker, caplog):
@@ -31,3 +31,16 @@ def test_failed_import_url(mocker, caplog):
             "adding it with `dvc add`."
         )
         assert expected_error in caplog.text
+
+
+def test_import_url_no_exec(mocker):
+    cli_args = parse_args(
+        ["import-url", "--no-exec", "src", "out", "--file", "file"]
+    )
+
+    cmd = cli_args.func(cli_args)
+    m = mocker.patch.object(cmd.repo, "imp_url", autospec=True)
+
+    assert cmd.run() == 0
+
+    m.assert_called_once_with("src", out="out", fname="file", no_exec=True)


Code review comments:
diff_hunk: @@ -103,3 +103,12 @@ def test_import_stage_accompanies_target(tmp_dir, dvc, erepo_dir):
 def test_import_url_nonexistent(dvc, erepo_dir):
     with pytest.raises(DependencyDoesNotExistError):
         dvc.imp_url(os.fspath(erepo_dir / "non-existent"))
+
+
+def test_import_url_with_no_exec(tmp_dir, dvc, erepo_dir):
+    tmp_dir.gen({"data_dir": {"file": "file content"}})
+    src = os.path.join("data_dir", "file")
+
+    dvc.imp_url(src, "./", no_exec=True)
path: tests/func/test_import_url.py
text: @user1:
minor
```suggestion
    dvc.imp_url(src, ".", no_exec=True)
```

diff_hunk: @@ -103,3 +103,12 @@ def test_import_stage_accompanies_target(tmp_dir, dvc, erepo_dir):
 def test_import_url_nonexistent(dvc, erepo_dir):
     with pytest.raises(DependencyDoesNotExistError):
         dvc.imp_url(os.fspath(erepo_dir / "non-existent"))
+
+
+def test_import_url_with_no_exec(tmp_dir, dvc, erepo_dir):
+    tmp_dir.gen({"data_dir": {"file": "file content"}})
+    src = os.path.join("data_dir", "file")
+
+    dvc.imp_url(src, "./", no_exec=True)
+    dst = tmp_dir / "file"
+    assert dst.exists() is False
path: tests/func/test_import_url.py
text: @user1:
minor
```suggestion
    assert not dst.exists()
```