Repository: pypa/pipenv

Issue:
Quote command if parentheses exist
Thank you for contributing to Pipenv!


### The issue

Fixes #3168 

### The fix

Quote the command if it contains `()`.

### The checklist

* [x] Associated issue
* [x] A news fragment in the `news/` directory to describe this fix with the extension `.bugfix`, `.feature`, `.behavior`, `.doc`. `.vendor`. or `.trivial` (this will appear in the release changelog). Use semantic line breaks and name the file after the issue number or the PR #.

<!--
### If this is a patch to the `vendor` directory…

Please try to refrain from submitting patches directly to `vendor` or `patched`, but raise your issue to the upstream project instead, and inform Pipenv to upgrade when the upstream project accepts the fix.

A pull request to upgrade vendor packages is strongly discouraged, unless there is a very good reason (e.g. you need to test Pipenv’s integration to a new vendor feature). Pipenv audits and performs vendor upgrades regularly, generally before a new release is about to drop.

If your patch is not or cannot be accepted by upstream, but is essential to Pipenv (make sure to discuss this with maintainers!), please remember to attach a patch file in `tasks/vendoring/patched`, so this divergence from upstream can be recorded and replayed afterwards.
-->



Discussion:
Gonna leave this for @uranusjr since he wrote the implementation but it seems ok to me
However

```
> echo (foo)
(foo)
> echo "(foo)"
"(foo)"
```

Maybe a better approach is to apply this on the command, but keep `\s` for other arguments?
@uranusjr It is interesting, Windows only though.

Does `delegator` accept command as a list?
The cmdify method is designed to work only for Windows. delegator accepts lists, but the implementation (by subprocess) has problems, which is why this whole function exists in the first place.
I thought about this a bit, and the only sane solution is probably to distinguish between the first (command) and other parts.

Resolving patch:
diff --git a/pipenv/cmdparse.py b/pipenv/cmdparse.py
--- a/pipenv/cmdparse.py
+++ b/pipenv/cmdparse.py
@@ -1,3 +1,4 @@
+import itertools
 import re
 import shlex
 
@@ -8,6 +9,12 @@ class ScriptEmptyError(ValueError):
     pass
 
 
+def _quote_if_contains(value, pattern):
+    if next(re.finditer(pattern, value), None):
+        return '"{0}"'.format(re.sub(r'(\\*)"', r'\1\1\\"', value))
+    return value
+
+
 class Script(object):
     """Parse a script line (in Pipfile's [scripts] section).
 
@@ -56,17 +63,21 @@ def cmdify(self):
         The result is then quoted into a pair of double quotes to be grouped.
 
         An argument is intentionally not quoted if it does not contain
-        whitespaces. This is done to be compatible with Windows built-in
+        foul characters. This is done to be compatible with Windows built-in
         commands that don't work well with quotes, e.g. everything with `echo`,
         and DOS-style (forward slash) switches.
 
+        Foul characters include:
+
+        * Whitespaces.
+        * Parentheses in the command. (pypa/pipenv#3168)
+
         The intended use of this function is to pre-process an argument list
         before passing it into ``subprocess.Popen(..., shell=True)``.
 
         See also: https://docs.python.org/3/library/subprocess.html#converting-argument-sequence
         """
-        return " ".join(
-            arg if not next(re.finditer(r'\s', arg), None)
-            else '"{0}"'.format(re.sub(r'(\\*)"', r'\1\1\\"', arg))
-            for arg in self._parts
-        )
+        return " ".join(itertools.chain(
+            [_quote_if_contains(self.command, r'[\s()]')],
+            (_quote_if_contains(arg, r'\s') for arg in self.args),
+        ))


Test patch:
diff --git a/tests/unit/test_cmdparse.py b/tests/unit/test_cmdparse.py
--- a/tests/unit/test_cmdparse.py
+++ b/tests/unit/test_cmdparse.py
@@ -47,3 +47,20 @@ def test_cmdify_complex():
         '-c',
         """ "print(\'Double quote: \\\"\')" """.strip(),
     ]), script
+
+
+@pytest.mark.run
+@pytest.mark.script
+def test_cmdify_quote_if_paren_in_command():
+    """Ensure ONLY the command is quoted if it contains parentheses.
+    """
+    script = Script.parse(' '.join([
+        '"C:\\Python36(x86)\\python.exe"',
+        '-c',
+        "print(123)",
+    ]))
+    assert script.cmdify() == ' '.join([
+        '"C:\\Python36(x86)\\python.exe"',
+        '-c',
+        "print(123)",
+    ]), script