Repository: amoffat/sh
Pull request #639 (2023-02-09 10:24:02)

Feature/repeating kwargs


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eeec87a..1e94186 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
 - Removed the builtin override `cd` [link](https://github.com/amoffat/sh/pull/584#discussion_r698055681)
 - Altered process launching model to behave more expectedly [#495](https://github.com/amoffat/sh/issues/495)
 - Bugfix where `_no_out` isn't allowed with `_iter="err"` [#638](https://github.com/amoffat/sh/issues/638)
+- Allow keyword arguments to have a list of values [#529](https://github.com/amoffat/sh/issues/529)
 
 ## 1.14.3 - 7/17/22
 
diff --git a/sh.py b/sh.py
index 6988b0d..ac8b6ac 100644
--- a/sh.py
+++ b/sh.py
@@ -622,7 +622,7 @@ class RunningCommand(object):
     exceptions."""
 
     # these are attributes that we allow to pass through to OProc
-    _OProc_attr_whitelist = {
+    _OProc_attr_allowlist = {
         "signal",
         "terminate",
         "kill",
@@ -969,7 +969,7 @@ class RunningCommand(object):
 
     def __getattr__(self, p):
         # let these three attributes pass through to the OProc object
-        if p in self._OProc_attr_whitelist:
+        if p in self._OProc_attr_allowlist:
             if self.process:
                 return getattr(self.process, p)
             else:
@@ -1114,8 +1114,8 @@ def fg_validator(passed_kwargs, merged_kwargs):
 _fg is invalid with nearly every other option, see warning and workaround here:
 
     https://amoffat.github.io/sh/sections/special_arguments.html#fg"""
-    whitelist = {"env", "fg", "cwd"}
-    offending = set(passed_kwargs.keys()) - whitelist
+    allowlist = {"env", "fg", "cwd"}
+    offending = set(passed_kwargs.keys()) - allowlist
 
     if "fg" in passed_kwargs and passed_kwargs["fg"] and offending:
         invalid.append(("fg", msg))
@@ -1280,7 +1280,7 @@ class Command(object):
         # whether or not to close all inherited fds. typically, this should be True,
         # as inheriting fds can be a security vulnerability
         "close_fds": True,
-        # a whitelist of the integer fds to pass through to the child process. setting
+        # a allowlist of the integer fds to pass through to the child process. setting
         # this forces close_fds to be True
         "pass_fds": set(),
         # return an instance of RunningCommand always. if this isn't True, then
@@ -1556,7 +1556,7 @@ def compile_args(a, kwargs, sep, prefix):
             for sub_arg in arg:
                 processed_args.append(sub_arg)
         elif isinstance(arg, dict):
-            processed_args += aggregate_keywords(arg, sep, prefix, raw=True)
+            processed_args += _aggregate_keywords(arg, sep, prefix, raw=True)
 
         # see https://github.com/amoffat/sh/issues/522
         elif arg is None or arg is False:
@@ -1565,12 +1565,12 @@ def compile_args(a, kwargs, sep, prefix):
             processed_args.append(str(arg))
 
     # aggregate the keyword arguments
-    processed_args += aggregate_keywords(kwargs, sep, prefix)
+    processed_args += _aggregate_keywords(kwargs, sep, prefix)
 
     return processed_args
 
 
-def aggregate_keywords(keywords, sep, prefix, raw=False):
+def _aggregate_keywords(keywords, sep, prefix, raw=False):
     """take our keyword arguments, and a separator, and compose the list of
     flat long (and short) arguments.  example
 
@@ -1607,34 +1607,42 @@ def aggregate_keywords(keywords, sep, prefix, raw=False):
 
     processed = []
 
-    for k, v in keywords.items():
-        # we're passing a short arg as a kwarg, example:
-        # cut(d="\t")
-        if len(k) == 1:
-            if v is not False:
-                processed.append("-" + k)
-                if v is not True:
-                    processed.append(str(v))
-
-        # we're doing a long arg
-        else:
-            if not raw:
-                k = k.replace("_", "-")
-
-            # if it's true, it has no value, just pass the name
-            if v is True:
-                processed.append(prefix + k)
-            # if it's false, skip passing it
-            elif v is False:
-                pass
-            # we may need to break the argument up into multiple arguments
-            elif sep is None or sep == " ":
-                processed.append(prefix + k)
-                processed.append(str(v))
-            # otherwise just join it together into a single argument
+    for k, maybe_list_of_v in keywords.items():
+        # turn our value(s) into a list of values so that we can process them
+        # all individually under the same key
+        list_of_v = [maybe_list_of_v]
+        if isinstance(maybe_list_of_v, (list, tuple)):
+            list_of_v = maybe_list_of_v
+
+        for v in list_of_v:
+            # we're passing a short arg as a kwarg, example:
+            # cut(d="\t")
+            if len(k) == 1:
+                if v is not False:
+                    processed.append("-" + k)
+                    if v is not True:
+                        processed.append(str(v))
+
+            # we're doing a long arg
             else:
-                arg = f"{prefix}{k}{sep}{v}"
-                processed.append(arg)
+                if not raw:
+                    k = k.replace("_", "-")
+
+                # if it's true, it has no value, just pass the name
+                if v is True:
+                    processed.append(prefix + k)
+                # if it's false, skip passing it
+                elif v is False:
+                    pass
+
+                # we may need to break the argument up into multiple arguments
+                elif sep is None or sep == " ":
+                    processed.append(prefix + k)
+                    processed.append(str(v))
+                # otherwise just join it together into a single argument
+                else:
+                    arg = f"{prefix}{k}{sep}{v}"
+                    processed.append(arg)
 
     return processed
 
@@ -3365,7 +3373,7 @@ class Environment(dict):
     # not resolve to functions.  we don't want to accidentally shadow real
     # commands with functions/imports that we define in sh.py.  for example,
     # "import time" may override the time system program
-    whitelist = {
+    allowlist = {
         "Command",
         "RunningCommand",
         "CommandNotFound",
@@ -3377,6 +3385,7 @@ class Environment(dict):
         "ForkException",
         "TimeoutException",
         "StreamBufferer",
+        "_aggregate_keywords",
         "__project_url__",
         "__version__",
         "__file__",
@@ -3403,8 +3412,8 @@ class Environment(dict):
             k = "_args"
 
         # if we're trying to import something real, see if it's in our global scope.
-        # what defines "real" is that it's in our whitelist
-        if k in self.whitelist:
+        # what defines "real" is that it's in our allowlist
+        if k in self.allowlist:
             return self.globs[k]
 
         # somebody tried to be funny and do "from sh import *"


Test patch:
diff --git a/tests/test.py b/tests/test.py
index 1a9951e..798a7b1 100644
--- a/tests/test.py
+++ b/tests/test.py
@@ -172,6 +172,20 @@ class BaseTests(unittest.TestCase):
             self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
 
 
+class ArgTests(BaseTests):
+    def test_list_args(self):
+        processed = sh._aggregate_keywords({"arg": [1, 2, 3]}, "=", "--")
+        self.assertListEqual(processed, ["--arg=1", "--arg=2", "--arg=3"])
+
+    def test_bool_values(self):
+        processed = sh._aggregate_keywords({"truthy": True, "falsey": False}, "=", "--")
+        self.assertListEqual(processed, ["--truthy"])
+
+    def test_space_sep(self):
+        processed = sh._aggregate_keywords({"arg": "123"}, " ", "--")
+        self.assertListEqual(processed, ["--arg", "123"])
+
+
 @requires_posix
 class FunctionalTests(BaseTests):
     def setUp(self):