Repository: iterative/dvc

Issue:
Experiments, parameters, and the CLI: remove comma-separated parsing
Bringing this conversation over from Discord:
> For v2.0, are the devs open to changing the CLI for dvc exp run so that --params are expected space-separated instead of comma-separated (e.g. `dvc exp run --params value1=1 value2=foo`)? I feel this would be a much more typical UX, and it would allow for more powerful functionality (e.g. being able to pass in an arbitrary YAML object). Also, it would completely remove all logic around how to split the parameters from your sphere of concern, because it's all handled by argparse and properly quoting things when writing your command (e.g. `dvc exp run --params 'value1={"foo": "look at these space, commas too!"}'`)

After feedback from @efiop and @dberenbaum, we thought a slightly different approach would be better so we could even get rid of the parsing of the equals sign:

```
dvc exp run --params param_name1 param_value1 --params param_name_2 param_value_2
```

A simple proof of concept is provided below:
```python
# test.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument(
    '--params',
    action='append',
    metavar=('param_name', 'param_value'),
    nargs=2,
)
print(parser.parse_args())
```

```bash
$ python test.py --params foo bar --params goo '{"baz": "maybe?"}'
Namespace(params=[['foo', 'bar'], ['goo', '{"baz": "maybe?"}']])
```

I know there's #4883 for a full-on hydra approach, but what I'm suggesting is much simpler and actually reduces complexity. With 2.0 coming up and this being a breaking change, I thought now would be the perfect time to do it. I'm happy to work on this if there are no objections.


Discussion:
One thing worth considering is that sticking with a key=value format might be the better choice if you do want to eventually use hydra. Since hydra expects key=value, if you use that you could potentially drop it in later without the change being breaking. Splitting on the first equals sign in a string should be much safer than the comma splitting that's happening now.

```python
>>> args = 'params.yaml:nested.param={"complex": "value with spaces, and commas", "query": "test == 3"}'
>>> param_name, _, param_value = args.partition('=')
>>> param_file, _, param_name = param_name.partition(':')
>>> param_file, param_name, param_value
('params.yaml', 'nested.param', '{"complex": "value with spaces, and commas", "query": "test == 3"}')
```

if `param_name` is empty, set it `param_file` and set  `param_file` to the default. If `param_value` is empty, raise an error.
@sjawhar, great suggestion. The problem I see with it is that files other than `params.yaml` will become harder to type as you'll need to keep specifying it again and again. But, I guess it's okay, as it might not be a good idea to encourage users to override a lot of params through the cmd itself.

 



Thanks for your diligence on this, @sjawhar !

Stepping back from Hydra integration, consistency and simplicity are important. @efiop mentioned that we should have a guide. Repeating the `--params` option each time is consistent with the [POSIX standard](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html) (see 12.1.9), and it makes it easier to parse, so it seems like a good change.

I suggested `key value` but `key=value` is probably a better choice. Otherwise, it may look like `key` and `value` are each independent arguments passed to `--params`.

Resolving patch:
diff --git a/dvc/command/experiments.py b/dvc/command/experiments.py
--- a/dvc/command/experiments.py
+++ b/dvc/command/experiments.py
@@ -521,7 +521,7 @@ def run(self):
             queue=self.args.queue,
             run_all=self.args.run_all,
             jobs=self.args.jobs,
-            params=self.args.params,
+            params=self.args.set_param,
             checkpoint_resume=self.args.checkpoint_resume,
             reset=self.args.reset,
             tmp_dir=self.args.tmp_dir,
@@ -1184,11 +1184,12 @@ def _add_run_common(parser):
         metavar="<name>",
     )
     parser.add_argument(
-        "--params",
+        "-S",
+        "--set-param",
         action="append",
         default=[],
-        help="Use the specified param values when reproducing pipelines.",
-        metavar="[<filename>:]<params_list>",
+        help="Use the specified param value when reproducing pipelines.",
+        metavar="[<filename>:]<param_name>=<param_value>",
     )
     parser.add_argument(
         "--queue",
diff --git a/dvc/repo/experiments/run.py b/dvc/repo/experiments/run.py
--- a/dvc/repo/experiments/run.py
+++ b/dvc/repo/experiments/run.py
@@ -2,7 +2,7 @@
 from typing import Iterable, Optional
 
 from dvc.repo import locked
-from dvc.utils.cli_parse import loads_params
+from dvc.utils.cli_parse import loads_param_overrides
 
 logger = logging.getLogger(__name__)
 
@@ -33,7 +33,7 @@ def run(
         return repo.experiments.reproduce_queued(jobs=jobs)
 
     if params:
-        params = loads_params(params)
+        params = loads_param_overrides(params)
     return repo.experiments.reproduce_one(
         targets=targets, params=params, tmp_dir=tmp_dir, **kwargs
     )
diff --git a/dvc/utils/cli_parse.py b/dvc/utils/cli_parse.py
--- a/dvc/utils/cli_parse.py
+++ b/dvc/utils/cli_parse.py
@@ -17,31 +17,35 @@ def parse_params(path_params: Iterable[str]) -> List[Dict[str, List[str]]]:
     return [{path: params} for path, params in ret.items()]
 
 
-def loads_params(path_params: Iterable[str],) -> Dict[str, Dict[str, Any]]:
-
+def loads_param_overrides(
+    path_params: Iterable[str],
+) -> Dict[str, Dict[str, Any]]:
     """Loads the content of params from the cli as Python object."""
     from ruamel.yaml import YAMLError
 
+    from dvc.dependency.param import ParamsDependency
     from dvc.exceptions import InvalidArgumentError
 
     from .serialize import loads_yaml
 
-    normalized_params = parse_params(path_params)
     ret: Dict[str, Dict[str, Any]] = defaultdict(dict)
 
-    for part in normalized_params:
-        assert part
-        (item,) = part.items()
-        path, param_keys = item
-
-        for param_str in param_keys:
-            try:
-                key, _, value = param_str.partition("=")
-                # interpret value strings using YAML rules
-                parsed = loads_yaml(value)
-                ret[path][key] = parsed
-            except (ValueError, YAMLError):
-                raise InvalidArgumentError(
-                    f"Invalid param/value pair '{param_str}'"
-                )
+    for path_param in path_params:
+        param_name, _, param_value = path_param.partition("=")
+        if not param_value:
+            raise InvalidArgumentError(
+                f"Must provide a value for parameter '{param_name}'"
+            )
+        path, _, param_name = param_name.partition(":")
+        if not param_name:
+            param_name = path
+            path = ParamsDependency.DEFAULT_PARAMS_FILE
+
+        try:
+            ret[path][param_name] = loads_yaml(param_value)
+        except (ValueError, YAMLError):
+            raise InvalidArgumentError(
+                f"Invalid parameter value for '{param_name}': '{param_value}"
+            )
+
     return ret


Test patch:
diff --git a/tests/func/experiments/test_experiments.py b/tests/func/experiments/test_experiments.py
--- a/tests/func/experiments/test_experiments.py
+++ b/tests/func/experiments/test_experiments.py
@@ -103,7 +103,7 @@ def test_failed_exp(tmp_dir, scm, dvc, exp_stage, mocker, caplog):
     "changes, expected",
     [
         [["foo=baz"], "{foo: baz, goo: {bag: 3}, lorem: false}"],
-        [["foo=baz,goo=bar"], "{foo: baz, goo: bar, lorem: false}"],
+        [["foo=baz", "goo=bar"], "{foo: baz, goo: bar, lorem: false}"],
         [
             ["goo.bag=4"],
             "{foo: [bar: 1, baz: 2], goo: {bag: 4}, lorem: false}",
@@ -114,7 +114,7 @@ def test_failed_exp(tmp_dir, scm, dvc, exp_stage, mocker, caplog):
             "{foo: [bar: 1, baz: 3], goo: {bag: 3}, lorem: false}",
         ],
         [
-            ["foo[1]=- baz\n- goo"],
+            ["foo[1]=[baz, goo]"],
             "{foo: [bar: 1, [baz, goo]], goo: {bag: 3}, lorem: false}",
         ],
         [
@@ -261,7 +261,11 @@ def test_update_py_params(tmp_dir, scm, dvc):
 
     results = dvc.experiments.run(
         stage.addressing,
-        params=["params.py:FLOAT=0.1,Train.seed=2121,Klass.a=222"],
+        params=[
+            "params.py:FLOAT=0.1",
+            "params.py:Train.seed=2121",
+            "params.py:Klass.a=222",
+        ],
         tmp_dir=True,
     )
     exp_a = first(results)