Repository: dbt-labs/dbt-core

Issue:
[CT-3085] [Bug] `dbt retry` doesn't unpack resource-type
### Is this a new bug in dbt-core?

- [X] I believe this is a new bug in dbt-core
- [X] I have searched the existing issues, and I could not find an existing issue for this bug

### Current Behavior

When using `dbt retry` after using `dbt build` with a resource-type specified, dbt will warn of a `BadParameter` since the resource-type from the previous run is returned as a string of a list.

### Expected Behavior

When using `dbt retry` in conjunction with a `dbt build` with a resource-type specified, it would run the appropriate nodes without a parameter issue. It would run whether I had a single resource-type specified or multiple resource-types specified.

### Steps To Reproduce

1. Create a model / tests that will error out

some_model.sql
```
select null as id
```
some_model.yml
```
version: 2

models:
  - name: some_model
    columns:
      - name: id
        tests:
          - not_null
```
2. Invoke `dbt build -s some_model --resource-type all` (optionally, `--resource-type model test`)
3. Invoke `dbt retry` to see failure
-- On the happy path
4. Invoke `dbt build -s some_model`
5. Invoke `dbt retry` to see proper flow

### Relevant log output

```shell
A stripped down log with multiple resource types

➜ dbt build -s stg_google_ads__account_history --resource-type seed model test                        
18:23:30  Running with dbt=1.6.1
18:23:30  Registered adapter: snowflake=1.6.2
18:23:31  Unable to do partial parsing because config vars, config profile, or config target have changed
18:24:00  Found 2356 models, 59 snapshots, 2095 tests, 87 seeds, 4 operations, 1744 sources, 0 exposures, 0 metrics, 961 macros, 0 groups, 0 semantic models
18:24:00  
18:24:04  
18:24:07  
18:24:07  Concurrency: 4 threads (target='snowflake_dev')
18:24:07  
18:24:07  1 of 2 START sql view model staging.stg_google_ads__account_history ............ [RUN]
18:24:08  1 of 2 OK created sql view model staging.stg_google_ads__account_history ....... [SUCCESS 1 in 0.86s]
18:24:08  2 of 2 START test not_null_stg_google_ads__account_history_account_id .......... [RUN]
18:24:09  2 of 2 FAIL 1 not_null_stg_google_ads__account_history_account_id .............. [FAIL 1 in 0.94s]
18:24:09  
18:24:09  Finished running 1 view model, 1 test, 4 hooks in 0 hours 0 minutes and 8.38 seconds (8.38s).
18:24:09  
18:24:09  Completed with 1 error and 0 warnings:
18:24:09  
18:24:09  Failure in test not_null_stg_google_ads__account_history_account_id (models/staging/adwords/src_adwords.yml)
18:24:09    Got 1 result, configured to fail if != 0
18:24:09  
18:24:09    compiled Code at target/compiled/ramp_dbt/models/staging/adwords/src_adwords.yml/not_null_stg_google_ads__account_history_account_id.sql
18:24:09  
18:24:09  Done. PASS=1 WARN=0 ERROR=1 SKIP=0 TOTAL=2


➜ dbt retry                                                                   
18:25:04  Running with dbt=1.6.1
18:25:04  Registered adapter: snowflake=1.6.2
18:25:07  Encountered an error:
"['seed', 'model', 'test']" is not one of 'metric', 'source', 'analysis', 'model', 'test', 'exposure', 'snapshot', 'seed', 'default', 'all'.
18:25:07  Traceback (most recent call last):
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/requires.py", line 87, in wrapper
    result, success = func(*args, **kwargs)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/requires.py", line 72, in wrapper
    return func(*args, **kwargs)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/requires.py", line 143, in wrapper
    return func(*args, **kwargs)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/requires.py", line 172, in wrapper
    return func(*args, **kwargs)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/requires.py", line 219, in wrapper
    return func(*args, **kwargs)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/requires.py", line 259, in wrapper
    return func(*args, **kwargs)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/main.py", line 609, in retry
    results = task.run()
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/task/retry.py", line 94, in run
    retry_flags = Flags.from_dict(cli_command, self.previous_args)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/flags.py", line 293, in from_dict
    ctx = args_to_context(command_arg_list)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/flags.py", line 69, in args_to_context
    sub_command_ctx = sub_command.make_context(sub_command_name, args)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 943, in make_context
    self.parse_args(ctx, args)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 1408, in parse_args
    value, args = param.handle_parse_result(ctx, opts, args)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 2400, in handle_parse_result
    value = self.process_value(ctx, value)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 2356, in process_value
    value = self.type_cast_value(ctx, value)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/options.py", line 72, in type_cast_value
    value = super(MultiOption, self).type_cast_value(ctx, value)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 2342, in type_cast_value
    return tuple(convert(x) for x in check_iter(value))
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 2342, in <genexpr>
    return tuple(convert(x) for x in check_iter(value))
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/core.py", line 2316, in convert
    return self.type(value, param=self, ctx=ctx)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/types.py", line 83, in __call__
    return self.convert(value, param, ctx)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/dbt/cli/option_types.py", line 60, in convert
    super().convert(value_item, param, ctx)
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/types.py", line 296, in convert
    self.fail(
  File "/Users/kevin/Library/Caches/pypoetry/virtualenvs/dbt-YSAPFX2U-py3.9/lib/python3.9/site-packages/click/types.py", line 136, in fail
    raise BadParameter(message, ctx=ctx, param=param)
click.exceptions.BadParameter: "['seed', 'model', 'test']" is not one of 'metric', 'source', 'analysis', 'model', 'test', 'exposure', 'snapshot', 'seed', 'default', 'all'.
```


### Environment

```markdown
- OS: MacOS 13.5.1
- Python: 3.9.16
- dbt: 1.6.1
```


### Which database adapter are you using with dbt?

snowflake

### Additional Context

Can confirm that without the resource-type specified in `dbt build`, `dbt retry` works as intended.


Discussion:
Thanks for reporting this @kevin-chao and providing such a nice example of how to reproduce it 🤩

Following your example, I got the same results as you, namely that this doesn't work correctly:

```shell
dbt build -s some_model --resource-type all
dbt retry
```

And neither does this:
```shell
dbt build -s some_model --resource-type model test
dbt retry
```

But this works fine:
```shell
dbt build -s some_model
dbt retry
```

Resolving patch:
diff --git a/core/dbt/cli/flags.py b/core/dbt/cli/flags.py
--- a/core/dbt/cli/flags.py
+++ b/core/dbt/cli/flags.py
@@ -57,7 +57,7 @@ def args_to_context(args: List[str]) -> Context:
     from dbt.cli.main import cli
 
     cli_ctx = cli.make_context(cli.name, args)
-    # Split args if they're a comma seperated string.
+    # Split args if they're a comma separated string.
     if len(args) == 1 and "," in args[0]:
         args = args[0].split(",")
     sub_command_name, sub_command, args = cli.resolve_command(cli_ctx, args)
@@ -340,6 +340,10 @@ def add_fn(x):
 
         spinal_cased = k.replace("_", "-")
 
+        # MultiOption flags come back as lists, but we want to pass them as space separated strings
+        if isinstance(v, list):
+            v = " ".join(v)
+
         if k == "macro" and command == CliCommand.RUN_OPERATION:
             add_fn(v)
         # None is a Singleton, False is a Flyweight, only one instance of each.
diff --git a/core/dbt/cli/options.py b/core/dbt/cli/options.py
--- a/core/dbt/cli/options.py
+++ b/core/dbt/cli/options.py
@@ -2,6 +2,7 @@
 import inspect
 import typing as t
 from click import Context
+from click.parser import OptionParser, ParsingState
 from dbt.cli.option_types import ChoiceTuple
 
 
@@ -13,8 +14,9 @@ def __init__(self, *args, **kwargs):
         nargs = kwargs.pop("nargs", -1)
         assert nargs == -1, "nargs, if set, must be -1 not {}".format(nargs)
         super(MultiOption, self).__init__(*args, **kwargs)
-        self._previous_parser_process = None
-        self._eat_all_parser = None
+        # this makes mypy happy, setting these to None causes mypy failures
+        self._previous_parser_process = lambda *args, **kwargs: None
+        self._eat_all_parser = lambda *args, **kwargs: None
 
         # validate that multiple=True
         multiple = kwargs.pop("multiple", None)
@@ -29,34 +31,35 @@ def __init__(self, *args, **kwargs):
         else:
             assert isinstance(option_type, ChoiceTuple), msg
 
-    def add_to_parser(self, parser, ctx):
-        def parser_process(value, state):
+    def add_to_parser(self, parser: OptionParser, ctx: Context):
+        def parser_process(value: str, state: ParsingState):
             # method to hook to the parser.process
             done = False
-            value = [value]
+            value_list = str.split(value, " ")
             if self.save_other_options:
                 # grab everything up to the next option
                 while state.rargs and not done:
-                    for prefix in self._eat_all_parser.prefixes:
+                    for prefix in self._eat_all_parser.prefixes:  # type: ignore[attr-defined]
                         if state.rargs[0].startswith(prefix):
                             done = True
                     if not done:
-                        value.append(state.rargs.pop(0))
+                        value_list.append(state.rargs.pop(0))
             else:
                 # grab everything remaining
-                value += state.rargs
+                value_list += state.rargs
                 state.rargs[:] = []
-            value = tuple(value)
+            value_tuple = tuple(value_list)
             # call the actual process
-            self._previous_parser_process(value, state)
+            self._previous_parser_process(value_tuple, state)
 
         retval = super(MultiOption, self).add_to_parser(parser, ctx)
         for name in self.opts:
             our_parser = parser._long_opt.get(name) or parser._short_opt.get(name)
             if our_parser:
-                self._eat_all_parser = our_parser
+                self._eat_all_parser = our_parser  # type: ignore[assignment]
                 self._previous_parser_process = our_parser.process
-                our_parser.process = parser_process
+                # mypy doesnt like assingment to a method see https://github.com/python/mypy/issues/708
+                our_parser.process = parser_process  # type: ignore[method-assign]
                 break
         return retval
 


Test patch:
diff --git a/tests/functional/cli/test_multioption.py b/tests/functional/cli/test_multioption.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/cli/test_multioption.py
@@ -0,0 +1,142 @@
+import pytest
+from dbt.tests.util import run_dbt
+
+
+model_one_sql = """
+select 1 as fun
+"""
+
+schema_sql = """
+sources:
+  - name: my_source
+    description: "My source"
+    schema: test_schema
+    tables:
+      - name: my_table
+      - name: my_other_table
+
+exposures:
+  - name: weekly_jaffle_metrics
+    label: By the Week
+    type: dashboard
+    maturity: high
+    url: https://bi.tool/dashboards/1
+    description: >
+      Did someone say "exponential growth"?
+    depends_on:
+      - ref('model_one')
+    owner:
+      name: dbt Labs
+      email: data@jaffleshop.com
+"""
+
+
+class TestResourceType:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {"schema.yml": schema_sql, "model_one.sql": model_one_sql}
+
+    def test_resource_type_single(self, project):
+        result = run_dbt(["-q", "ls", "--resource-types", "model"])
+        assert len(result) == 1
+        assert result == ["test.model_one"]
+
+    def test_resource_type_quoted(self, project):
+        result = run_dbt(["-q", "ls", "--resource-types", "model source"])
+        assert len(result) == 3
+        expected_result = {
+            "test.model_one",
+            "source:test.my_source.my_table",
+            "source:test.my_source.my_other_table",
+        }
+        assert set(result) == expected_result
+
+    def test_resource_type_args(self, project):
+        result = run_dbt(
+            [
+                "-q",
+                "ls",
+                "--resource-type",
+                "model",
+                "--resource-type",
+                "source",
+                "--resource-type",
+                "exposure",
+            ]
+        )
+        assert len(result) == 4
+        expected_result = {
+            "test.model_one",
+            "source:test.my_source.my_table",
+            "source:test.my_source.my_other_table",
+            "exposure:test.weekly_jaffle_metrics",
+        }
+        assert set(result) == expected_result
+
+
+class TestOutputKeys:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {"model_one.sql": model_one_sql}
+
+    def test_output_key_single(self, project):
+        result = run_dbt(["-q", "ls", "--output", "json", "--output-keys", "name"])
+        assert len(result) == 1
+        assert result == ['{"name": "model_one"}']
+
+    def test_output_key_quoted(self, project):
+        result = run_dbt(["-q", "ls", "--output", "json", "--output-keys", "name resource_type"])
+
+        assert len(result) == 1
+        assert result == ['{"name": "model_one", "resource_type": "model"}']
+
+    def test_output_key_args(self, project):
+        result = run_dbt(
+            [
+                "-q",
+                "ls",
+                "--output",
+                "json",
+                "--output-keys",
+                "name",
+                "--output-keys",
+                "resource_type",
+            ]
+        )
+
+        assert len(result) == 1
+        assert result == ['{"name": "model_one", "resource_type": "model"}']
+
+
+class TestSelectExclude:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "model_one.sql": model_one_sql,
+            "model_two.sql": model_one_sql,
+            "model_three.sql": model_one_sql,
+        }
+
+    def test_select_exclude_single(self, project):
+        result = run_dbt(["-q", "ls", "--select", "model_one"])
+        assert len(result) == 1
+        assert result == ["test.model_one"]
+        result = run_dbt(["-q", "ls", "--exclude", "model_one"])
+        assert len(result) == 2
+        assert "test.model_one" not in result
+
+    def test_select_exclude_quoted(self, project):
+        result = run_dbt(["-q", "ls", "--select", "model_one model_two"])
+        assert len(result) == 2
+        assert "test.model_three" not in result
+        result = run_dbt(["-q", "ls", "--exclude", "model_one model_two"])
+        assert len(result) == 1
+        assert result == ["test.model_three"]
+
+    def test_select_exclude_args(self, project):
+        result = run_dbt(["-q", "ls", "--select", "model_one", "--select", "model_two"])
+        assert len(result) == 2
+        assert "test.model_three" not in result
+        result = run_dbt(["-q", "ls", "--exclude", "model_one", "--exclude", "model_two"])
+        assert len(result) == 1
+        assert result == ["test.model_three"]
diff --git a/tests/functional/retry/fixtures.py b/tests/functional/retry/fixtures.py
--- a/tests/functional/retry/fixtures.py
+++ b/tests/functional/retry/fixtures.py
@@ -45,3 +45,16 @@
 {% do log("Timezone set to: " + timezone, info=True) %}
 {% endmacro %}
 """
+
+simple_model = """
+select null as id
+"""
+
+simple_schema = """
+models:
+  - name: some_model
+    columns:
+      - name: id
+        tests:
+          - not_null
+"""
diff --git a/tests/functional/retry/test_retry.py b/tests/functional/retry/test_retry.py
--- a/tests/functional/retry/test_retry.py
+++ b/tests/functional/retry/test_retry.py
@@ -9,6 +9,8 @@
     schema_yml,
     models__second_model,
     macros__alter_timezone_sql,
+    simple_model,
+    simple_schema,
 )
 
 
@@ -225,3 +227,47 @@ def test_fail_fast(self, project):
 
         results = run_dbt(["retry"])
         assert {r.node.unique_id: r.status for r in results.results} == {}
+
+
+class TestRetryResourceType:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {
+            "null_model.sql": simple_model,
+            "schema.yml": simple_schema,
+        }
+
+    def test_resource_type(self, project):
+        # test multiple options in single string
+        results = run_dbt(["build", "--select", "null_model", "--resource-type", "test model"])
+        assert len(results) == 1
+
+        # nothing to do
+        results = run_dbt(["retry"])
+        assert len(results) == 0
+
+        # test multiple options in multiple args
+        results = run_dbt(
+            [
+                "build",
+                "--select",
+                "null_model",
+                "--resource-type",
+                "test",
+                "--resource-type",
+                "model",
+            ]
+        )
+        assert len(results) == 1
+
+        # nothing to do
+        results = run_dbt(["retry"])
+        assert len(results) == 0
+
+        # test single all option
+        results = run_dbt(["build", "--select", "null_model", "--resource-type", "all"])
+        assert len(results) == 1
+
+        # nothing to do
+        results = run_dbt(["retry"])
+        assert len(results) == 0
diff --git a/tests/unit/test_cli_flags.py b/tests/unit/test_cli_flags.py
--- a/tests/unit/test_cli_flags.py
+++ b/tests/unit/test_cli_flags.py
@@ -370,7 +370,7 @@ def test_from_dict__run(self):
         }
         result = self._create_flags_from_dict(Command.RUN, args_dict)
         assert "model_one" in result.select[0]
-        assert "model_two" in result.select[0]
+        assert "model_two" in result.select[1]
 
     def test_from_dict__build(self):
         args_dict = {