Repository: dbt-labs/dbt-core

Issue:
[CT-2069] Unexpected behavior with global/subcommand flags
For backwards compatibility with older behavior, some flags are implemented at both the "global" ("parent") level, and also at the "subcommand" ("child") level. We have some tricky logic to handle this potential duplication:
https://github.com/dbt-labs/dbt-core/blob/77748571b46593999dfc45b717a1bc1189ec2533/core/dbt/cli/flags.py#L70-L95

On `feature/click-cli` branch, I'm observing behavior where:
- parent flag alone isn't respected
- child flag alone is respected
- flag provided to both parent + child, parent flag takes precedence and is respected

**Expected behavior:**
- parent flag alone **is** (!) respected
- child flag alone is respected
- if both parent + child flags are passed, raise an error (ideal) — but if needed, we can document that the parent flag always wins

### Example

Create a new dbt project, and add `require-dbt-version: "1.4.1"` to `dbt_project.yml`.

```bash
$ python3 -m dbt.cli.main --no-version-check parse
...
dbt.exceptions.DbtProjectError: Runtime Error
  This version of dbt is not supported with the 'test' package.
    Installed version of dbt: =1.5.0-a1
    Required version of dbt for 'test': ['=1.4.1']
  Check for a different version of the 'test' package, or run dbt again with --no-version-check
```
```
$ python3 -m dbt.cli.main parse --no-version-check
23:52:24  Performance info: target/perf_info.json
```
```
$ python3 -m dbt.cli.main --no-version-check parse --version-check
23:52:27  Performance info: target/perf_info.json
```



Discussion:
@jtcohen6 Can we choose only the option where there are not parent flags? LIke `dbt run --flag` and never `dbt --flag run`?
@stu-k That would be a significant breaking change for a large number of existing CLI users, so I don't see it as a viable option.
@jtcohen6 Would it be fair to ask that a flag be defined in either the parent or child level, not both?

✅ `dbt --full-refresh run` OR (not both) `dbt run --full-refresh`
🚫 `dbt --full-refresh run --full-refresh`

It's clear that this is the intent of the [`assign_params`](https://github.com/dbt-labs/dbt-core/blob/77748571b46593999dfc45b717a1bc1189ec2533/core/dbt/cli/flags.py#L70) method, but it's not currently working.

Acceptance criteria for this ticket:
- P0: Respect "parent"-level flag when passed alone
- P1: Raise an exception if the same flag is passed in both places. Only do this if it's a minor lift. Otherwise we document the current behavior.
@ChenyuLInx says:

https://github.com/dbt-labs/dbt-core/blob/77748571b46593999dfc45b717a1bc1189ec2533/core/dbt/cli/flags.py#L87

This is the wrong `ctx`. It is currently the context of the child, and it should be the context of the parent.
> @jtcohen6 Would it be fair to ask that a flag be defined in either the parent or child level, not both?
> 
> ✅ `dbt --full-refresh run` OR (not both) `dbt run --full-refresh` 🚫 `dbt --full-refresh run --full-refresh`

@jtcohen6 Just to be clear-- your 👍 on this comment means there is no legitimate use case where the same flag will be populated on multiple levels?  I ask because right now we have logic [here](https://github.com/dbt-labs/dbt-core/blob/567e2ca2be0ecb6272b4c873c8a3489a3696b7f6/core/dbt/cli/flags.py#L119) specifically to support that happening.
> @jtcohen6 Just to be clear-- your 👍 on this comment means t[here](https://github.com/dbt-labs/dbt-core/blob/567e2ca2be0ecb6272b4c873c8a3489a3696b7f6/core/dbt/cli/flags.py#L119) is no legitimate use case where the same flag will be populated on multiple levels? I ask because right now we have logic here specifically to support that happening.

IMO, if the same flag is passed at both the "parent" and the "child" levels:
- Ideal: Raise an error. Message something like: `--version-check was provided both before and after the subcommand, it can only be set either before or after`.
- Acceptable: We have a consistent behavior whereby either the "parent" or the "child" always wins, and we document that behavior. I have a slight preference for the "parent" winning (status quo), since that's where we document/suggest putting these 
global" flags currently. I could also see the argument for the "child" flag winning since it's passed later (see rationale below).

I understand that standard CLI behavior allows you to pass the same flag multiple times, and the last one wins, e.g. `dbt --debug --no-debug test`. In the fullness of time, if our CLI could remove the distinction between parent/child flags (#6497), then picking the last instance of the flag to win seems reasonable. For now, I'd rather opt for behavior that's transparent & explicit for the end user.

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
@@ -1,57 +1,44 @@
-# TODO  Move this to /core/dbt/flags.py when we're ready to break things
 import os
 import sys
 from dataclasses import dataclass
 from importlib import import_module
 from multiprocessing import get_context
 from pprint import pformat as pf
-from typing import Callable, Dict, List, Set
+from typing import Callable, Dict, List, Set, Union
 
 from click import Context, get_current_context
-from click.core import ParameterSource, Command, Group
-
+from click.core import Command, Group, ParameterSource
+from dbt.cli.exceptions import DbtUsageException
+from dbt.cli.resolvers import default_log_path, default_project_dir
 from dbt.config.profile import read_user_config
 from dbt.contracts.project import UserConfig
-from dbt.cli.exceptions import DbtUsageException
 from dbt.deprecations import renamed_env_var
 from dbt.helper_types import WarnErrorOptions
-from dbt.cli.resolvers import default_project_dir, default_log_path
-
 
 if os.name != "nt":
     # https://bugs.python.org/issue41567
     import multiprocessing.popen_spawn_posix  # type: ignore  # noqa: F401
 
-# TODO anything that has a default in params should be removed here?
-# Or maybe only the ones that's in the root click group
 FLAGS_DEFAULTS = {
     "INDIRECT_SELECTION": "eager",
     "TARGET_PATH": None,
-    # cli args without user_config or env var option
+    # Cli args without user_config or env var option.
     "FULL_REFRESH": False,
     "STRICT_MODE": False,
     "STORE_FAILURES": False,
     "INTROSPECT": True,
 }
 
-
-# For backwards compatability, some params are defined across multiple levels,
-# Top-level value should take precedence.
-# e.g. dbt --target-path test2 run --target-path test2
-EXPECTED_DUPLICATE_PARAMS = [
-    "full_refresh",
-    "target_path",
-    "version_check",
-    "fail_fast",
-    "indirect_selection",
-    "store_failures",
-    "introspect",
-]
+DEPRECATED_PARAMS = {
+    "deprecated_defer": "defer",
+    "deprecated_favor_state": "favor_state",
+    "deprecated_print": "print",
+    "deprecated_state": "state",
+}
 
 
 def convert_config(config_name, config_value):
-    # This function should take care of converting the values from config and original
-    # set_from_args to the correct type
+    """Convert the values from config and original set_from_args to the correct type."""
     ret = config_value
     if config_name.lower() == "warn_error_options" and type(config_value) == dict:
         ret = WarnErrorOptions(
@@ -65,12 +52,12 @@ def args_to_context(args: List[str]) -> Context:
     from dbt.cli.main import cli
 
     cli_ctx = cli.make_context(cli.name, args)
-    # args would get converted during make context
+    # Split args if they're a comma seperated 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)
 
-    # handle source and docs group
+    # Handle source and docs group.
     if type(sub_command) == Group:
         sub_command_name, sub_command, args = sub_command.resolve_command(cli_ctx, args)
 
@@ -80,111 +67,122 @@ def args_to_context(args: List[str]) -> Context:
     return sub_command_ctx
 
 
-DEPRECATED_PARAMS = {
-    "deprecated_defer": "defer",
-    "deprecated_favor_state": "favor_state",
-    "deprecated_print": "print",
-    "deprecated_state": "state",
-}
-
-
 @dataclass(frozen=True)
 class Flags:
+    """Primary configuration artifact for running dbt"""
+
     def __init__(self, ctx: Context = None, user_config: UserConfig = None) -> None:
 
-        # set the default flags
+        # Set the default flags.
         for key, value in FLAGS_DEFAULTS.items():
             object.__setattr__(self, key, value)
 
         if ctx is None:
             ctx = get_current_context()
 
-        def assign_params(ctx, params_assigned_from_default, deprecated_env_vars):
+        def _get_params_by_source(ctx: Context, source_type: ParameterSource):
+            """Generates all params of a given source type."""
+            yield from [
+                name for name, source in ctx._parameter_source.items() if source is source_type
+            ]
+            if ctx.parent:
+                yield from _get_params_by_source(ctx.parent, source_type)
+
+        # Ensure that any params sourced from the commandline are not present more than once.
+        # Click handles this exclusivity, but only at a per-subcommand level.
+        seen_params = []
+        for param in _get_params_by_source(ctx, ParameterSource.COMMANDLINE):
+            if param in seen_params:
+                raise DbtUsageException(
+                    f"{param.lower()} was provided both before and after the subcommand, it can only be set either before or after.",
+                )
+            seen_params.append(param)
+
+        def _assign_params(
+            ctx: Context,
+            params_assigned_from_default: set,
+            deprecated_env_vars: Dict[str, Callable],
+        ):
             """Recursively adds all click params to flag object"""
             for param_name, param_value in ctx.params.items():
-                # TODO: this is to avoid duplicate params being defined in two places (version_check in run and cli)
-                # However this is a bit of a hack and we should find a better way to do this
-
                 # N.B. You have to use the base MRO method (object.__setattr__) to set attributes
                 # when using frozen dataclasses.
                 # https://docs.python.org/3/library/dataclasses.html#frozen-instances
-                if hasattr(self, param_name.upper()):
-                    if param_name in deprecated_env_vars:
-                        # param already set via its deprecated but still respected env var
+
+                # Handle deprecated env vars while still respecting old values
+                # e.g. DBT_NO_PRINT -> DBT_PRINT if DBT_NO_PRINT is set, it is
+                # respected over DBT_PRINT or --print.
+                new_name: Union[str, None] = None
+                if param_name in DEPRECATED_PARAMS:
+
+                    # Deprecated env vars can only be set via env var.
+                    # We use the deprecated option in click to serialize the value
+                    # from the env var string.
+                    param_source = ctx.get_parameter_source(param_name)
+                    if param_source == ParameterSource.DEFAULT:
                         continue
+                    elif param_source != ParameterSource.ENVIRONMENT:
+                        raise DbtUsageException(
+                            "Deprecated parameters can only be set via environment variables",
+                        )
 
-                    if (
-                        param_name not in EXPECTED_DUPLICATE_PARAMS
-                        and ctx.get_parameter_source(param_name) != "kwargs"
-                    ):
+                    # Rename for clarity.
+                    dep_name = param_name
+                    new_name = DEPRECATED_PARAMS.get(dep_name)
+                    try:
+                        assert isinstance(new_name, str)
+                    except AssertionError:
                         raise Exception(
-                            f"Duplicate flag names found in click command: {param_name}"
+                            f"No deprecated param name match in DEPRECATED_PARAMS from {dep_name} to {new_name}"
                         )
-                    else:
-                        # Expected duplicate param from multi-level click command (ex: dbt --full_refresh run --full_refresh)
-                        # Overwrite user-configured param with value from parent context
-                        if ctx.get_parameter_source(param_name) != ParameterSource.DEFAULT:
-                            object.__setattr__(self, param_name.upper(), param_value)
-                else:
-                    # handle deprecated env vars while still respecting old values
-                    # e.g. DBT_NO_PRINT -> DBT_PRINT if DBT_NO_PRINT is set, it is
-                    # respected over DBT_PRINT or --print
-                    if param_name in DEPRECATED_PARAMS:
-
-                        # deprecated env vars can only be set via env var.
-                        # we use the deprecated option in click to serialize the value
-                        # from the env var string
-                        param_source = ctx.get_parameter_source(param_name)
-                        if param_source == ParameterSource.DEFAULT:
-                            continue
-                        elif param_source != ParameterSource.ENVIRONMENT:
-                            raise DbtUsageException(
-                                "Deprecated parameters can only be set via environment variables",
-                            )
-
-                        # rename for clarity
-                        dep_name = param_name
-                        new_name = DEPRECATED_PARAMS.get(dep_name)
-
-                        # find param objects for their envvar name
-                        try:
-                            dep_opt = [x for x in ctx.command.params if x.name == dep_name][0]
-                            new_opt = [x for x in ctx.command.params if x.name == new_name][0]
-                        except IndexError:
-                            raise Exception(
-                                f"No deprecated param name match from {dep_name} to {new_name}"
-                            )
-
-                        # remove param from defaulted set since the deprecated
-                        # value is not set from default, but from an env var
-                        if new_name in params_assigned_from_default:
-                            params_assigned_from_default.remove(new_name)
-
-                        # adding the deprecation warning function to the set
-                        deprecated_env_vars[new_name] = renamed_env_var(
-                            old_name=dep_opt.envvar,
-                            new_name=new_opt.envvar,
+
+                    # Find param objects for their envvar name.
+                    try:
+                        dep_param = [x for x in ctx.command.params if x.name == dep_name][0]
+                        new_param = [x for x in ctx.command.params if x.name == new_name][0]
+                    except IndexError:
+                        raise Exception(
+                            f"No deprecated param name match in context from {dep_name} to {new_name}"
                         )
 
-                        object.__setattr__(self, new_name.upper(), param_value)
-                    else:
-                        object.__setattr__(self, param_name.upper(), param_value)
-                        if ctx.get_parameter_source(param_name) == ParameterSource.DEFAULT:
-                            params_assigned_from_default.add(param_name)
+                    # Remove param from defaulted set since the deprecated
+                    # value is not set from default, but from an env var.
+                    if new_name in params_assigned_from_default:
+                        params_assigned_from_default.remove(new_name)
+
+                    # Add the deprecation warning function to the set.
+                    assert isinstance(dep_param.envvar, str)
+                    assert isinstance(new_param.envvar, str)
+                    deprecated_env_vars[new_name] = renamed_env_var(
+                        old_name=dep_param.envvar,
+                        new_name=new_param.envvar,
+                    )
+
+                # Set the flag value.
+                is_duplicate = hasattr(self, param_name.upper())
+                is_default = ctx.get_parameter_source(param_name) == ParameterSource.DEFAULT
+                flag_name = (new_name or param_name).upper()
+
+                if (is_duplicate and not is_default) or not is_duplicate:
+                    object.__setattr__(self, flag_name, param_value)
+
+                # Track default assigned params.
+                if is_default:
+                    params_assigned_from_default.add(param_name)
 
             if ctx.parent:
-                assign_params(ctx.parent, params_assigned_from_default, deprecated_env_vars)
+                _assign_params(ctx.parent, params_assigned_from_default, deprecated_env_vars)
 
         params_assigned_from_default = set()  # type: Set[str]
         deprecated_env_vars: Dict[str, Callable] = {}
-        assign_params(ctx, params_assigned_from_default, deprecated_env_vars)
+        _assign_params(ctx, params_assigned_from_default, deprecated_env_vars)
 
-        # set deprecated_env_var_warnings to be fired later after events have been init
+        # Set deprecated_env_var_warnings to be fired later after events have been init.
         object.__setattr__(
             self, "deprecated_env_var_warnings", [x for x in deprecated_env_vars.values()]
         )
 
-        # Get the invoked command flags
+        # Get the invoked command flags.
         invoked_subcommand_name = (
             ctx.invoked_subcommand if hasattr(ctx, "invoked_subcommand") else None
         )
@@ -193,7 +191,7 @@ def assign_params(ctx, params_assigned_from_default, deprecated_env_vars):
             invoked_subcommand.allow_extra_args = True
             invoked_subcommand.ignore_unknown_options = True
             invoked_subcommand_ctx = invoked_subcommand.make_context(None, sys.argv)
-            assign_params(
+            _assign_params(
                 invoked_subcommand_ctx, params_assigned_from_default, deprecated_env_vars
             )
 
@@ -201,7 +199,7 @@ def assign_params(ctx, params_assigned_from_default, deprecated_env_vars):
             profiles_dir = getattr(self, "PROFILES_DIR", None)
             user_config = read_user_config(profiles_dir) if profiles_dir else None
 
-        # Overwrite default assignments with user config if available
+        # Overwrite default assignments with user config if available.
         if user_config:
             param_assigned_from_default_copy = params_assigned_from_default.copy()
             for param_assigned_from_default in params_assigned_from_default:
@@ -215,16 +213,16 @@ def assign_params(ctx, params_assigned_from_default, deprecated_env_vars):
                     param_assigned_from_default_copy.remove(param_assigned_from_default)
             params_assigned_from_default = param_assigned_from_default_copy
 
-        # Hard coded flags
+        # Set hard coded flags.
         object.__setattr__(self, "WHICH", invoked_subcommand_name or ctx.info_name)
         object.__setattr__(self, "MP_CONTEXT", get_context("spawn"))
 
-        # Apply the lead/follow relationship between some parameters
+        # Apply the lead/follow relationship between some parameters.
         self._override_if_set("USE_COLORS", "USE_COLORS_FILE", params_assigned_from_default)
         self._override_if_set("LOG_LEVEL", "LOG_LEVEL_FILE", params_assigned_from_default)
         self._override_if_set("LOG_FORMAT", "LOG_FORMAT_FILE", params_assigned_from_default)
 
-        # Default LOG_PATH from PROJECT_DIR, if available.
+        # Set default LOG_PATH from PROJECT_DIR, if available.
         # Starting in v1.5, if `log-path` is set in `dbt_project.yml`, it will raise a deprecation warning,
         # with the possibility of removing it in a future release.
         if getattr(self, "LOG_PATH", None) is None:
@@ -232,31 +230,30 @@ def assign_params(ctx, params_assigned_from_default, deprecated_env_vars):
             version_check = getattr(self, "VERSION_CHECK", True)
             object.__setattr__(self, "LOG_PATH", default_log_path(project_dir, version_check))
 
-        # Support console DO NOT TRACK initiative
+        # Support console DO NOT TRACK initiative.
         if os.getenv("DO_NOT_TRACK", "").lower() in ("1", "t", "true", "y", "yes"):
             object.__setattr__(self, "SEND_ANONYMOUS_USAGE_STATS", False)
 
-        # Check mutual exclusivity once all flags are set
+        # Check mutual exclusivity once all flags are set.
         self._assert_mutually_exclusive(
             params_assigned_from_default, ["WARN_ERROR", "WARN_ERROR_OPTIONS"]
         )
 
-        # Support lower cased access for legacy code
+        # Support lower cased access for legacy code.
         params = set(
             x for x in dir(self) if not callable(getattr(self, x)) and not x.startswith("__")
         )
         for param in params:
             object.__setattr__(self, param.lower(), getattr(self, param))
 
-    # If the value of the lead parameter was set explicitly, apply the value to follow,
-    # unless follow was also set explicitly.
+    def __str__(self) -> str:
+        return str(pf(self.__dict__))
+
     def _override_if_set(self, lead: str, follow: str, defaulted: Set[str]) -> None:
+        """If the value of the lead parameter was set explicitly, apply the value to follow, unless follow was also set explicitly."""
         if lead.lower() not in defaulted and follow.lower() in defaulted:
             object.__setattr__(self, follow.upper(), getattr(self, lead.upper(), None))
 
-    def __str__(self) -> str:
-        return str(pf(self.__dict__))
-
     def _assert_mutually_exclusive(
         self, params_assigned_from_default: Set[str], group: List[str]
     ) -> None:
@@ -275,7 +272,8 @@ def _assert_mutually_exclusive(
                 set_flag = flag
 
     def fire_deprecations(self):
+        """Fires events for deprecated env_var usage."""
         [dep_fn() for dep_fn in self.deprecated_env_var_warnings]
-        # it is necessary to remove this attr from the class so it does
-        # not get pickled when written to disk as json
+        # 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")
diff --git a/core/dbt/cli/main.py b/core/dbt/cli/main.py
--- a/core/dbt/cli/main.py
+++ b/core/dbt/cli/main.py
@@ -133,9 +133,9 @@ def invoke(self, args: List[str], **kwargs) -> dbtRunnerResult:
     epilog="Specify one of these sub-commands and you can find more help from there.",
 )
 @click.pass_context
-@p.send_anonymous_usage_stats
 @p.cache_selected_only
 @p.debug
+@p.deprecated_print
 @p.enable_legacy_logger
 @p.fail_fast
 @p.log_cache_events
@@ -147,10 +147,10 @@ def invoke(self, args: List[str], **kwargs) -> dbtRunnerResult:
 @p.macro_debugging
 @p.partial_parse
 @p.print
-@p.deprecated_print
 @p.printer_width
 @p.quiet
 @p.record_timing_info
+@p.send_anonymous_usage_stats
 @p.single_threaded
 @p.static_parser
 @p.use_colors


Test patch:
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
@@ -3,7 +3,7 @@
 import click
 from multiprocessing import get_context
 from pathlib import Path
-from typing import List
+from typing import List, Optional
 
 from dbt.cli.exceptions import DbtUsageException
 from dbt.cli.main import cli
@@ -13,8 +13,10 @@
 
 
 class TestFlags:
-    def make_dbt_context(self, context_name: str, args: List[str]) -> click.Context:
-        ctx = cli.make_context(context_name, args)
+    def make_dbt_context(
+        self, context_name: str, args: List[str], parent: Optional[click.Context] = None
+    ) -> click.Context:
+        ctx = cli.make_context(context_name, args, parent)
         return ctx
 
     @pytest.fixture(scope="class")
@@ -338,3 +340,10 @@ def test_log_file_settings_from_config(self):
         assert flags.LOG_LEVEL_FILE == "warn"
         assert flags.USE_COLORS is True
         assert flags.USE_COLORS_FILE is False
+
+    def test_duplicate_flags_raises_error(self):
+        parent_context = self.make_dbt_context("parent", ["--version-check"])
+        context = self.make_dbt_context("child", ["--version-check"], parent_context)
+
+        with pytest.raises(DbtUsageException):
+            Flags(context)