From a33bbc97d5d9c2baf71c08deba58348cbe43ed29 Mon Sep 17 00:00:00 2001 From: David de la Iglesia Castro Date: Tue, 27 Sep 2022 21:03:08 +0200 Subject: [PATCH] parsing: Escape str interpolation in dict unpacking. Use subprocess.list2cmdline (windows) / shlex.quote Closes #8203 Closes #8370 --- dvc/parsing/interpolate.py | 15 ++++++++++++-- tests/func/parsing/test_interpolated_entry.py | 20 ++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/dvc/parsing/interpolate.py b/dvc/parsing/interpolate.py index 137eaf340..db53c46bb 100644 --- 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}'" diff --git a/tests/func/parsing/test_interpolated_entry.py b/tests/func/parsing/test_interpolated_entry.py index 3be23b6d5..0fa56a59c 100644 --- a/tests/func/parsing/test_interpolated_entry.py +++ b/tests/func/parsing/test_interpolated_entry.py @@ -6,6 +6,7 @@ import pytest 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" } } },