Repository: dbt-labs/dbt-core

Issue:
Feature: More granular on-start and on-end hooks
### Describe the feature
Each dbt command should have its own set of on-[command]-start and on-[command]-end hooks, rather than just on-run-start and on-run-end that aren't specific to the `run` command. For example, `on-test-start` would run at the start of `dbt test`, `on-source-start` would run at the start of `dbt source snapshot-freshness`, etc.

### Describe alternatives you've considered
I can run a second command right after the first one

### Additional context
I'm opening this now because I'm trying to incorporate the dbt_artifacts package, and it would be really handy to be able to trigger it in the on-[command]-end hook. Now that I'm typing this, I'm not positive the artifacts would be saved out during the hook so maybe it wouldn't work anyways, but I still think the feature is valuable. I had an earlier use case where I wanted to run a command on-test-start and on-test-end which I've forgotten, but I think it had to do with adjusting Snowflake warehouse usage.

### Who will this benefit?
Developers and admins who want more ability to trigger events or processes related to specific dbt commands.

### Are you interested in contributing this feature?
This is probably beyond me



Discussion:
@djbelknapdbs Thanks for opening!

I think, rather than a different `on-[command]-end` hook for each command type, I'd rather just have:
- All tasks observe the `on-run-start` and `on-run-end` hooks. (It's really [just `dbt test`](https://github.com/fishtown-analytics/dbt/blob/c794600242e734d870d4bf1c8fb4c0349ab961eb/core/dbt/task/test.py#L170-L174) that ignores them today, and I don't think this makes a lot of sense.)
- We expose which arguments were passed to the current task to the Jinja context, via a `flags` or `args` context variable, as proposed in https://github.com/fishtown-analytics/dbt/issues/2253#issuecomment-606637983. Then, you have full control in your `on-run-x` hook (or the macro it calls) to add conditional checks based on whether the command is `dbt run`, `dbt test`, `dbt source snapshot-freshness`, etc.

What do you think?

> Now that I'm typing this, I'm not positive the artifacts would be saved out during the hook so maybe it wouldn't work anyways, but I still think the feature is valuable.

I think that's right, so this change wouldn't work for the particular use case. As a general rule, though, I think the feature would be valuable! There are some context variables, such as `schemas` and `results`, that are only available in the [`on-run-end` context](https://docs.getdbt.com/reference/dbt-jinja-functions/on-run-end-context).
The actual change to the `test` task to enable `on-run-start` + `on-run-end` hooks is tremendously straightforward, it just looks like removing this method override:
https://github.com/dbt-labs/dbt/blob/650b34ae246b4c60cde395fcb123418c7807f9aa/core/dbt/task/test.py#L170-L174

The trickier change would be the complementary addition of a `flags`/`args` context variable, enabling users to switch on/off a particular project hook based on the current task type. I don't think that's a blocker to making this switch, it's just a good idea that will make `on-run-x` hooks even more powerful/configurable.

Worth saying that the `dbt build` command, new in v0.21, is going to execute tests _and_ execute `on-run-x` hooks.

Resolving patch:
diff --git a/core/dbt/context/base.py b/core/dbt/context/base.py
--- a/core/dbt/context/base.py
+++ b/core/dbt/context/base.py
@@ -524,12 +524,8 @@ def flags(self) -> Any:
             -- no-op
             {% endif %}
 
-        The list of valid flags are:
-
-        - `flags.FULL_REFRESH`: True if `--full-refresh` was provided on the
-            command line
-        - `flags.NON_DESTRUCTIVE`: True if `--non-destructive` was provided on
-            the command line
+        This supports all flags defined in flags submodule (core/dbt/flags.py)
+        TODO: Replace with object that provides read-only access to flag values
         """
         return flags
 
diff --git a/core/dbt/flags.py b/core/dbt/flags.py
--- a/core/dbt/flags.py
+++ b/core/dbt/flags.py
@@ -32,6 +32,7 @@
 FAIL_FAST = None
 SEND_ANONYMOUS_USAGE_STATS = None
 PRINTER_WIDTH = 80
+WHICH = None
 
 # Global CLI defaults. These flags are set from three places:
 # CLI args, environment variables, and user_config (profiles.yml).
@@ -77,8 +78,6 @@ def env_set_path(key: str) -> Optional[Path]:
         return Path(value)
 
 
-SINGLE_THREADED_WEBSERVER = env_set_truthy('DBT_SINGLE_THREADED_WEBSERVER')
-SINGLE_THREADED_HANDLER = env_set_truthy('DBT_SINGLE_THREADED_HANDLER')
 MACRO_DEBUGGING = env_set_truthy('DBT_MACRO_DEBUGGING')
 DEFER_MODE = env_set_truthy('DBT_DEFER_TO_STATE')
 ARTIFACT_STATE_PATH = env_set_path('DBT_ARTIFACT_STATE_PATH')
@@ -97,13 +96,15 @@ def set_from_args(args, user_config):
     global STRICT_MODE, FULL_REFRESH, WARN_ERROR, \
         USE_EXPERIMENTAL_PARSER, STATIC_PARSER, WRITE_JSON, PARTIAL_PARSE, \
         USE_COLORS, STORE_FAILURES, PROFILES_DIR, DEBUG, LOG_FORMAT, GREEDY, \
-        VERSION_CHECK, FAIL_FAST, SEND_ANONYMOUS_USAGE_STATS, PRINTER_WIDTH
+        VERSION_CHECK, FAIL_FAST, SEND_ANONYMOUS_USAGE_STATS, PRINTER_WIDTH, \
+        WHICH
 
     STRICT_MODE = False  # backwards compatibility
     # cli args without user_config or env var option
     FULL_REFRESH = getattr(args, 'full_refresh', FULL_REFRESH)
     STORE_FAILURES = getattr(args, 'store_failures', STORE_FAILURES)
     GREEDY = getattr(args, 'greedy', GREEDY)
+    WHICH = getattr(args, 'which', WHICH)
 
     # global cli flags with env var and user_config alternatives
     USE_EXPERIMENTAL_PARSER = get_flag_value('USE_EXPERIMENTAL_PARSER', args, user_config)


Test patch:
diff --git a/core/dbt/task/test.py b/core/dbt/task/test.py
--- a/core/dbt/task/test.py
+++ b/core/dbt/task/test.py
@@ -2,7 +2,7 @@
 from dbt import utils
 from dbt.dataclass_schema import dbtClassMixin
 import threading
-from typing import Dict, Any, Union
+from typing import Union
 
 from .compile import CompileRunner
 from .run import RunTask
@@ -24,7 +24,7 @@
 from dbt.graph import (
     ResourceTypeSelector,
 )
-from dbt.node_types import NodeType, RunHookType
+from dbt.node_types import NodeType
 from dbt import flags
 
 
@@ -165,12 +165,6 @@ class TestTask(RunTask):
     def raise_on_first_error(self):
         return False
 
-    def safe_run_hooks(
-        self, adapter, hook_type: RunHookType, extra_context: Dict[str, Any]
-    ) -> None:
-        # Don't execute on-run-* hooks for tests
-        pass
-
     def get_node_selector(self) -> TestSelector:
         if self.manifest is None or self.graph is None:
             raise InternalException(
diff --git a/test/integration/008_schema_tests_test/test_schema_v2_tests.py b/test/integration/008_schema_tests_test/test_schema_v2_tests.py
--- a/test/integration/008_schema_tests_test/test_schema_v2_tests.py
+++ b/test/integration/008_schema_tests_test/test_schema_v2_tests.py
@@ -218,6 +218,38 @@ def test_postgres_config(self):
 
 class TestHooksInTests(DBTIntegrationTest):
 
+    @property
+    def schema(self):
+        return "schema_tests_008"
+
+    @property
+    def models(self):
+        # this makes things easier
+        return "ephemeral"
+
+    @property
+    def project_config(self):
+        return {
+            'config-version': 2,
+            "on-run-start": ["{{ log('hooks called in tests -- good!') if execute }}"],
+            "on-run-end": ["{{ log('hooks called in tests -- good!') if execute }}"],
+        }
+
+    @use_profile('postgres')
+    def test_postgres_hooks_do_run_for_tests(self):
+        # This passes now that hooks run, a behavior we changed in v1.0
+        results = self.run_dbt(['test', '--model', 'ephemeral'])
+        self.assertEqual(len(results), 1)
+        for result in results:
+            self.assertEqual(result.status, "pass")
+            self.assertFalse(result.skipped)
+            self.assertEqual(
+                result.failures, 0,
+                'test {} failed'.format(result.node.name)
+            )
+
+class TestHooksForWhich(DBTIntegrationTest):
+
     @property
     def schema(self):
         return "schema_tests_008"
@@ -231,12 +263,12 @@ def models(self):
     def project_config(self):
         return {
             'config-version': 2,
-            "on-run-start": ["{{ exceptions.raise_compiler_error('hooks called in tests -- error') if execute }}"],
-            "on-run-end": ["{{ exceptions.raise_compiler_error('hooks called in tests -- error') if execute }}"],
+            "on-run-start": ["{{exceptions.raise_compiler_error('hooks called in tests -- error') if (execute and flags.WHICH != 'test') }}"],
+            "on-run-end": ["{{exceptions.raise_compiler_error('hooks called in tests -- error') if (execute and flags.WHICH != 'test') }}"],
         }
 
     @use_profile('postgres')
-    def test_postgres_hooks_dont_run_for_tests(self):
+    def test_postgres_these_hooks_dont_run_for_tests(self):
         # This would fail if the hooks ran
         results = self.run_dbt(['test', '--model', 'ephemeral'])
         self.assertEqual(len(results), 1)