Repository: dbt-labs/dbt-core

Issue:
[CT-2570] Instantiate `Flags` from dict
## Description
As part of the work for `dbt retry` #7299, we need to be able to instantiate a `Flags` class from a dict (with unserialized values) and name of a command alone. This will support the creation of `Flags` and `RuntimeConfig` for a command being retried.

## Acceptance Criteria
- `Flags` can be instantiated provided only the following
  - the string name of a command
  - an unserialized dict representing the args used in a command


Discussion:

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
@@ -4,14 +4,16 @@
 from importlib import import_module
 from multiprocessing import get_context
 from pprint import pformat as pf
-from typing import Callable, Dict, List, Set, Union
+from typing import Any, Callable, Dict, List, Optional, Set, Union
 
-from click import Context, get_current_context
-from click.core import Command, Group, ParameterSource
+from click import Context, get_current_context, Parameter
+from click.core import Command as ClickCommand, Group, ParameterSource
 from dbt.cli.exceptions import DbtUsageException
 from dbt.cli.resolvers import default_log_path, default_project_dir
+from dbt.cli.types import Command as CliCommand
 from dbt.config.profile import read_user_config
 from dbt.contracts.project import UserConfig
+from dbt.exceptions import DbtInternalError
 from dbt.deprecations import renamed_env_var
 from dbt.helper_types import WarnErrorOptions
 
@@ -37,6 +39,9 @@
 }
 
 
+WHICH_KEY = "which"
+
+
 def convert_config(config_name, config_value):
     """Convert the values from config and original set_from_args to the correct type."""
     ret = config_value
@@ -58,10 +63,10 @@ def args_to_context(args: List[str]) -> Context:
     sub_command_name, sub_command, args = cli.resolve_command(cli_ctx, args)
 
     # Handle source and docs group.
-    if type(sub_command) == Group:
+    if isinstance(sub_command, Group):
         sub_command_name, sub_command, args = sub_command.resolve_command(cli_ctx, args)
 
-    assert type(sub_command) == Command
+    assert isinstance(sub_command, ClickCommand)
     sub_command_ctx = sub_command.make_context(sub_command_name, args)
     sub_command_ctx.parent = cli_ctx
     return sub_command_ctx
@@ -277,3 +282,114 @@ def fire_deprecations(self):
         # It is necessary to remove this attr from the class so it does
         # not get pickled when written to disk as json.
         object.__delattr__(self, "deprecated_env_var_warnings")
+
+    @classmethod
+    def from_dict(cls, command: CliCommand, args_dict: Dict[str, Any]) -> "Flags":
+        command_arg_list = command_params(command, args_dict)
+        ctx = args_to_context(command_arg_list)
+        flags = cls(ctx=ctx)
+        flags.fire_deprecations()
+        return flags
+
+
+CommandParams = List[str]
+
+
+def command_params(command: CliCommand, args_dict: Dict[str, Any]) -> CommandParams:
+    """Given a command and a dict, returns a list of strings representing
+    the CLI params for that command. The order of this list is consistent with
+    which flags are expected at the parent level vs the command level.
+
+    e.g. fn("run", {"defer": True, "print": False}) -> ["--no-print", "run", "--defer"]
+
+    The result of this function can be passed in to the args_to_context function
+    to produce a click context to instantiate Flags with.
+    """
+
+    cmd_args = set(command_args(command))
+    prnt_args = set(parent_args())
+    default_args = set([x.lower() for x in FLAGS_DEFAULTS.keys()])
+
+    res = command.to_list()
+
+    for k, v in args_dict.items():
+        k = k.lower()
+
+        # if a "which" value exists in the args dict, it should match the command provided
+        if k == WHICH_KEY:
+            if v != command.value:
+                raise DbtInternalError(
+                    f"Command '{command.value}' does not match value of which: '{v}'"
+                )
+            continue
+
+        # param was assigned from defaults and should not be included
+        if k not in (cmd_args | prnt_args) - default_args:
+            continue
+
+        # if the param is in parent args, it should come before the arg name
+        # e.g. ["--print", "run"] vs ["run", "--print"]
+        add_fn = res.append
+        if k in prnt_args:
+
+            def add_fn(x):
+                res.insert(0, x)
+
+        spinal_cased = k.replace("_", "-")
+
+        if v in (None, False):
+            add_fn(f"--no-{spinal_cased}")
+        elif v is True:
+            add_fn(f"--{spinal_cased}")
+        else:
+            add_fn(f"--{spinal_cased}={v}")
+
+    return res
+
+
+ArgsList = List[str]
+
+
+def parent_args() -> ArgsList:
+    """Return a list representing the params the base click command takes."""
+    from dbt.cli.main import cli
+
+    return format_params(cli.params)
+
+
+def command_args(command: CliCommand) -> ArgsList:
+    """Given a command, return a list of strings representing the params
+    that command takes. This function only returns params assigned to a
+    specific command, not those of its parent command.
+
+    e.g. fn("run") -> ["defer", "favor_state", "exclude", ...]
+    """
+    import dbt.cli.main as cli
+
+    CMD_DICT: Dict[CliCommand, ClickCommand] = {
+        CliCommand.BUILD: cli.build,
+        CliCommand.CLEAN: cli.clean,
+        CliCommand.COMPILE: cli.compile,
+        CliCommand.DOCS_GENERATE: cli.docs_generate,
+        CliCommand.DOCS_SERVE: cli.docs_serve,
+        CliCommand.DEBUG: cli.debug,
+        CliCommand.DEPS: cli.deps,
+        CliCommand.INIT: cli.init,
+        CliCommand.LIST: cli.list,
+        CliCommand.PARSE: cli.parse,
+        CliCommand.RUN: cli.run,
+        CliCommand.RUN_OPERATION: cli.run_operation,
+        CliCommand.SEED: cli.seed,
+        CliCommand.SHOW: cli.show,
+        CliCommand.SNAPSHOT: cli.snapshot,
+        CliCommand.SOURCE_FRESHNESS: cli.freshness,
+        CliCommand.TEST: cli.test,
+    }
+    click_cmd: Optional[ClickCommand] = CMD_DICT.get(command, None)
+    if click_cmd is None:
+        raise DbtInternalError(f"No command found for name '{command.name}'")
+    return format_params(click_cmd.params)
+
+
+def format_params(params: List[Parameter]) -> ArgsList:
+    return [str(x.name) for x in params if not str(x.name).lower().startswith("deprecated_")]
diff --git a/core/dbt/cli/types.py b/core/dbt/cli/types.py
new file mode 100644
--- /dev/null
+++ b/core/dbt/cli/types.py
@@ -0,0 +1,38 @@
+from enum import Enum
+from typing import List
+
+from dbt.exceptions import DbtInternalError
+
+
+class Command(Enum):
+    BUILD = "build"
+    CLEAN = "clean"
+    COMPILE = "compile"
+    DOCS_GENERATE = "generate"
+    DOCS_SERVE = "serve"
+    DEBUG = "debug"
+    DEPS = "deps"
+    INIT = "init"
+    LIST = "list"
+    PARSE = "parse"
+    RUN = "run"
+    RUN_OPERATION = "run-operation"
+    SEED = "seed"
+    SHOW = "show"
+    SNAPSHOT = "snapshot"
+    SOURCE_FRESHNESS = "freshness"
+    TEST = "test"
+
+    @classmethod
+    def from_str(cls, s: str) -> "Command":
+        try:
+            return cls(s)
+        except ValueError:
+            raise DbtInternalError(f"No value '{s}' exists in Command enum")
+
+    def to_list(self) -> List[str]:
+        return {
+            Command.DOCS_GENERATE: ["docs", "generate"],
+            Command.DOCS_SERVE: ["docs", "serve"],
+            Command.SOURCE_FRESHNESS: ["source", "freshness"],
+        }.get(self, [self.value])


Test patch:
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -1,18 +1,22 @@
 import click
 
+from dbt.cli.flags import command_args
 from dbt.cli.main import cli
+from dbt.cli.types import Command
 
 
 class TestCLI:
-    def test_commands_have_docstrings(self):
-        def run_test(commands):
-            for command in commands.values():
-                if type(command) is click.Command:
-                    assert command.__doc__ is not None
-                if type(command) is click.Group:
-                    run_test(command.commands)
+    def _all_commands(self, group=cli, result=set()):
+        for command in group.commands.values():
+            result.add(command)
+            if isinstance(command, click.Group):
+                self._all_commands(command, result)
+                continue
+        return result
 
-        run_test(cli.commands)
+    def test_commands_have_docstrings(self):
+        for command in self._all_commands():
+            assert command.__doc__ is not None
 
     # TODO:  This isn't the ideal way to test params as
     # they will be tested as many times as they are used as decorators.
@@ -41,3 +45,10 @@ def run_test(command):
                     run_test(command)
 
         run_test(cli)
+
+    def test_commands_in_enum_and_dict(self):
+        for command in self._all_commands(cli):
+            if isinstance(command, click.Group):
+                continue
+            cmd = Command.from_str(command.name)
+            command_args(cmd)
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
@@ -6,10 +6,13 @@
 from typing import List, Optional
 
 from dbt.cli.exceptions import DbtUsageException
+from dbt.cli.flags import Flags
 from dbt.cli.main import cli
+from dbt.cli.types import Command
 from dbt.contracts.project import UserConfig
-from dbt.cli.flags import Flags
+from dbt.exceptions import DbtInternalError
 from dbt.helper_types import WarnErrorOptions
+from dbt.tests.util import rm_file, write_file
 
 
 class TestFlags:
@@ -347,3 +350,39 @@ def test_duplicate_flags_raises_error(self):
 
         with pytest.raises(DbtUsageException):
             Flags(context)
+
+    def _create_flags_from_dict(self, cmd, d):
+        write_file("", "profiles.yml")
+        result = Flags.from_dict(cmd, d)
+        assert result.which is cmd.value
+        rm_file("profiles.yml")
+        return result
+
+    def test_from_dict__run(self):
+        args_dict = {
+            "print": False,
+            "select": ["model_one", "model_two"],
+        }
+        result = self._create_flags_from_dict(Command.RUN, args_dict)
+        assert "model_one" in result.select[0]
+        assert "model_two" in result.select[0]
+
+    def test_from_dict__build(self):
+        args_dict = {
+            "print": True,
+            "state": "some/path",
+        }
+        result = self._create_flags_from_dict(Command.BUILD, args_dict)
+        assert result.print is True
+        assert "some/path" in str(result.state)
+
+    def test_from_dict__seed(self):
+        args_dict = {"use_colors": False, "exclude": ["model_three"]}
+        result = self._create_flags_from_dict(Command.SEED, args_dict)
+        assert result.use_colors is False
+        assert "model_three" in result.exclude[0]
+
+    def test_from_dict__which_fails(self):
+        args_dict = {"which": "some bad command"}
+        with pytest.raises(DbtInternalError, match=r"does not match value of which"):
+            self._create_flags_from_dict(Command.RUN, args_dict)