Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Behavior Flag Framework #183

Merged
merged 17 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest fixtures get registered by importing them into conftest.py. They do not need to be used in that file.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mypy was looking for pytest_mock types.

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
Loading