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

Support configuration using pyproject.toml #16

Merged
merged 30 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7736f7f
Support configuration using pyproject.toml
jochemvandooren May 1, 2024
376c6bc
Formatting
jochemvandooren May 1, 2024
c769daa
Fix existing tests
jochemvandooren May 1, 2024
0d278bf
Add check for pyproject.toml
jochemvandooren May 1, 2024
92398fc
Make rule_config an optional parameter
jochemvandooren May 1, 2024
1ab1c9e
Make general config an optional parameter
jochemvandooren May 1, 2024
651fd53
Add tests
jochemvandooren May 1, 2024
3622da3
Add mkdocs files
jochemvandooren May 1, 2024
58b15b4
Merge branch 'master' into jvandooren/rule-configuration
jochemvandooren May 1, 2024
a465a29
rule-configuration Fix formatting
jochemvandooren May 1, 2024
933207d
Add a test for disabling rules
jochemvandooren May 2, 2024
dc58c5a
Improve docstrings
jochemvandooren May 2, 2024
e62836d
Update src/dbt_score/rule.py
jochemvandooren May 3, 2024
4603f43
Process review comments
jochemvandooren May 3, 2024
edc2766
Make config defaults Final
jochemvandooren May 3, 2024
566b561
Drop python 3.10 support
jochemvandooren May 3, 2024
1abff4f
Rename config_parser to config
jochemvandooren May 3, 2024
7abc430
Revert copying rule config
jochemvandooren May 3, 2024
8cadd1a
Improve logging and error
jochemvandooren May 3, 2024
862da7f
Remove unused fixtures
jochemvandooren May 3, 2024
06e0957
Improve test
jochemvandooren May 3, 2024
da190b7
Fix python version in readme
jochemvandooren May 3, 2024
12d49fc
Fix type hints
jochemvandooren May 3, 2024
ae091fe
Address comments
jochemvandooren May 3, 2024
99c4da3
Update tests/test_cli.py
jochemvandooren May 10, 2024
a50d027
Require config in RuleRegistry
jochemvandooren May 10, 2024
52080c0
Convert Severity in RuleConfig
jochemvandooren May 10, 2024
923d020
Change params to config and make get_config_file static
jochemvandooren May 13, 2024
4af4971
Use rule.source() instead of reconstructing name
jochemvandooren May 14, 2024
87998db
Use proper namespaces in tests
jochemvandooren May 14, 2024
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
3 changes: 3 additions & 0 deletions docs/reference/config_parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Config parser

::: dbt_score.config_parser
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ nav:
- Home: index.md
- Reference:
- reference/cli.md
- reference/config_parser.md
- reference/exceptions.md
- reference/evaluation.md
- reference/models.md
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ max-args = 6

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
"PLR2004", # magic value comparisons
"PLR2004", # Magic value comparisons
"PLR0913" # Too many arguments in function definition
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
]

### Coverage ###
Expand Down
7 changes: 6 additions & 1 deletion src/dbt_score/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from click.core import ParameterSource
from dbt.cli.options import MultiOption

from dbt_score.config_parser import DEFAULT_CONFIG_FILE, DbtScoreConfig
from dbt_score.lint import lint_dbt_project
from dbt_score.parse import dbt_parse, get_default_manifest_path

Expand Down Expand Up @@ -57,7 +58,11 @@ def lint(select: tuple[str], manifest: Path, run_dbt_parse: bool) -> None:
if manifest_provided and run_dbt_parse:
raise click.UsageError("--run-dbt-parse cannot be used with --manifest.")

config = DbtScoreConfig()
if Path(DEFAULT_CONFIG_FILE).exists():
config.load_toml_file(DEFAULT_CONFIG_FILE)

if run_dbt_parse:
dbt_parse()

lint_dbt_project(manifest)
lint_dbt_project(manifest, config)
80 changes: 80 additions & 0 deletions src/dbt_score/config_parser.py
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""This module is responsible for parsing configuration."""

import configparser
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
import json
import logging
from dataclasses import dataclass, field
from typing import Any, ClassVar

logger = logging.getLogger(__name__)

DEFAULT_CONFIG_FILE = "pyproject.toml"


@dataclass
class RuleConfig:
"""Configuration for a rule."""

severity: int | None = None
description: str | None = None
params: dict[str, Any] = field(default_factory=dict)

@staticmethod
def from_dict(rule_config: dict[str, Any]) -> "RuleConfig":
"""Create a RuleConfig from a dictionary."""
severity = rule_config.pop("severity", None)
description = rule_config.pop("description", None)

return RuleConfig(
severity=severity, description=description, params=rule_config
)


class DbtScoreConfig:
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
"""Configuration for dbt-score."""

_main_section = "tool.dbt-score"
_options: ClassVar[list[str]] = ["rule_namespaces", "disabled_rules"]
_rules_section = f"{_main_section}.rules"
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self) -> None:
"""Initialize the DbtScoreConfig object."""
self.rule_namespaces: list[str] = ["dbt_score_rules"]
self.disabled_rules: list[str] = []
self.rules_config: dict[str, RuleConfig] = {}

def set_option(self, option: str, value: Any) -> None:
"""Set an option in the config."""
setattr(self, option, value)

def load_toml_file(self, file: str) -> None:
"""Load the options from a TOML file."""
config = configparser.ConfigParser()
config.read(file)

# Main configuration
if config.has_section(self._main_section):
for option in config.options(self._main_section):
if option in self._options:
self.set_option(
option, json.loads(config.get(self._main_section, option))
)
else:
logger.warning(
f"Option {option} in {self._main_section} not supported."
)

# Rule configuration
rules_sections = list(
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
filter(
lambda section: section.startswith(self._rules_section),
config.sections(),
)
)

for rule_section in rules_sections:
rule_name = rule_section.replace(f"{self._rules_section}.", "")
rule_config = {
param: json.loads(val) for param, val in config.items(rule_section)
}
self.rules_config[rule_name] = RuleConfig.from_dict(rule_config)
6 changes: 2 additions & 4 deletions src/dbt_score/evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,13 @@ def __init__(

def evaluate(self) -> None:
"""Evaluate all rules."""
# Instantiate all rules. In case they keep state across calls, this must be
# done only once.
rules = [rule_class() for rule_class in self._rule_registry.rules.values()]
rules = self._rule_registry.rules.values()

for model in self._manifest_loader.models:
self.results[model] = {}
for rule in rules:
try:
result: RuleViolation | None = rule.evaluate(model)
result: RuleViolation | None = rule.evaluate(model, **rule.params)
except Exception as e:
self.results[model][rule.__class__] = e
else:
Expand Down
8 changes: 6 additions & 2 deletions src/dbt_score/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@

from pathlib import Path

from dbt_score.config_parser import DbtScoreConfig
from dbt_score.evaluation import Evaluation
from dbt_score.formatters.human_readable_formatter import HumanReadableFormatter
from dbt_score.models import ManifestLoader
from dbt_score.rule_registry import RuleRegistry
from dbt_score.scoring import Scorer


def lint_dbt_project(manifest_path: Path) -> None:
def lint_dbt_project(manifest_path: Path, config: DbtScoreConfig | None = None) -> None:
"""Lint dbt manifest."""
if not manifest_path.exists():
raise FileNotFoundError(f"Manifest not found at {manifest_path}.")
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved

rule_registry = RuleRegistry()
if config is None:
config = DbtScoreConfig()
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved

rule_registry = RuleRegistry(config)
rule_registry.load_all()

manifest_loader = ManifestLoader(manifest_path)
Expand Down
45 changes: 44 additions & 1 deletion src/dbt_score/rule.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Rule definitions."""

import inspect
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
import typing
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Type, TypeAlias, overload

from dbt_score.config_parser import RuleConfig
from dbt_score.models import Model


Expand Down Expand Up @@ -31,17 +33,50 @@ class Rule:

description: str
severity: Severity = Severity.MEDIUM
default_params: typing.ClassVar[dict[str, Any]] = {}

def __init__(self, rule_config: RuleConfig | None = None) -> None:
"""Initialize the rule."""
self.params = self.process_config(rule_config) if rule_config else {}
druzhinin-kirill marked this conversation as resolved.
Show resolved Hide resolved

def __init_subclass__(cls, **kwargs) -> None: # type: ignore
"""Initializes the subclass."""
super().__init_subclass__(**kwargs)
if not hasattr(cls, "description"):
raise AttributeError("Subclass must define class attribute `description`.")

def process_config(self, rule_config: RuleConfig) -> dict[str, Any]:
"""Process the rule config."""
rule_params = self.default_params.copy()

# Overwrite default rule params
for k, v in rule_config.params.items():
if k in self.default_params:
rule_params[k] = v
else:
raise AttributeError(f"Unknown rule parameter: {k}.")

self.set_severity(rule_config.severity or self.severity)
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
self.set_description(rule_config.description or self.description)
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved

return rule_params

def evaluate(self, model: Model) -> RuleViolation | None:
"""Evaluates the rule."""
raise NotImplementedError("Subclass must implement method `evaluate`.")

@classmethod
def set_severity(cls, severity: int | Severity) -> None:
"""Set the severity of the rule."""
if isinstance(severity, int):
severity = Severity(severity)
cls.severity = severity

@classmethod
def set_description(cls, description: str) -> None:
"""Set the description of the rule."""
cls.description = description

@classmethod
def source(cls) -> str:
"""Return the source of the rule, i.e. a fully qualified name."""
Expand Down Expand Up @@ -106,13 +141,21 @@ def wrapped_func(self: Rule, *args: Any, **kwargs: Any) -> RuleViolation | None:
"""Wrap func to add `self`."""
return func(*args, **kwargs)

# Get default parameters from the rule definition
default_params = {
key: val.default
for key, val in inspect.signature(func).parameters.items()
if val.default != inspect.Parameter.empty
}

# Create the rule class inheriting from Rule
rule_class = type(
func.__name__,
(Rule,),
{
"description": rule_description,
"severity": severity,
"default_params": default_params,
"evaluate": wrapped_func,
# Forward origin of the decorated function
"__qualname__": func.__qualname__, # https://peps.python.org/pep-3155/
Expand Down
29 changes: 20 additions & 9 deletions src/dbt_score/rule_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,32 @@
import pkgutil
from typing import Iterator, Type

from dbt_score.config_parser import DbtScoreConfig, RuleConfig
from dbt_score.exceptions import DuplicatedRuleException
from dbt_score.rule import Rule

logger = logging.getLogger(__name__)

THIRD_PARTY_RULES_NAMESPACE = "dbt_score_rules"


class RuleRegistry:
"""A container for configured rules."""

def __init__(self) -> None:
def __init__(self, config: DbtScoreConfig | None = None) -> None:
"""Instantiate a rule registry."""
self.config = config or DbtScoreConfig()
self._rules: dict[str, Type[Rule]] = {}
self._initialized_rules: dict[str, Rule] = {}

def init_rules(self) -> None:
"""Initialize rules."""
for rule_name, rule_class in self._rules.items():
rule_config = self.config.rules_config.get(rule_name, RuleConfig())
self._initialized_rules[rule_name] = rule_class(rule_config=rule_config)

@property
def rules(self) -> dict[str, Type[Rule]]:
def rules(self) -> dict[str, Rule]:
"""Get all rules."""
return self._rules
return self._initialized_rules

def _walk_packages(self, namespace_name: str) -> Iterator[str]:
"""Walk packages and sub-packages recursively."""
Expand All @@ -50,14 +57,18 @@ def _load(self, namespace_name: str) -> None:
for obj_name in dir(module):
obj = module.__dict__[obj_name]
if type(obj) is type and issubclass(obj, Rule) and obj is not Rule:
self._add_rule(obj_name, obj)
self._add_rule(f"{module_name}.{obj_name}", obj)

def _add_rule(self, name: str, rule: Type[Rule]) -> None:
if name in self.rules:
"""Add a rule."""
if name in self._rules:
raise DuplicatedRuleException(name)
self._rules[name] = rule
if name not in self.config.disabled_rules:
self._rules[name] = rule

def load_all(self) -> None:
"""Load all rules, core and third-party."""
self._load("dbt_score.rules")
self._load(THIRD_PARTY_RULES_NAMESPACE)
for namespace in self.config.rule_namespaces:
self._load(namespace)
self.init_rules()
43 changes: 43 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,36 @@
from typing import Any, Type

from dbt_score import Model, Rule, RuleViolation, Severity, rule
from dbt_score.config_parser import DbtScoreConfig, RuleConfig
from pytest import fixture

# Configuration


@fixture
def valid_config_path() -> Path:
"""Return the path of the configuration."""
return Path(__file__).parent / "resources" / "pyproject.toml"


@fixture
def invalid_config_path() -> Path:
"""Return the path of the configuration."""
return Path(__file__).parent / "resources" / "invalid_pyproject.toml"


@fixture
def default_config() -> DbtScoreConfig:
"""Return a DbtScoreConfig object."""
return DbtScoreConfig()


@fixture
def default_rule_config() -> RuleConfig:
"""Return an empty RuleConfig object."""
return RuleConfig()


# Manifest


Expand Down Expand Up @@ -156,6 +184,21 @@ def rule_severity_critical(model: Model) -> RuleViolation | None:
return rule_severity_critical


@fixture
def rule_with_params() -> Type[Rule]:
"""An example rule with additional input params."""

@rule
def rule_with_params(
model: Model, model_name: str = "model1"
) -> RuleViolation | None:
"""Rule with additional input params."""
if model.name != model_name:
return RuleViolation(message=model_name)

return rule_with_params


@fixture
def rule_error() -> Type[Rule]:
"""An example rule which fails to run."""
Expand Down
2 changes: 2 additions & 0 deletions tests/resources/invalid_pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tool.dbt-score]
foo = "bar"
10 changes: 10 additions & 0 deletions tests/resources/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[tool.dbt-score]
rule_namespaces = ["namespace_foo"]
jochemvandooren marked this conversation as resolved.
Show resolved Hide resolved
disabled_rules = ["foo", "bar"]


[tool.dbt-score.rules.foobar]
severity=4

[tool.dbt-score.rules.rule_with_params]
model_name="model2"
Loading