Skip to content

Commit

Permalink
Handle internal exceptions in postflight (#7242)
Browse files Browse the repository at this point in the history
  • Loading branch information
stu-k authored Apr 10, 2023
1 parent f38d5ad commit 2afb4cc
Show file tree
Hide file tree
Showing 17 changed files with 281 additions and 177 deletions.
6 changes: 6 additions & 0 deletions .changes/unreleased/Fixes-20230329-113203.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Fixes
body: Handle internal exceptions
time: 2023-03-29T11:32:03.259072-05:00
custom:
Author: stu-k
Issue: "7118"
50 changes: 49 additions & 1 deletion core/dbt/cli/README.md
Original file line number Diff line number Diff line change
@@ -1 +1,49 @@
TODO
# Exception Handling

## `requires.py`

### `postflight`
In the postflight decorator, the click command is invoked (i.e. `func(*args, **kwargs)`) and wrapped in a `try/except` block to handle any exceptions thrown.
Any exceptions thrown from `postflight` are wrapped by custom exceptions from the `dbt.cli.exceptions` module (i.e. `ResultExit`, `ExceptionExit`) to instruct click to complete execution with a particular exit code.

Some `dbt-core` handled exceptions have an attribute named `results` which contains results from running nodes (e.g. `FailFastError`). These are wrapped in the `ResultExit` exception to represent runs that have failed in a way that `dbt-core` expects.
If the invocation of the command does not throw any exceptions but does not succeed, `postflight` will still raise the `ResultExit` exception to make use of the exit code.
These exceptions produce an exit code of `1`.

Exceptions wrapped with `ExceptionExit` may be thrown by `dbt-core` intentionally (i.e. an exception that inherits from `dbt.exceptions.Exception`) or unintentionally (i.e. exceptions thrown by the python runtime). In either case these are considered errors that `dbt-core` did not expect and are treated as genuine exceptions.
These exceptions produce an exit code of `2`.

If no exceptions are thrown from invoking the command and the command succeeds, `postflight` will not raise any exceptions.
When no exceptions are raised an exit code of `0` is produced.

## `main.py`

### `dbtRunner`
`dbtRunner` provides a programmatic interface for our click CLI and wraps the invocation of the click commands to handle any exceptions thrown.

`dbtRunner.invoke` should ideally only ever return an instantiated `dbtRunnerResult` which contains the following fields:
- `success`: A boolean representing whether the command invocation was successful
- `result`: The optional result of the command invoked. This attribute can have many types, please see the definition of `dbtRunnerResult` for more information
- `exception`: If an exception was thrown during command invocation it will be saved here, otherwise it will be `None`. Please note that the exceptions held in this attribute are not the exceptions thrown by `preflight` but instead the exceptions that `ResultExit` and `ExceptionExit` wrap

Programmatic exception handling might look like the following:
```python
res = dbtRunner().invoke(["run"])
if not res.success:
...
if type(res.exception) == SomeExceptionType:
...
```

## `dbt/tests/util.py`

### `run_dbt`
In many of our functional and integration tests, we want to be sure that an invocation of `dbt` raises a certain exception.
A common pattern for these assertions:
```python
class TestSomething:
def test_something(self, project):
with pytest.raises(SomeException):
run_dbt(["run"])
```
To allow these tests to assert that exceptions have been thrown, the `run_dbt` function will raise any exceptions it recieves from the invocation of a `dbt` command.
4 changes: 2 additions & 2 deletions core/dbt/cli/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# initialize the dbt runner
dbt = dbtRunner()
# run the command
res, success = dbt.invoke(cli_args)
res = dbt.invoke(cli_args)

# preload profile and project
profile = load_profile(project_dir, {}, "testing-postgres")
Expand All @@ -17,4 +17,4 @@
# initialize the runner with pre-loaded profile and project, you can also pass in a preloaded manifest
dbt = dbtRunner(profile=profile, project=project)
# run the command, this will use the pre-loaded profile and project instead of loading
res, success = dbt.invoke(cli_args)
res = dbt.invoke(cli_args)
43 changes: 43 additions & 0 deletions core/dbt/cli/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from typing import Optional, IO

from click.exceptions import ClickException
from dbt.utils import ExitCodes


class DbtUsageException(Exception):
pass


class DbtInternalException(Exception):
pass


class CliException(ClickException):
"""The base exception class for our implementation of the click CLI.
The exit_code attribute is used by click to determine which exit code to produce
after an invocation."""

def __init__(self, exit_code: ExitCodes) -> None:
self.exit_code = exit_code.value

# the typing of _file is to satisfy the signature of ClickException.show
# overriding this method prevents click from printing any exceptions to stdout
def show(self, _file: Optional[IO] = None) -> None:
pass


class ResultExit(CliException):
"""This class wraps any exception that contains results while invoking dbt, or the
results of an invocation that did not succeed but did not throw any exceptions."""

def __init__(self, result) -> None:
super().__init__(ExitCodes.ModelError)
self.result = result


class ExceptionExit(CliException):
"""This class wraps any exception that does not contain results thrown while invoking dbt."""

def __init__(self, exception: Exception) -> None:
super().__init__(ExitCodes.UnhandledError)
self.exception = exception
10 changes: 5 additions & 5 deletions core/dbt/cli/flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from pprint import pformat as pf
from typing import Callable, Dict, List, Set

from click import Context, get_current_context, BadOptionUsage
from click import Context, get_current_context
from click.core import ParameterSource, Command, Group

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
Expand Down Expand Up @@ -137,8 +138,7 @@ def assign_params(ctx, params_assigned_from_default, deprecated_env_vars):
if param_source == ParameterSource.DEFAULT:
continue
elif param_source != ParameterSource.ENVIRONMENT:
raise BadOptionUsage(
param_name,
raise DbtUsageException(
"Deprecated parameters can only be set via environment variables",
)

Expand Down Expand Up @@ -268,8 +268,8 @@ def _assert_mutually_exclusive(
for flag in group:
flag_set_by_user = flag.lower() not in params_assigned_from_default
if flag_set_by_user and set_flag:
raise BadOptionUsage(
flag.lower(), f"{flag.lower()}: not allowed with argument {set_flag.lower()}"
raise DbtUsageException(
f"{flag.lower()}: not allowed with argument {set_flag.lower()}"
)
elif flag_set_by_user:
set_flag = flag
Expand Down
Loading

0 comments on commit 2afb4cc

Please sign in to comment.