Skip to content

Commit

Permalink
Add Behavior Flag Framework (#183)
Browse files Browse the repository at this point in the history
* add behavior flag framework

* support older versions of python

* add sample flag for even

* changelog

* add no_warn option for behavior flags

* bump version for downstream testing

* update user_flags to user_overrides

* update Behavior from SimpleNamespace to a custom class to support typing

* move flag initialization into rendered flag, adopt Rendered naming convention

* fix the default source test

* fix the event code; update docs

* update docs

* update docs

* use the correct exception in Behavior

* move event_catcher fixture to be reusable

* add a unit test for raising the correct exception
  • Loading branch information
mikealfare authored Sep 6, 2024
1 parent 35af654 commit ce09ad3
Show file tree
Hide file tree
Showing 12 changed files with 376 additions and 48 deletions.
6 changes: 6 additions & 0 deletions .changes/unreleased/Features-20240808-194933.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Features
body: Add Behavior Flag Framework
time: 2024-08-08T19:49:33.738569-04:00
custom:
Author: mikealfare
Issue: "178"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/

# VSCode
.vscode/
2 changes: 1 addition & 1 deletion dbt_common/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
version = "1.7.0"
version = "1.8.0a1"
125 changes: 125 additions & 0 deletions dbt_common/behavior_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import inspect
from typing import Any, Dict, List, TypedDict

try:
from typing import NotRequired
except ImportError:
# NotRequired was introduced in Python 3.11
# This is the suggested way to implement a TypedDict with optional arguments
from typing import Optional as NotRequired

from dbt_common.events.functions import fire_event
from dbt_common.events.types import BehaviorDeprecationEvent
from dbt_common.exceptions import CompilationError


class BehaviorFlag(TypedDict):
"""
Configuration used to create a BehaviorFlagRendered instance
Args:
name: the name of the behavior flag
default: default setting, starts as False, becomes True after a bake-in period
deprecation_version: the version when the default will change to True
deprecation_message: an additional message to send when the flag evaluates to False
docs_url: the url to the relevant docs on docs.getdbt.com
"""

name: str
default: bool
source: NotRequired[str]
deprecation_version: NotRequired[str]
deprecation_message: NotRequired[str]
docs_url: NotRequired[str]


class BehaviorFlagRendered:
"""
A rendered behavior flag that gets used throughout dbt packages
Args:
flag: the configuration for the behavior flag
user_overrides: a set of user settings, one of which may be an override on this behavior flag
"""

def __init__(self, flag: BehaviorFlag, user_overrides: Dict[str, Any]) -> None:
self.name = flag["name"]
self.setting = user_overrides.get(flag["name"], flag["default"])
self.deprecation_event = self._deprecation_event(flag)

@property
def setting(self) -> bool:
if self._setting is False:
fire_event(self.deprecation_event)
return self._setting

@setting.setter
def setting(self, value: bool) -> None:
self._setting = value

@property
def no_warn(self) -> bool:
return self._setting

def _deprecation_event(self, flag: BehaviorFlag) -> BehaviorDeprecationEvent:
return BehaviorDeprecationEvent(
flag_name=flag["name"],
flag_source=flag.get("source", self._default_source()),
deprecation_version=flag.get("deprecation_version"),
deprecation_message=flag.get("deprecation_message"),
docs_url=flag.get("docs_url"),
)

@staticmethod
def _default_source() -> str:
"""
If the maintainer did not provide a source, default to the module that called this class.
For adapters, this will likely be `dbt.adapters.<foo>.impl` for `dbt-foo`.
"""
for frame in inspect.stack():
if module := inspect.getmodule(frame[0]):
if module.__name__ != __name__:
return module.__name__
return "Unknown"

def __bool__(self) -> bool:
return self.setting


class Behavior:
"""
A collection of behavior flags
This is effectively a dictionary that supports dot notation for easy reference, e.g.:
```python
if adapter.behavior.my_flag:
...
if adapter.behavior.my_flag.no_warn: # this will not fire the deprecation event
...
```
```jinja
{% if adapter.behavior.my_flag %}
...
{% endif %}
{% if adapter.behavior.my_flag.no_warn %} {# this will not fire the deprecation event #}
...
{% endif %}
```
Args:
flags: a list of configurations, one for each behavior flag
user_overrides: a set of user settings, which may include overrides on one or more of the behavior flags
"""

_flags: List[BehaviorFlagRendered]

def __init__(self, flags: List[BehaviorFlag], user_overrides: Dict[str, Any]) -> None:
self._flags = [BehaviorFlagRendered(flag, user_overrides) for flag in flags]

def __getattr__(self, name: str) -> BehaviorFlagRendered:
for flag in self._flags:
if flag.name == name:
return flag
raise CompilationError(f"The flag {name} has not be registered.")
16 changes: 16 additions & 0 deletions dbt_common/events/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ message GenericMessage {
EventInfo info = 1;
}

// D - Deprecations

// D018
message BehaviorDeprecationEvent {
string flag_name = 1;
string flag_source = 2;
string deprecation_version = 3;
string deprecation_message = 4;
string docs_url = 5;
}

message BehaviorDeprecationEventMsg {
EventInfo info = 1;
BehaviorDeprecationEvent data = 2;
}

// M - Deps generation

// M020
Expand Down
39 changes: 39 additions & 0 deletions dbt_common/events/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from typing import Optional

from dbt_common.events.base_types import (
DebugLevel,
InfoLevel,
WarnLevel,
)
from dbt_common.ui import warning_tag


# The classes in this file represent the data necessary to describe a
Expand All @@ -28,6 +32,41 @@
#
# The basic idea is that event codes roughly translate to the natural order of running a dbt task


# =======================================================
# D - Deprecations
# =======================================================


class BehaviorDeprecationEvent(WarnLevel):
flag_name: str
flag_source: str
deprecation_version: Optional[str]
deprecation_message: Optional[str]
docs_url: Optional[str]

def code(self) -> str:
return "D018"

def message(self) -> str:
msg = f"The legacy behavior controlled by `{self.flag_name}` is deprecated.\n"

if self.deprecation_version:
msg = (
f"The legacy behavior is expected to be retired in `{self.deprecation_version}`.\n"
)

msg += f"The new behavior can be turned on by setting `flags.{self.flag_name}` to `True` in `dbt_project.yml`.\n"

if self.deprecation_message:
msg += f"{self.deprecation_message}.\n"

docs_url = self.docs_url or f"https://docs.getdbt.com/search?q={self.flag_name}"
msg += f"Visit {docs_url} for more information."

return warning_tag(msg)


# =======================================================
# M - Deps generation
# =======================================================
Expand Down
94 changes: 49 additions & 45 deletions dbt_common/events/types_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,10 @@ exclude = [
"dbt_common/events/types_pb2.py",
"venv",
".venv",
"env*"
"env*",
".hatch/*",
]
per-file-ignores = ["*/__init__.py: F401"]
per-file-ignores = ["*/__init__.py: F401", "*/conftest.py: F401"]
docstring-convention = "google"

[tool.mypy]
Expand All @@ -140,6 +141,7 @@ show_error_codes = true
disable_error_code = "attr-defined" # TODO: revisit once other mypy errors resolved
disallow_untyped_defs = false # TODO: add type annotations everywhere
warn_redundant_casts = true
ignore_missing_imports = true
exclude = [
"dbt_common/events/types_pb2.py",
"env*",
Expand Down
1 change: 1 addition & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from tests.unit.utils import event_catcher
Loading

0 comments on commit ce09ad3

Please sign in to comment.