Repository: iterative/dvc

Issue:
run: allow tracking of all params in a file with `-p`
## Bug Report

### Please provide information about your setup

**Output of `dvc version`:**

```console
DVC version: 1.0.2
Python version: 3.6.8
Platform: Linux-4.15.0-106-generic-x86_64-with-LinuxMint-18.3-sylvia
Binary: False
Package: pip
Supported remotes: http, https, ssh
Cache: reflink - not supported, hardlink - supported, symlink - supported
Repo: dvc, git
```

**Additional Information (if any):**

I tried to use `dvc run` with two `-p` arguments (because I have two parameters files). It gives me this error:
```
ERROR: dependency 'params.yaml' does not exist
```

If I remove one of the two arguments, it works. So it looks like DVC doesn't enjoy having two `-p` arguments. Is it expected? If DVC doesn't support more than one parameters file, the ERROR message should indicate that more explicitly.


Comments:
@gcoter, does the params.yaml exist? Can you give more verbose error message (and, your command that you tried)?
@skshetry thanks for your quick answer! My command looks like this:
```
dvc run -d training.py -p config/params_1.json -p config/params_2.json:data.training,model -o trained_model/ -n training python training.py
```

I prefer to use JSON files so `params.yaml` does not exist in my project.
@gcoter, is that a typo on `-p config/params_1.json` (i.e. there's no list of parameters eg: `-p config/params_1.json:loss`).
@skshetry No, I thought that specifying `config/params_1.json` would select everything inside this file. Should I specify explicitly the keys I want to select?
@gcoter, yes, you have to specify keys that you want.
@skshetry Ok, indeed it works if I specify manually the keys. I thought that DVC would list all the keys by default. But I don't mind listing them myself. Thank you for your help!
@gcoter, but, I can see it's usefulness. I'll log this as a feature request.
@gcoter, another question, why do you choose to `-p` rather than tracking whole file? for granularity tracking of parameter values, right?
@skshetry I am at the beginning of the project, so it is not very clear in my mind, but I would like to benefit from the `dvc params diff` command. Basically, if I use `-d`, I cannot diff the params. Well, I could use `git diff` but `dvc params diff` formats the diff in a nice way in my opinion. And since this file is actually used to store configuration settings, it makes sense to me to use `-p`.
#### Simple workaround for json files

Define bash functions in `.bashrc`

```
function print_json_keys {
    json_file=$1
    if [ "$2" ]
    then
        sep="$2"
    else
        sep=','
    fi
    jq -r '[paths | join(".")]' $1 | sed -n 's/\ *"\(.*\)",*/\1/p' | head -c -1 | tr '\r\n' $sep
}

function get_all_dvc_json_params {
    json_file=$1
    echo "$json_file:$(print_json_keys $json_file)"
}
```

then use, assuming you have params in `config.json`

```
dvc run -p $(get_all_dvc_json_params config.json)
```

@skshetry let me know if you think it makes sense to add this as default behavior for DVC when the parameters are not defined. I can to take add this to DVC if I receive some guidance.
For future reference, the file must be named `params.yaml`, not `params.yml` for DVC to see it.
@lambdaofgod, sure, go ahead. We might just need to support it as `-p params.yaml` or `-p params.json` (without explicit `:`). 
But, the main question is, what the behavior should be.

1) Should it work like your script, i.e. fetch all params and track each of them individually?
2) Or, should it just act like other output dependencies, in that the stage reproduces if you add/delete any params in the file?

If 1), then how should we specify them on `dvc.yaml`? Should that be limited to just this helper function?
Option 2 makes sense to me because:

1. If a new parameter is added, I would rather err on the side of being overly aggressive in detecting parameters changes rather than ignore it.
2. It's clean and simple in `dvc.yaml` (list the file instead of breaking out each individual key).
3. It seems consistent with how YAML section keys are handled now. I don't see much difference from the current handling of `-p foo` with a `params.yaml` like:

```yaml
foo:
  bar: 1
  baz: 2
```

We have two choices on syntax in `dvc.yaml`.

#### 1. Using `null` to indicate track everything
```yaml
params:
  - params.yaml:
  - config.yaml:
```

  Pros:
  1. This does not require us to create our own syntax/DSL, it is YAML.
  2. (Better schema?)
 

  Cons:
  1.  This syntax requires filename to be explicitly specified, even for `params.yaml` file which we consider as being the default.
  2. The semantic meaning of `null` to mean track everything is questionable. However, it depends on the narrative we want to set. Currently, the purpose of `params` is that it can track parameters in a granular way. If we do change that to suggest that DVC can track params as a whole, but it can also support things at a granular level, it does make sense as well.
  (This is closer to implementation).

#### 2. Using `*`
```yaml
params:
 - *   # means track everything from params.yaml
 - config.yaml:*   # means track everything from config.yaml
```

Pros:
1. Supports tracking `params.yaml` easily by just using `*`.

 Cons:
1. Requires us to create our own DSL, which may be confused with being a regex.


> We have two choices on syntax in `dvc.yaml`.

Why it's not possible to support the following?

```yaml
params:
  - custom_params.yaml  # full file
  - foo  # dvc will search for this as usual
```

It is ambiguous. The `foo` may be a params file, and `custom_params.yaml` may be just a hyperparameter `yaml` nested inside `custom_params`. 

Note that this is only a metadata, and the files don't have to necessarily be present in the workspace, eg: the file could have been deleted or be read from certain revisions, etc. so the fallback approaches are going to be ugly.

> It is ambiguous. The `foo` may be a params file, and `custom_params.yaml` may be just a hyperparameter `yaml` nested inside `custom_params`. 

I find it more intuitive than the other syntax, this is what users usually try first and report as a bug when it fails.

For conflicts, we can document the behavior and add priority for resolving: `checking if {name} is in params.yaml > checking if {name} is a file and exists`. Although, tbh, I don't think it's common to use a parameters file without extension nor name a parameter with a extension.

> Note that this is only a metadata, and the files don't have to necessarily be present in the workspace, eg: the file could have been deleted or be read from certain revisions, etc. so the fallback approaches are going to be ugly.

Not sure if I understand why this is an issue only with this syntax

It's better to avoid ambiguity if possible. And, `diff` and `status` have stronger requirements.
For example, what will you show `dvc status` for `foo` if the `foo` file and `params.yaml` both don't exist at all? 

Also `dvc` supports any arbitrary file (it just fallbacks to yaml), so `foo.bar` could be a params file or an xpath to that parameters.

> I find it more intuitive than the other syntax, this is what users usually try first and report as a bug when it fails.

I know it's intuitive, but I don't think we have a much better choice. Given that we consider this to be an advanced feature, it's okay to consider it as a tradeoff.
> ```yaml
> params:
>   - custom_params.yaml  # full file
>   - foo  # dvc will search for this as usual
> ```

I think we could come up with consistent logic to deal with ambiguities here, so I think we should focus on whether the intuitiveness/convenience outweighs the ambiguity.

Can we assume any param that ends in `.yaml`, `.yml`, `.json`, `.toml`, or `.py` is a file path, and otherwise it's not? Is there a realistic scenario when this would be problematic?

> It's better to avoid ambiguity if possible. And, `diff` and `status` have stronger requirements.
> For example, what will you show `dvc status` for `foo` if the `foo` file and `params.yaml` both don't exist at all?

Doesn't the use of `.` to specify nested keys already introduce ambiguity (how do we know whether this is a nested key or just a top-level key with `.` in it)? Is this different?

> Also `dvc` supports any arbitrary file (it just fallbacks to yaml), so `foo.bar` could be a params file or an xpath to that parameters.

Aren't parameters limited to particular file types, and dvc uses the file extension to determine the type? It seems like the only added ambiguity is when dvc falls back to yaml, and I don't think it's unreasonable to expect `.yaml` or `.yml` there.
> Can we assume any param that ends in .yaml, .yml, .json, .toml, or .py is a file path, and otherwise it's not? Is there a realistic scenario when this would be problematic?

It's a weird restriction to not be able to track keys named `yaml`/`yml`/json`/`toml`/`py`. These file extensions are likely to expand in the future.

> Doesn't the use of . to specify nested keys already introduce ambiguity (how do we know whether this is a nested key or just a top-level key with . in it)? Is this different?

We don't support the keys with `.` at all. `.` is strictly a separator.

> Aren't parameters limited to particular file types, and dvc uses the file extension to determine the type? It seems like the only added ambiguity is when dvc falls back to yaml, and I don't think it's unreasonable to expect .yaml or .yml there.

yes, DVC uses file extensions to determine the type and falls back to yaml parser which supports `json` and `yaml` contents.

Overloading the schema to track parameters in granular and in whole may be confusing. 


> ```yaml
> params:
>   - params.yaml:
>   - config.yaml:
> ```

How will this work in CLI? `dvc stage add -p config.yaml:`? Will that cause any issues?

> It's a weird restriction to not be able to track keys named `yaml`/`yml`/json`/`toml`/`py`. These file extensions are likely to expand in the future.

I think we can document that supported file extensions cannot be used as parameter keys.

> yes, DVC uses file extensions to determine the type and falls back to yaml parser which supports `json` and `yaml` contents.

So if we rely on file extensions, the breaking changes would be:
* Parameter keys like `yaml` or other file extensions are no longer supported.
* YAML/JSON parameter files can't use nonstandard extensions.

@skshetry Does that all sound right? If you think there's a good way to go with option 1 and have the CLI make sense without introducing breaking changes, I'm fine with that. Otherwise, I think the file extension approach is intuitive and the breaking changes aren't too bad as long as we document them.
@daavoo mentioned that tracking params directories could come up in the future. It seems like option 1 would be better if we want to make future changes like this easier.

Patch:
diff --git a/dvc/dependency/__init__.py b/dvc/dependency/__init__.py
--- a/dvc/dependency/__init__.py
+++ b/dvc/dependency/__init__.py
@@ -47,16 +47,29 @@ def loads_from(stage, s_list, erepo=None):
 def _merge_params(s_list):
     d = defaultdict(list)
     default_file = ParamsDependency.DEFAULT_PARAMS_FILE
+
+    # figure out completely tracked params file, and ignore specific keys
+    wholly_tracked = set()
+    for key in s_list:
+        if not isinstance(key, dict):
+            continue
+        wholly_tracked.update(k for k, params in key.items() if not params)
+
     for key in s_list:
         if isinstance(key, str):
-            d[default_file].append(key)
+            if default_file not in wholly_tracked:
+                d[default_file].append(key)
             continue
+
         if not isinstance(key, dict):
             msg = "Only list of str/dict is supported. Got: "
             msg += f"'{type(key).__name__}'."
             raise ValueError(msg)
 
         for k, params in key.items():
+            if k in wholly_tracked:
+                d[k] = []
+                continue
             if not isinstance(params, list):
                 msg = "Expected list of params for custom params file "
                 msg += f"'{k}', got '{type(params).__name__}'."
diff --git a/dvc/dependency/param.py b/dvc/dependency/param.py
--- a/dvc/dependency/param.py
+++ b/dvc/dependency/param.py
@@ -1,7 +1,9 @@
 import errno
 import logging
 import os
+import typing
 from collections import defaultdict
+from typing import Dict
 
 import dpath.util
 from voluptuous import Any
@@ -37,51 +39,82 @@ class ParamsDependency(Dependency):
     DEFAULT_PARAMS_FILE = "params.yaml"
 
     def __init__(self, stage, path, params=None, repo=None):
-        info = {}
-        self.params = params or []
-        if params:
-            if isinstance(params, list):
-                self.params = params
-            else:
-                assert isinstance(params, dict)
-                self.params = list(params.keys())
-                info = {self.PARAM_PARAMS: params}
-
-        super().__init__(
-            stage,
-            path
-            or os.path.join(stage.repo.root_dir, self.DEFAULT_PARAMS_FILE),
-            info=info,
-            repo=repo,
+        self.params = list(params) if params else []
+        info = (
+            {self.PARAM_PARAMS: params} if isinstance(params, dict) else None
         )
+        repo = repo or stage.repo
+        path = path or os.path.join(repo.root_dir, self.DEFAULT_PARAMS_FILE)
+        super().__init__(stage, path, info=info, repo=repo)
 
     def dumpd(self):
         ret = super().dumpd()
         if not self.hash_info:
-            ret[self.PARAM_PARAMS] = self.params
+            ret[self.PARAM_PARAMS] = self.params or {}
         return ret
 
     def fill_values(self, values=None):
         """Load params values dynamically."""
-        if not values:
+        if values is None:
             return
+
         info = {}
+        if not self.params:
+            info.update(values)
         for param in self.params:
             if param in values:
                 info[param] = values[param]
         self.hash_info = HashInfo(self.PARAM_PARAMS, info)
 
+    def read_params(
+        self, flatten: bool = True, **kwargs: typing.Any
+    ) -> Dict[str, typing.Any]:
+        try:
+            config = self.read_file()
+        except MissingParamsFile:
+            config = {}
+
+        if not self.params:
+            return config
+
+        ret = {}
+        if flatten:
+            for param in self.params:
+                try:
+                    ret[param] = dpath.util.get(config, param, separator=".")
+                except KeyError:
+                    continue
+            return ret
+
+        from dpath.util import merge
+
+        for param in self.params:
+            merge(
+                ret,
+                dpath.util.search(config, param, separator="."),
+                separator=".",
+            )
+        return ret
+
     def workspace_status(self):
-        status = super().workspace_status()
+        if not self.exists:
+            return {str(self): "deleted"}
+        if self.hash_info.value is None:
+            return {str(self): "new"}
 
-        if status.get(str(self)) == "deleted":
-            return status
+        from funcy import ldistinct
 
         status = defaultdict(dict)
         info = self.hash_info.value if self.hash_info else {}
         actual = self.read_params()
-        for param in self.params:
-            if param not in actual.keys():
+
+        # NOTE: we want to preserve the order of params as specified in the
+        # status. In case of tracking the whole file, the order is top-level
+        # keys in the file and then the keys in the `info` from `dvc.lock`
+        # (which are alphabetically sorted).
+        params = self.params or ldistinct([*actual.keys(), *info.keys()])
+        for param in params:
+            if param not in actual:
                 st = "deleted"
             elif param not in info:
                 st = "new"
@@ -130,35 +163,6 @@ def read_file(self):
                 f"Unable to read parameters from '{self}'"
             ) from exc
 
-    def _read(self):
-        try:
-            return self.read_file()
-        except MissingParamsFile:
-            return {}
-
-    def read_params_d(self, **kwargs):
-        config = self._read()
-
-        ret = {}
-        for param in self.params:
-            dpath.util.merge(
-                ret,
-                dpath.util.search(config, param, separator="."),
-                separator=".",
-            )
-        return ret
-
-    def read_params(self):
-        config = self._read()
-
-        ret = {}
-        for param in self.params:
-            try:
-                ret[param] = dpath.util.get(config, param, separator=".")
-            except KeyError:
-                pass
-        return ret
-
     def get_hash(self):
         info = self.read_params()
 
@@ -179,12 +183,5 @@ def save(self):
         if not self.isfile and not self.isdir:
             raise self.IsNotFileOrDirError(self)
 
-        if self.is_empty:
-            logger.warning(f"'{self}' is empty.")
-
         self.ignore()
-
-        if self.metric or self.plot:
-            self.verify_metric()
-
         self.hash_info = self.get_hash()
diff --git a/dvc/repo/params/show.py b/dvc/repo/params/show.py
--- a/dvc/repo/params/show.py
+++ b/dvc/repo/params/show.py
@@ -66,7 +66,9 @@ def _read_params(
 
     if deps:
         for param in params:
-            params_dict = error_handler(param.read_params_d)(onerror=onerror)
+            params_dict = error_handler(param.read_params)(
+                onerror=onerror, flatten=False
+            )
             if params_dict:
                 res[
                     repo.fs.path.relpath(param.fs_path, os.getcwd())
diff --git a/dvc/stage/serialize.py b/dvc/stage/serialize.py
--- a/dvc/stage/serialize.py
+++ b/dvc/stage/serialize.py
@@ -90,18 +90,12 @@ def _serialize_params_keys(params):
     """
     keys = []
     for param_dep in sort_by_path(params):
-        dump = param_dep.dumpd()
-        path, params = dump[PARAM_PATH], dump[PARAM_PARAMS]
-        assert isinstance(params, (dict, list))
         # when on no_exec, params are not filled and are saved as list
-        k = sorted(params.keys() if isinstance(params, dict) else params)
-        if not k:
-            continue
-
-        if path == DEFAULT_PARAMS_FILE:
+        k = sorted(param_dep.params)
+        if k and param_dep.def_path == DEFAULT_PARAMS_FILE:
             keys = k + keys
         else:
-            keys.append({path: k})
+            keys.append({param_dep.def_path: k or None})
     return keys
 
 


Test patch:
diff --git a/tests/func/params/test_diff.py b/tests/func/params/test_diff.py
--- a/tests/func/params/test_diff.py
+++ b/tests/func/params/test_diff.py
@@ -1,3 +1,5 @@
+import pytest
+
 from dvc.utils import relpath
 
 
@@ -218,3 +220,24 @@ def test_diff_targeted(tmp_dir, scm, dvc, run_copy):
     assert dvc.params.diff(a_rev="HEAD~2", targets=["other_params.yaml"]) == {
         "other_params.yaml": {"xyz": {"old": "val", "new": "val3"}}
     }
+
+
+@pytest.mark.parametrize("file", ["params.yaml", "other_params.yaml"])
+def test_diff_without_targets_specified(tmp_dir, dvc, scm, file):
+    params_file = tmp_dir / file
+    params_file.dump({"foo": {"bar": "bar"}, "x": "0"})
+    dvc.stage.add(
+        name="test",
+        cmd=f"echo {file}",
+        params=[{file: None}],
+    )
+    scm.add_commit([params_file, "dvc.yaml"], message="foo")
+
+    params_file.dump({"foo": {"bar": "baz"}, "y": "100"})
+    assert dvc.params.diff() == {
+        file: {
+            "foo.bar": {"new": "baz", "old": "bar"},
+            "x": {"new": None, "old": "0"},
+            "y": {"new": "100", "old": None},
+        }
+    }
diff --git a/tests/func/params/test_show.py b/tests/func/params/test_show.py
--- a/tests/func/params/test_show.py
+++ b/tests/func/params/test_show.py
@@ -178,3 +178,17 @@ def test_log_errors(tmp_dir, scm, dvc, capsys, file, error_path):
         "DVC failed to load some parameters for following revisions: 'v1'."
         in error
     )
+
+
+@pytest.mark.parametrize("file", ["params.yaml", "other_params.yaml"])
+def test_show_without_targets_specified(tmp_dir, dvc, scm, file):
+    params_file = tmp_dir / file
+    data = {"foo": {"bar": "bar"}, "x": "0"}
+    params_file.dump(data)
+    dvc.stage.add(
+        name="test",
+        cmd=f"echo {file}",
+        params=[{file: None}],
+    )
+
+    assert dvc.params.show() == {"": {"data": {file: {"data": data}}}}
diff --git a/tests/func/test_status.py b/tests/func/test_status.py
--- a/tests/func/test_status.py
+++ b/tests/func/test_status.py
@@ -137,3 +137,49 @@ def test_status_outputs(tmp_dir, dvc):
     assert dvc.status(targets=["alice"]) == {
         "alice_bob": [{"changed outs": {"alice": "modified"}}]
     }
+
+
+def test_params_without_targets(tmp_dir, dvc):
+    dvc.stage.add(
+        name="test", cmd="echo params.yaml", params=[{"params.yaml": None}]
+    )
+    assert dvc.status() == {
+        "test": [{"changed deps": {"params.yaml": "deleted"}}]
+    }
+
+    (tmp_dir / "params.yaml").touch()
+    assert dvc.status() == {"test": [{"changed deps": {"params.yaml": "new"}}]}
+
+    dvc.commit("test", force=True)
+    # make sure that we are able to keep track of "empty" contents
+    # and be able to distinguish between no-lock-entry and empty-lock-entry.
+    assert (tmp_dir / "dvc.lock").parse() == {
+        "schema": "2.0",
+        "stages": {
+            "test": {"cmd": "echo params.yaml", "params": {"params.yaml": {}}}
+        },
+    }
+    assert dvc.status() == {}
+
+    (tmp_dir / "params.yaml").dump({"foo": "foo", "bar": "bar"})
+    assert dvc.status() == {
+        "test": [
+            {"changed deps": {"params.yaml": {"bar": "new", "foo": "new"}}}
+        ]
+    }
+    dvc.commit("test", force=True)
+
+    (tmp_dir / "params.yaml").dump({"foo": "foobar", "lorem": "ipsum"})
+    assert dvc.status() == {
+        "test": [
+            {
+                "changed deps": {
+                    "params.yaml": {
+                        "bar": "deleted",
+                        "foo": "modified",
+                        "lorem": "new",
+                    }
+                }
+            }
+        ]
+    }
diff --git a/tests/unit/dependency/test_params.py b/tests/unit/dependency/test_params.py
--- a/tests/unit/dependency/test_params.py
+++ b/tests/unit/dependency/test_params.py
@@ -40,6 +40,37 @@ def test_loads_params(dvc):
     assert not deps[2].hash_info
 
 
+def test_loads_params_without_any_specific_targets(dvc):
+    stage = Stage(dvc)
+    deps = loads_params(
+        stage,
+        [
+            "foo",
+            {"params.yaml": None},
+            {"a_file": []},
+            {"b_file": ["baz"]},
+            {"b_file": ["bat"]},
+            {"a_file": ["foobar"]},
+        ],
+    )
+    assert len(deps) == 3
+
+    assert isinstance(deps[0], ParamsDependency)
+    assert deps[0].def_path == ParamsDependency.DEFAULT_PARAMS_FILE
+    assert deps[0].params == []
+    assert not deps[0].hash_info
+
+    assert isinstance(deps[1], ParamsDependency)
+    assert deps[1].def_path == "a_file"
+    assert deps[1].params == []
+    assert not deps[1].hash_info
+
+    assert isinstance(deps[2], ParamsDependency)
+    assert deps[2].def_path == "b_file"
+    assert deps[2].params == ["baz", "bat"]
+    assert not deps[2].hash_info
+
+
 @pytest.mark.parametrize("params", [[3], [{"b_file": "cat"}]])
 def test_params_error(dvc, params):
     with pytest.raises(ValueError):
@@ -199,3 +230,25 @@ def test_params_with_false_values(tmp_dir, dvc, param_value):
     dep.fill_values(load_yaml(DEFAULT_PARAMS_FILE))
 
     assert dep.status() == {}
+
+
+def test_params_status_without_targets(tmp_dir, dvc):
+    params_file = tmp_dir / "params.yaml"
+    dep = ParamsDependency(Stage(dvc), str(params_file), [])
+
+    assert dep.hash_info.value is None
+    assert dep.status() == {"params.yaml": "deleted"}
+
+    params_file.dump({"foo": "foo", "bar": "bar"})
+
+    assert dep.status() == {"params.yaml": "new"}
+
+    dep.fill_values({})
+    assert dep.hash_info.value == {}
+    assert dep.status() == {"params.yaml": {"bar": "new", "foo": "new"}}
+
+    dep.fill_values({"foo": "foobar", "lorem": "ipsum"})
+    assert dep.hash_info.value == {"foo": "foobar", "lorem": "ipsum"}
+    assert dep.status() == {
+        "params.yaml": {"bar": "new", "foo": "modified", "lorem": "deleted"}
+    }
diff --git a/tests/unit/stage/test_serialize_pipeline_file.py b/tests/unit/stage/test_serialize_pipeline_file.py
--- a/tests/unit/stage/test_serialize_pipeline_file.py
+++ b/tests/unit/stage/test_serialize_pipeline_file.py
@@ -100,6 +100,25 @@ def test_params_file_sorted(dvc):
     ]
 
 
+def test_params_file_without_targets(dvc):
+    params = [
+        "foo",
+        "bar",
+        {"params.yaml": None},
+        {"custom.yaml": ["wxyz", "pqrs", "baz"]},
+        {"a-file-of-params.yaml": None},
+        {"a-file-of-params.yaml": ["barr"]},
+    ]
+    stage = create_stage(
+        PipelineStage, dvc, outs=["bar"], deps=["foo"], params=params, **kwargs
+    )
+    assert to_pipeline_file(stage)["something"][stage.PARAM_PARAMS] == [
+        {"a-file-of-params.yaml": None},
+        {"custom.yaml": ["baz", "pqrs", "wxyz"]},
+        {"params.yaml": None},
+    ]
+
+
 @pytest.mark.parametrize(
     "typ, extra",
     [("plots", {"plot": True}), ("metrics", {"metric": True}), ("outs", {})],
diff --git a/tests/unit/stage/test_serialize_pipeline_lock.py b/tests/unit/stage/test_serialize_pipeline_lock.py
--- a/tests/unit/stage/test_serialize_pipeline_lock.py
+++ b/tests/unit/stage/test_serialize_pipeline_lock.py
@@ -120,6 +120,25 @@ def test_lock_params_no_values_filled(dvc):
     assert to_single_stage_lockfile(stage) == {"cmd": "command"}
 
 
+@pytest.mark.parametrize(
+    "info, expected",
+    [
+        (None, {}),
+        ({}, {}),
+        ({"foo": "foo", "bar": "bar"}, {"bar": "bar", "foo": "foo"}),
+    ],
+)
+def test_lock_params_without_targets(dvc, info, expected):
+    stage = create_stage(
+        PipelineStage, dvc, params=[{"params.yaml": None}], **kwargs
+    )
+    stage.deps[0].fill_values(info)
+    assert to_single_stage_lockfile(stage) == {
+        "cmd": "command",
+        "params": {"params.yaml": OrderedDict(expected)},
+    }
+
+
 @pytest.mark.parametrize("typ", ["plots", "metrics", "outs"])
 def test_lock_outs(dvc, typ):
     stage = create_stage(PipelineStage, dvc, **{typ: ["input"]}, **kwargs)