Skip to content

Commit fde03ee

Browse files
authored
Adding support for exposures (#112)
As discussed in [this issue](#111), adding exposures as a type of evaluable. I believe I've got it plugged into all the right places - tests pass, and I can run rules locally along the lines of the below made-up example: ``` @rule(severity=Severity.CRITICAL) def exposures_must_have_owner(exposure: Exposure) -> RuleViolation | None: """Exposures may not be owned by Data Team""" if exposure.owner["name"] == "Data Team": return RuleViolation( message=f"Exposure {exposure.name} may not have an owner {exposure.owner['name']}" ) return None ``` Let me know if I've missed anywhere obvious?
1 parent 55aa1f6 commit fde03ee

File tree

16 files changed

+385
-20
lines changed

16 files changed

+385
-20
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
- Support linting of exposures (#112)
12+
1113
## [0.12.0] - 2025-05-06
1214

1315
- Add support for linting and scoring dbt seeds (#110)

docs/create_rules.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function is its description. Therefore, it is important to use a
3232
self-explanatory name for the function and document it well.
3333

3434
The type annotation for the rule's argument dictates whether the rule should be
35-
applied to dbt models, sources or snapshots.
35+
applied to dbt models, sources, snapshots, seeds, or exposures.
3636

3737
Here is the same example rule, applied to sources:
3838

@@ -139,8 +139,8 @@ class SkipSchemaY(RuleFilter):
139139
return model.schema.lower() != 'y'
140140
```
141141

142-
Filters also rely on type-annotations to dictate whether they apply to models
143-
sources or snapshots:
142+
Filters also rely on type-annotations to dictate whether they apply to models,
143+
sources, snapshots, seeds, or exposures:
144144

145145
```python
146146
from dbt_score import RuleFilter, rule_filter, Source

docs/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ encourage) good practices. The dbt entities that `dbt-score` is able to lint
1414
- Models
1515
- Sources
1616
- Snapshots
17+
- Exposures
18+
- Seeds
1719

1820
## Example
1921

src/dbt_score/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Init dbt_score package."""
22

3-
from dbt_score.models import Model, Seed, Snapshot, Source
3+
from dbt_score.models import Exposure, Model, Seed, Snapshot, Source
44
from dbt_score.rule import Rule, RuleViolation, Severity, rule
55
from dbt_score.rule_filter import RuleFilter, rule_filter
66

77
__all__ = [
8+
"Exposure",
89
"Model",
910
"Source",
1011
"Snapshot",
@@ -13,6 +14,6 @@
1314
"Rule",
1415
"RuleViolation",
1516
"Severity",
16-
"rule_filter",
1717
"rule",
18+
"rule_filter",
1819
]

src/dbt_score/dbt_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def dbt_ls(select: Iterable[str] | None) -> Iterable[str]:
7575
"model",
7676
"source",
7777
"snapshot",
78+
"exposure",
7879
"seed",
7980
"--output",
8081
"name",

src/dbt_score/evaluation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def evaluate(self) -> None:
6464
self._manifest_loader.models.values(),
6565
self._manifest_loader.sources.values(),
6666
self._manifest_loader.snapshots.values(),
67+
self._manifest_loader.exposures.values(),
6768
self._manifest_loader.seeds.values(),
6869
):
6970
# type inference on elements from `chain` is wonky
@@ -98,6 +99,7 @@ def evaluate(self) -> None:
9899
self._manifest_loader.models
99100
or self._manifest_loader.sources
100101
or self._manifest_loader.snapshots
102+
or self._manifest_loader.exposures
101103
or self._manifest_loader.seeds
102104
):
103105
self._formatter.project_evaluated(self.project_score)

src/dbt_score/formatters/human_readable_formatter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from dbt_score.evaluation import EvaluableResultsType
66
from dbt_score.formatters import Formatter
7-
from dbt_score.models import Evaluable, Model, Seed, Snapshot, Source
7+
from dbt_score.models import Evaluable, Exposure, Model, Seed, Snapshot, Source
88
from dbt_score.rule import RuleViolation
99
from dbt_score.scoring import Score
1010

@@ -37,6 +37,8 @@ def pretty_name(evaluable: Evaluable) -> str:
3737
return evaluable.selector_name
3838
case Snapshot():
3939
return evaluable.name
40+
case Exposure():
41+
return evaluable.name
4042
case Seed():
4143
return evaluable.name
4244
case _:

src/dbt_score/formatters/manifest_formatter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ def project_evaluated(self, score: Score) -> None:
3636
source_manifest = manifest["sources"][evaluable_id]
3737
source_manifest["meta"]["score"] = evaluable_score.value
3838
source_manifest["meta"]["badge"] = evaluable_score.badge
39+
if evaluable_id.startswith("exposure"):
40+
exposure_manifest = manifest["exposures"][evaluable_id]
41+
exposure_manifest["meta"]["score"] = evaluable_score.value
42+
exposure_manifest["meta"]["badge"] = evaluable_score.badge
3943
print(json.dumps(manifest, indent=2))

src/dbt_score/models.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def _get_columns(
156156

157157
# Type annotation for parent references
158158
ParentType = Union["Model", "Source", "Snapshot", "Seed"]
159-
ChildType = Union["Model", "Snapshot"]
159+
ChildType = Union["Model", "Snapshot", "Exposure"]
160160

161161

162162
@dataclass
@@ -499,6 +499,71 @@ def __hash__(self) -> int:
499499
return hash(self.unique_id)
500500

501501

502+
@dataclass
503+
class Exposure:
504+
"""Represents a dbt exposure.
505+
506+
Attributes:
507+
unique_id: The unique id of the exposure (e.g. `exposure.package.exposure1`).
508+
name: The name of the exposure.
509+
description: The description of the exposure.
510+
label: The label of the exposure.
511+
url: The url of the exposure.
512+
maturity: The maturity of the exposure.
513+
original_file_path: The path to the exposure file
514+
(e.g. `models/exposures/exposures.yml`).
515+
type: The type of the exposure, e.g. `application`.
516+
owner: The owner of the exposure,
517+
e.g. `{"name": "owner", "email": "[email protected]"}`.
518+
config: The config of the exposure.
519+
meta: The meta of the exposure.
520+
tags: The list of tags attached to the exposure.
521+
depends_on: The depends_on of the exposure.
522+
parents: The list of models, sources, and snapshot this exposure depends on.
523+
_raw_values: The raw values of the exposure in the manifest.
524+
"""
525+
526+
unique_id: str
527+
name: str
528+
description: str
529+
label: str
530+
url: str
531+
maturity: str
532+
original_file_path: str
533+
type: str
534+
owner: dict[str, Any]
535+
config: dict[str, Any]
536+
meta: dict[str, Any]
537+
tags: list[str]
538+
depends_on: dict[str, list[str]] = field(default_factory=dict)
539+
parents: list[ParentType] = field(default_factory=list)
540+
_raw_values: dict[str, Any] = field(default_factory=dict)
541+
542+
@classmethod
543+
def from_node(cls, node_values: dict[str, Any]) -> "Exposure":
544+
"""Create an exposure object from a node in the manifest."""
545+
return cls(
546+
unique_id=node_values["unique_id"],
547+
name=node_values["name"],
548+
description=node_values["description"],
549+
label=node_values["label"],
550+
url=node_values["url"],
551+
maturity=node_values["maturity"],
552+
original_file_path=node_values["original_file_path"],
553+
type=node_values["type"],
554+
owner=node_values["owner"],
555+
config=node_values["config"],
556+
meta=node_values["meta"],
557+
tags=node_values["tags"],
558+
depends_on=node_values["depends_on"],
559+
_raw_values=node_values,
560+
)
561+
562+
def __hash__(self) -> int:
563+
"""Compute a unique hash for an exposure."""
564+
return hash(self.unique_id)
565+
566+
502567
@dataclass
503568
class Seed(HasColumnsMixin):
504569
"""Represents a dbt seed.
@@ -579,11 +644,11 @@ def __hash__(self) -> int:
579644
return hash(self.unique_id)
580645

581646

582-
Evaluable: TypeAlias = Model | Source | Snapshot | Seed
647+
Evaluable: TypeAlias = Model | Source | Snapshot | Seed | Exposure
583648

584649

585650
class ManifestLoader:
586-
"""Load the models, sources, snapshots, seeds and tests from the manifest."""
651+
"""Load the evaluables from the manifest."""
587652

588653
def __init__(self, file_path: Path, select: Iterable[str] | None = None):
589654
"""Initialize the ManifestLoader.
@@ -604,25 +669,38 @@ def __init__(self, file_path: Path, select: Iterable[str] | None = None):
604669
for source_id, source_values in self.raw_manifest.get("sources", {}).items()
605670
if source_values["package_name"] == self.project_name
606671
}
672+
self.raw_exposures = {
673+
exposure_id: exposure_values
674+
for exposure_id, exposure_values in self.raw_manifest.get(
675+
"exposures", {}
676+
).items()
677+
if exposure_values["package_name"] == self.project_name
678+
}
607679

608680
self.models: dict[str, Model] = {}
609681
self.tests: dict[str, list[dict[str, Any]]] = defaultdict(list)
610682
self.sources: dict[str, Source] = {}
611683
self.snapshots: dict[str, Snapshot] = {}
684+
self.exposures: dict[str, Exposure] = {}
612685
self.seeds: dict[str, Seed] = {}
613686

614687
self._reindex_tests()
615688
self._load_models()
616689
self._load_sources()
617690
self._load_snapshots()
691+
self._load_exposures()
618692
self._load_seeds()
619693
self._populate_relatives()
620694

621695
if select:
622696
self._filter_evaluables(select)
623697

624698
if (
625-
len(self.models) + len(self.sources) + len(self.snapshots) + len(self.seeds)
699+
len(self.models)
700+
+ len(self.sources)
701+
+ len(self.snapshots)
702+
+ len(self.seeds)
703+
+ len(self.exposures)
626704
) == 0:
627705
logger.warning("Nothing to evaluate!")
628706

@@ -647,6 +725,13 @@ def _load_snapshots(self) -> None:
647725
snapshot = Snapshot.from_node(node_values, self.tests.get(node_id, []))
648726
self.snapshots[node_id] = snapshot
649727

728+
def _load_exposures(self) -> None:
729+
"""Load the exposures from the manifest."""
730+
for node_id, node_values in self.raw_exposures.items():
731+
if node_values.get("resource_type") == "exposure":
732+
exposure = Exposure.from_node(node_values)
733+
self.exposures[node_id] = exposure
734+
650735
def _load_seeds(self) -> None:
651736
"""Load the seeds from the manifest."""
652737
for node_id, node_values in self.raw_nodes.items():
@@ -672,7 +757,11 @@ def _reindex_tests(self) -> None:
672757

673758
def _populate_relatives(self) -> None:
674759
"""Populate `parents` and `children` for all evaluables."""
675-
for node in list(self.models.values()) + list(self.snapshots.values()):
760+
for node in (
761+
list(self.models.values())
762+
+ list(self.snapshots.values())
763+
+ list(self.exposures.values())
764+
):
676765
for parent_id in node.depends_on.get("nodes", []):
677766
if parent_id in self.models:
678767
node.parents.append(self.models[parent_id])
@@ -704,4 +793,5 @@ def _filter_evaluables(self, select: Iterable[str]) -> None:
704793
k: s for k, s in self.sources.items() if s.selector_name in selected
705794
}
706795
self.snapshots = {k: s for k, s in self.snapshots.items() if s.name in selected}
796+
self.exposures = {k: e for k, e in self.exposures.items() if e.name in selected}
707797
self.seeds = {k: s for k, s in self.seeds.items() if s.name in selected}

src/dbt_score/rule.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
overload,
1515
)
1616

17-
from dbt_score.models import Evaluable, Model, Seed, Snapshot, Source
17+
from dbt_score.models import Evaluable, Exposure, Model, Seed, Snapshot, Source
1818
from dbt_score.more_itertools import first_true
1919
from dbt_score.rule_filter import RuleFilter
2020

@@ -66,11 +66,13 @@ class RuleViolation:
6666
ModelRuleEvaluationType: TypeAlias = Callable[[Model], RuleViolation | None]
6767
SourceRuleEvaluationType: TypeAlias = Callable[[Source], RuleViolation | None]
6868
SnapshotRuleEvaluationType: TypeAlias = Callable[[Snapshot], RuleViolation | None]
69+
ExposureRuleEvaluationType: TypeAlias = Callable[[Exposure], RuleViolation | None]
6970
SeedRuleEvaluationType: TypeAlias = Callable[[Seed], RuleViolation | None]
7071
RuleEvaluationType: TypeAlias = (
7172
ModelRuleEvaluationType
7273
| SourceRuleEvaluationType
7374
| SnapshotRuleEvaluationType
75+
| ExposureRuleEvaluationType
7476
| SeedRuleEvaluationType
7577
)
7678

@@ -210,6 +212,11 @@ def rule(__func: SnapshotRuleEvaluationType) -> Type[Rule]:
210212
...
211213

212214

215+
@overload
216+
def rule(__func: ExposureRuleEvaluationType) -> Type[Rule]:
217+
...
218+
219+
213220
@overload
214221
def rule(__func: SeedRuleEvaluationType) -> Type[Rule]:
215222
...

0 commit comments

Comments
 (0)