Repository: iterative/dvc

Issue:
repro: invalid argument quoting during dictionary interpolation
During dictionary interpolation, values are always enclosed in single quotes. On Windows this always results in an error because the `cmd` shell doesn't have single quotes. On Linux this results in an error at least if the value contains a single quote.

### Reproduce

A testing dvc.yaml:
```
vars:
  - param:
    param_1:
      param: testing quotes
    param_2:
      param: test'n quotes
stages:
  test_quotes_1: # This fails on Windows
    cmd: python -c "import sys; assert sys.argv[2] == 'testing quotes', sys.argv[2]" ${param_1} && echo Passed || echo Failed
  test_quotes_2: # This fails on both Windows and Linux
    cmd: python -c "import sys; assert sys.argv[2] == 'test\'n quotes', sys.argv[2]" ${param_2} && echo Passed || echo Failed
```
1. dvc init --no-scm
2. Create a dvc.yaml as above
3. dvc repro

On Windows: both stages print "Failed"
On Linux: `test_quotes_1` prins "Passed", `test_quotes_2` fails to execute due to bash syntax error.

### Expected

Both stages print "Passed"

### Environment information

**Output of `dvc doctor`:**
Windows:
```console
DVC version: 2.27.2 (pip)
---------------------------------
Platform: Python 3.9.7 on Windows-10-10.0.19044-SP0
Subprojects:
        dvc_data = 0.10.0
        dvc_objects = 0.4.0
        dvc_render = 0.0.11
        dvc_task = 0.1.2
        dvclive = 0.10.0
        scmrepo = 0.1.1
Supports:
        http (aiohttp = 3.7.4.post0, aiohttp-retry = 2.8.3),
        https (aiohttp = 3.7.4.post0, aiohttp-retry = 2.8.3),
        s3 (s3fs = 2022.8.2, boto3 = 1.24.78)
Cache types: hardlink
Cache directory: NTFS on C:\
Caches: local
Remotes: local
Workspace directory: NTFS on C:\
Repo: dvc, git
```
Linux:
```console
DVC version: 2.27.2 (pip)
---------------------------------
Platform: Python 3.9.7 on Linux-4.14.268-205.500.amzn2.x86_64-x86_64-with-glibc2.26
Subprojects:
        dvc_data = 0.10.0
        dvc_objects = 0.4.0
        dvc_render = 0.0.11
        dvc_task = 0.1.2
        dvclive = 0.11.0
        scmrepo = 0.1.1
Supports:
        http (aiohttp = 3.8.3, aiohttp-retry = 2.8.3),
        https (aiohttp = 3.8.3, aiohttp-retry = 2.8.3),
        s3 (s3fs = 2022.8.2, boto3 = 1.24.59)
Cache types: <https://error.dvc.org/no-dvc-cache>
Caches: local
Remotes: None
Workspace directory: xfs on /dev/xvda1
Repo: dvc (no_scm)
```




Discussion:

Resolving patch:
diff --git a/dvc/parsing/interpolate.py b/dvc/parsing/interpolate.py
--- a/dvc/parsing/interpolate.py
+++ b/dvc/parsing/interpolate.py
@@ -1,3 +1,4 @@
+import os
 import re
 import typing
 from collections.abc import Iterable, Mapping
@@ -70,6 +71,16 @@ def embrace(s: str):
     return BRACE_OPEN + s + BRACE_CLOSE
 
 
+def escape_str(value):
+    if os.name == "nt":
+        from subprocess import list2cmdline
+
+        return list2cmdline([value])
+    from shlex import quote
+
+    return quote(value)
+
+
 @singledispatch
 def to_str(obj) -> str:
     return str(obj)
@@ -97,12 +108,12 @@ def _(obj: dict):
                     result += f"--no-{k} "
 
         elif isinstance(v, str):
-            result += f"--{k} '{v}' "
+            result += f"--{k} {escape_str(v)} "
 
         elif isinstance(v, Iterable):
             for n, i in enumerate(v):
                 if isinstance(i, str):
-                    i = f"'{i}'"
+                    i = escape_str(i)
                 elif isinstance(i, Iterable):
                     raise ParseError(
                         f"Cannot interpolate nested iterable in '{k}'"


Test patch:
diff --git a/tests/func/parsing/test_interpolated_entry.py b/tests/func/parsing/test_interpolated_entry.py
--- a/tests/func/parsing/test_interpolated_entry.py
+++ b/tests/func/parsing/test_interpolated_entry.py
@@ -6,6 +6,7 @@
 from dvc.dependency import _merge_params
 from dvc.parsing import DEFAULT_PARAMS_FILE, DataResolver
 from dvc.parsing.context import recurse_not_a_node
+from dvc.parsing.interpolate import escape_str
 
 from . import (
     CONTEXT_DATA,
@@ -272,14 +273,17 @@ def test_cmd_dict(tmp_dir, dvc, bool_config, list_config):
         if list_config:
             conf["parsing"]["list"] = list_config
 
+    string = "spaced string"
+    mixed_quote_string = "quote\"'d"
     data = {
         "dict": {
             "foo": "foo",
             "bar": 2,
-            "string": "spaced string",
+            "string": string,
+            "mixed_quote_string": mixed_quote_string,
             "bool": True,
             "bool-false": False,
-            "list": [1, 2, "foo"],
+            "list": [1, 2, "foo", mixed_quote_string],
             "nested": {"foo": "foo"},
         }
     }
@@ -296,9 +300,10 @@ def test_cmd_dict(tmp_dir, dvc, bool_config, list_config):
         bool_resolved = " --bool --no-bool-false"
 
     if list_config is None or list_config == "nargs":
-        list_resolved = " --list 1 2 'foo'"
+        list_resolved = f" --list 1 2 foo {escape_str(mixed_quote_string)}"
     else:
-        list_resolved = " --list 1 --list 2 --list 'foo'"
+        list_resolved = " --list 1 --list 2 --list foo"
+        list_resolved += f" --list {escape_str(mixed_quote_string)}"
 
     assert_stage_equal(
         resolver.resolve(),
@@ -306,11 +311,12 @@ def test_cmd_dict(tmp_dir, dvc, bool_config, list_config):
             "stages": {
                 "stage1": {
                     "cmd": "python script.py"
-                    " --foo 'foo' --bar 2"
-                    " --string 'spaced string'"
+                    " --foo foo --bar 2"
+                    f" --string {escape_str(string)}"
+                    f" --mixed_quote_string {escape_str(mixed_quote_string)}"
                     f"{bool_resolved}"
                     f"{list_resolved}"
-                    " --nested.foo 'foo'"
+                    " --nested.foo foo"
                 }
             }
         },