-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement rule evaluation and scoring (#11)
Stitch together the manifest (collection of models) and the rule registry (collection of rules) to evaluate every model with every rule defined. For every model, its score is computed. The aggregation of these scores is also computed in the project score.\ Add a human-readable formatter to display those results upon CLI invocation.
- Loading branch information
1 parent
133ec81
commit 1ced0af
Showing
22 changed files
with
683 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Evaluation | ||
|
||
::: dbt_score.evaluation |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Human-readable formatter | ||
|
||
::: dbt_score.formatters.human_readable_formatter |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# Formatters | ||
|
||
::: dbt_score.formatters |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
# CLI | ||
|
||
::: dbt_score.scoring |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
"""This module is responsible for evaluating rules.""" | ||
|
||
from __future__ import annotations | ||
|
||
from typing import Type | ||
|
||
from dbt_score.formatters import Formatter | ||
from dbt_score.models import ManifestLoader, Model | ||
from dbt_score.rule import Rule, RuleViolation | ||
from dbt_score.rule_registry import RuleRegistry | ||
from dbt_score.scoring import Scorer | ||
|
||
# The results of a given model are stored in a dictionary, mapping rules to either: | ||
# - None if there was no issue | ||
# - A RuleViolation if a linting error was found | ||
# - An Exception if the rule failed to run | ||
ModelResultsType = dict[Type[Rule], None | RuleViolation | Exception] | ||
|
||
|
||
class Evaluation: | ||
"""Evaluate a set of rules on a set of nodes.""" | ||
|
||
def __init__( | ||
self, | ||
rule_registry: RuleRegistry, | ||
manifest_loader: ManifestLoader, | ||
formatter: Formatter, | ||
scorer: Scorer, | ||
) -> None: | ||
"""Create an Evaluation object. | ||
Args: | ||
rule_registry: A rule registry to access rules. | ||
manifest_loader: A manifest loader to access model metadata. | ||
formatter: A formatter to display results. | ||
scorer: A scorer to compute scores. | ||
""" | ||
self._rule_registry = rule_registry | ||
self._manifest_loader = manifest_loader | ||
self._formatter = formatter | ||
self._scorer = scorer | ||
|
||
# For each model, its results | ||
self.results: dict[Model, ModelResultsType] = {} | ||
|
||
# For each model, its computed score | ||
self.scores: dict[Model, float] = {} | ||
|
||
# The aggregated project score | ||
self.project_score: float | ||
|
||
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()] | ||
|
||
for model in self._manifest_loader.models: | ||
self.results[model] = {} | ||
for rule in rules: | ||
try: | ||
result: RuleViolation | None = rule.evaluate(model) | ||
except Exception as e: | ||
self.results[model][rule.__class__] = e | ||
else: | ||
self.results[model][rule.__class__] = result | ||
|
||
self.scores[model] = self._scorer.score_model(self.results[model]) | ||
self._formatter.model_evaluated( | ||
model, self.results[model], self.scores[model] | ||
) | ||
|
||
# Compute score for project | ||
self.project_score = self._scorer.score_aggregate_models( | ||
list(self.scores.values()) | ||
) | ||
self._formatter.project_evaluated(self.project_score) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
"""Formatters are used to output CLI results.""" | ||
|
||
from __future__ import annotations | ||
|
||
import typing | ||
from abc import ABC, abstractmethod | ||
|
||
if typing.TYPE_CHECKING: | ||
from dbt_score.evaluation import ModelResultsType | ||
from dbt_score.models import Model | ||
|
||
|
||
class Formatter(ABC): | ||
"""Abstract class to define a formatter.""" | ||
|
||
@abstractmethod | ||
def model_evaluated( | ||
self, model: Model, results: ModelResultsType, score: float | ||
) -> None: | ||
"""Callback when a model has been evaluated.""" | ||
raise NotImplementedError | ||
|
||
@abstractmethod | ||
def project_evaluated(self, score: float) -> None: | ||
"""Callback when a project has been evaluated.""" | ||
raise NotImplementedError |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
"""Human readable formatter.""" | ||
|
||
|
||
from dbt_score.evaluation import ModelResultsType | ||
from dbt_score.formatters import Formatter | ||
from dbt_score.models import Model | ||
from dbt_score.rule import RuleViolation | ||
|
||
|
||
class HumanReadableFormatter(Formatter): | ||
"""Formatter for human-readable messages in the terminal.""" | ||
|
||
indent = " " | ||
label_ok = "\033[1;32mOK \033[0m" | ||
label_warning = "\033[1;33mWARN\033[0m" | ||
label_error = "\033[1;31mERR \033[0m" | ||
|
||
@staticmethod | ||
def bold(text: str) -> str: | ||
"""Return text in bold.""" | ||
return f"\033[1m{text}\033[0m" | ||
|
||
def model_evaluated( | ||
self, model: Model, results: ModelResultsType, score: float | ||
) -> None: | ||
"""Callback when a model has been evaluated.""" | ||
print(f"Model {self.bold(model.name)}") | ||
for rule, result in results.items(): | ||
if result is None: | ||
print(f"{self.indent}{self.label_ok} {rule.source()}") | ||
elif isinstance(result, RuleViolation): | ||
print( | ||
f"{self.indent}{self.label_warning} " | ||
f"({rule.severity.name.lower()}) {rule.source()}: {result.message}" | ||
) | ||
else: | ||
print(f"{self.indent}{self.label_error} {rule.source()}: {result!s}") | ||
print(f"Score: {self.bold(str(round(score * 10, 1)))}") | ||
print() | ||
|
||
def project_evaluated(self, score: float) -> None: | ||
"""Callback when a project has been evaluated.""" | ||
print(f"Project score: {self.bold(str(round(score * 10, 1)))}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
"""Module computing scores.""" | ||
|
||
from __future__ import annotations | ||
|
||
import typing | ||
|
||
if typing.TYPE_CHECKING: | ||
from dbt_score.evaluation import ModelResultsType | ||
from dbt_score.rule import RuleViolation, Severity | ||
|
||
|
||
class Scorer: | ||
"""Logic for computing scores.""" | ||
|
||
# This magic number comes from rule severity. | ||
# Assuming a rule violation: | ||
# - A low severity yields a score 2/3 | ||
# - A medium severity yields a score 1/3 | ||
# - A high severity yields a score 0/3 | ||
score_cardinality = 3 | ||
|
||
min_score = 0.0 | ||
max_score = 1.0 | ||
|
||
def score_model(self, model_results: ModelResultsType) -> float: | ||
"""Compute the score of a given model.""" | ||
if len(model_results) == 0: | ||
# No rule? No problem | ||
return self.max_score | ||
if any( | ||
rule.severity == Severity.CRITICAL and isinstance(result, RuleViolation) | ||
for rule, result in model_results.items() | ||
): | ||
# If there's a CRITICAL violation, the score is 0 | ||
return self.min_score | ||
else: | ||
# Otherwise, the score is the weighted average (by severity) of the results | ||
return sum( | ||
[ | ||
# The more severe the violation, the more points are lost | ||
self.score_cardinality - rule.severity.value | ||
if isinstance(result, RuleViolation) # Either 0/3, 1/3 or 2/3 | ||
else self.score_cardinality # 3/3 | ||
for rule, result in model_results.items() | ||
] | ||
) / (self.score_cardinality * len(model_results)) | ||
|
||
def score_aggregate_models(self, scores: list[float]) -> float: | ||
"""Compute the score of a list of models.""" | ||
if 0.0 in scores: | ||
# Any model with a CRITICAL violation makes the project score 0 | ||
return self.min_score | ||
if len(scores) == 0: | ||
return self.max_score | ||
return sum(scores) / len(scores) |
Oops, something went wrong.