altimate-datapilot-cli 0.0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- altimate_datapilot_cli-0.0.8.dist-info/AUTHORS.rst +5 -0
- altimate_datapilot_cli-0.0.8.dist-info/LICENSE +9 -0
- altimate_datapilot_cli-0.0.8.dist-info/METADATA +102 -0
- altimate_datapilot_cli-0.0.8.dist-info/RECORD +139 -0
- altimate_datapilot_cli-0.0.8.dist-info/WHEEL +5 -0
- altimate_datapilot_cli-0.0.8.dist-info/entry_points.txt +4 -0
- altimate_datapilot_cli-0.0.8.dist-info/top_level.txt +1 -0
- datapilot/__init__.py +1 -0
- datapilot/__main__.py +14 -0
- datapilot/cli/__init__.py +0 -0
- datapilot/cli/main.py +11 -0
- datapilot/clients/__init__.py +0 -0
- datapilot/clients/altimate/__init__.py +0 -0
- datapilot/clients/altimate/client.py +85 -0
- datapilot/clients/altimate/utils.py +75 -0
- datapilot/config/__init__.py +0 -0
- datapilot/config/config.py +16 -0
- datapilot/config/utils.py +32 -0
- datapilot/core/__init__.py +0 -0
- datapilot/core/insights/__init__.py +2 -0
- datapilot/core/insights/base/__init__.py +0 -0
- datapilot/core/insights/base/insight.py +34 -0
- datapilot/core/insights/report.py +16 -0
- datapilot/core/insights/schema.py +24 -0
- datapilot/core/insights/sql/__init__.py +0 -0
- datapilot/core/insights/sql/base/__init__.py +0 -0
- datapilot/core/insights/sql/base/insight.py +18 -0
- datapilot/core/insights/sql/runtime/__init__.py +0 -0
- datapilot/core/insights/sql/static/__init__.py +0 -0
- datapilot/core/insights/utils.py +20 -0
- datapilot/core/platforms/__init__.py +0 -0
- datapilot/core/platforms/dbt/__init__.py +0 -0
- datapilot/core/platforms/dbt/cli/__init__.py +0 -0
- datapilot/core/platforms/dbt/cli/cli.py +112 -0
- datapilot/core/platforms/dbt/constants.py +34 -0
- datapilot/core/platforms/dbt/exceptions.py +6 -0
- datapilot/core/platforms/dbt/executor.py +157 -0
- datapilot/core/platforms/dbt/factory.py +22 -0
- datapilot/core/platforms/dbt/formatting.py +45 -0
- datapilot/core/platforms/dbt/hooks/__init__.py +0 -0
- datapilot/core/platforms/dbt/hooks/executor_hook.py +86 -0
- datapilot/core/platforms/dbt/insights/__init__.py +115 -0
- datapilot/core/platforms/dbt/insights/base.py +133 -0
- datapilot/core/platforms/dbt/insights/checks/__init__.py +0 -0
- datapilot/core/platforms/dbt/insights/checks/base.py +26 -0
- datapilot/core/platforms/dbt/insights/checks/check_column_desc_are_same.py +105 -0
- datapilot/core/platforms/dbt/insights/checks/check_column_name_contract.py +154 -0
- datapilot/core/platforms/dbt/insights/checks/check_macro_args_have_desc.py +75 -0
- datapilot/core/platforms/dbt/insights/checks/check_macro_has_desc.py +63 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_all_columns.py +96 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_labels_keys.py +112 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_meta_keys.py +108 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_properties_file.py +64 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_tests_by_group.py +118 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_tests_by_name.py +114 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_has_tests_by_type.py +119 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_materialization_by_childs.py +129 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_name_contract.py +132 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_parents_and_childs.py +135 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_parents_database.py +109 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_parents_schema.py +109 -0
- datapilot/core/platforms/dbt/insights/checks/check_model_tags.py +87 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_childs.py +97 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_columns_have_desc.py +96 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_all_columns.py +103 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_freshness.py +94 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_labels_keys.py +110 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_loader.py +62 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_meta_keys.py +117 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_tests.py +82 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_tests_by_group.py +117 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_tests_by_name.py +113 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_has_tests_by_type.py +119 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_table_has_description.py +62 -0
- datapilot/core/platforms/dbt/insights/checks/check_source_tags.py +76 -0
- datapilot/core/platforms/dbt/insights/dbt_test/__init__.py +0 -0
- datapilot/core/platforms/dbt/insights/dbt_test/base.py +23 -0
- datapilot/core/platforms/dbt/insights/dbt_test/missing_primary_key_tests.py +130 -0
- datapilot/core/platforms/dbt/insights/dbt_test/test_coverage.py +118 -0
- datapilot/core/platforms/dbt/insights/governance/__init__.py +0 -0
- datapilot/core/platforms/dbt/insights/governance/base.py +23 -0
- datapilot/core/platforms/dbt/insights/governance/documentation_on_stale_columns.py +130 -0
- datapilot/core/platforms/dbt/insights/governance/exposures_dependent_on_private_models.py +90 -0
- datapilot/core/platforms/dbt/insights/governance/public_models_without_contracts.py +89 -0
- datapilot/core/platforms/dbt/insights/governance/undocumented_columns.py +148 -0
- datapilot/core/platforms/dbt/insights/governance/undocumented_public_models.py +110 -0
- datapilot/core/platforms/dbt/insights/modelling/README.md +15 -0
- datapilot/core/platforms/dbt/insights/modelling/__init__.py +0 -0
- datapilot/core/platforms/dbt/insights/modelling/base.py +31 -0
- datapilot/core/platforms/dbt/insights/modelling/direct_join_to_source.py +125 -0
- datapilot/core/platforms/dbt/insights/modelling/downstream_models_dependent_on_source.py +113 -0
- datapilot/core/platforms/dbt/insights/modelling/duplicate_sources.py +85 -0
- datapilot/core/platforms/dbt/insights/modelling/hard_coded_references.py +80 -0
- datapilot/core/platforms/dbt/insights/modelling/joining_of_upstream_concepts.py +79 -0
- datapilot/core/platforms/dbt/insights/modelling/model_fanout.py +126 -0
- datapilot/core/platforms/dbt/insights/modelling/multiple_sources_joined.py +83 -0
- datapilot/core/platforms/dbt/insights/modelling/root_model.py +82 -0
- datapilot/core/platforms/dbt/insights/modelling/source_fanout.py +102 -0
- datapilot/core/platforms/dbt/insights/modelling/staging_model_dependent_on_downstream_models.py +103 -0
- datapilot/core/platforms/dbt/insights/modelling/staging_model_dependent_on_staging_models.py +89 -0
- datapilot/core/platforms/dbt/insights/modelling/unused_sources.py +59 -0
- datapilot/core/platforms/dbt/insights/performance/__init__.py +0 -0
- datapilot/core/platforms/dbt/insights/performance/base.py +26 -0
- datapilot/core/platforms/dbt/insights/performance/chain_view_linking.py +92 -0
- datapilot/core/platforms/dbt/insights/performance/exposure_parent_materializations.py +104 -0
- datapilot/core/platforms/dbt/insights/schema.py +72 -0
- datapilot/core/platforms/dbt/insights/structure/__init__.py +0 -0
- datapilot/core/platforms/dbt/insights/structure/base.py +33 -0
- datapilot/core/platforms/dbt/insights/structure/model_directories_structure.py +92 -0
- datapilot/core/platforms/dbt/insights/structure/model_naming_conventions.py +97 -0
- datapilot/core/platforms/dbt/insights/structure/source_directories_structure.py +80 -0
- datapilot/core/platforms/dbt/insights/structure/test_directory_structure.py +74 -0
- datapilot/core/platforms/dbt/insights/utils.py +9 -0
- datapilot/core/platforms/dbt/schemas/__init__.py +0 -0
- datapilot/core/platforms/dbt/schemas/catalog.py +73 -0
- datapilot/core/platforms/dbt/schemas/manifest.py +462 -0
- datapilot/core/platforms/dbt/utils.py +525 -0
- datapilot/core/platforms/dbt/wrappers/__init__.py +0 -0
- datapilot/core/platforms/dbt/wrappers/catalog/__init__.py +0 -0
- datapilot/core/platforms/dbt/wrappers/catalog/v1/__init__.py +0 -0
- datapilot/core/platforms/dbt/wrappers/catalog/v1/wrapper.py +18 -0
- datapilot/core/platforms/dbt/wrappers/catalog/wrapper.py +9 -0
- datapilot/core/platforms/dbt/wrappers/manifest/__init__.py +0 -0
- datapilot/core/platforms/dbt/wrappers/manifest/v11/__init__.py +0 -0
- datapilot/core/platforms/dbt/wrappers/manifest/v11/schemas.py +47 -0
- datapilot/core/platforms/dbt/wrappers/manifest/v11/wrapper.py +396 -0
- datapilot/core/platforms/dbt/wrappers/manifest/wrapper.py +35 -0
- datapilot/core/platforms/dbt/wrappers/run_results/__init__.py +0 -0
- datapilot/core/platforms/dbt/wrappers/run_results/run_results.py +39 -0
- datapilot/exceptions/__init__.py +0 -0
- datapilot/exceptions/exceptions.py +10 -0
- datapilot/schemas/__init__.py +0 -0
- datapilot/schemas/constants.py +5 -0
- datapilot/schemas/nodes.py +19 -0
- datapilot/schemas/sql.py +10 -0
- datapilot/utils/__init__.py +0 -0
- datapilot/utils/formatting/__init__.py +0 -0
- datapilot/utils/formatting/utils.py +59 -0
- datapilot/utils/utils.py +317 -0
@@ -0,0 +1,76 @@
|
|
1
|
+
from typing import List
|
2
|
+
|
3
|
+
from datapilot.core.insights.utils import get_severity
|
4
|
+
from datapilot.core.platforms.dbt.insights.checks.base import ChecksInsight
|
5
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTInsightResult
|
6
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTModelInsightResponse
|
7
|
+
from datapilot.core.platforms.dbt.schemas.manifest import AltimateResourceType
|
8
|
+
|
9
|
+
|
10
|
+
class CheckSourceTags(ChecksInsight):
|
11
|
+
NAME = "Source has tags"
|
12
|
+
ALIAS = "check_source_tags"
|
13
|
+
DESCRIPTION = "The source has only valid tags from the provided list."
|
14
|
+
REASON_TO_FLAG = "The source has tags that are not in the valid tags list"
|
15
|
+
TESTS_STR = "tags"
|
16
|
+
|
17
|
+
def _build_failure_result(
|
18
|
+
self,
|
19
|
+
node_id: str,
|
20
|
+
tags: List[str],
|
21
|
+
) -> DBTInsightResult:
|
22
|
+
"""
|
23
|
+
Build failure result for the insight if a source's tags are not in the provided tag list.
|
24
|
+
"""
|
25
|
+
|
26
|
+
failure_message = f"The source:{node_id}'s tags: {tags} are not in the provided tag list: {self.tag_list}\n"
|
27
|
+
|
28
|
+
recommendation = "Update the source's tags to adhere to the provided tag list."
|
29
|
+
|
30
|
+
return DBTInsightResult(
|
31
|
+
type=self.TYPE,
|
32
|
+
name=self.NAME,
|
33
|
+
message=failure_message,
|
34
|
+
recommendation=recommendation,
|
35
|
+
reason_to_flag=self.REASON_TO_FLAG,
|
36
|
+
metadata={"tags": tags, "source_id": node_id},
|
37
|
+
)
|
38
|
+
|
39
|
+
def generate(self, *args, **kwargs) -> List[DBTModelInsightResponse]:
|
40
|
+
"""
|
41
|
+
Generate a list of InsightResponse objects for each source in the DBT project,
|
42
|
+
Ensures that the source has only valid tags from the provided list.
|
43
|
+
The provided tag list is in the configuration file.
|
44
|
+
"""
|
45
|
+
insights = []
|
46
|
+
self.tag_list = self.get_check_config(self.TESTS_STR)
|
47
|
+
for node_id, node in self.sources.items():
|
48
|
+
if self.should_skip_model(node_id):
|
49
|
+
self.logger.debug(f"Skipping source {node_id} as it is not enabled for selected models")
|
50
|
+
continue
|
51
|
+
if node.resource_type == AltimateResourceType.source:
|
52
|
+
tag_list = self.valid_tag(node.tags)
|
53
|
+
if tag_list:
|
54
|
+
insights.append(
|
55
|
+
DBTModelInsightResponse(
|
56
|
+
unique_id=node_id,
|
57
|
+
package_name=node.package_name,
|
58
|
+
original_file_path=node.original_file_path,
|
59
|
+
path=node.original_file_path,
|
60
|
+
insight=self._build_failure_result(node_id, tag_list),
|
61
|
+
severity=get_severity(self.config, self.ALIAS, self.DEFAULT_SEVERITY),
|
62
|
+
)
|
63
|
+
)
|
64
|
+
return insights
|
65
|
+
|
66
|
+
def valid_tag(self, tags: List[str]) -> List[str]:
|
67
|
+
"""
|
68
|
+
Check if the tags of the source are in the provided tag list.
|
69
|
+
"""
|
70
|
+
if not self.tag_list:
|
71
|
+
return True
|
72
|
+
tag_list = []
|
73
|
+
for tag in tags:
|
74
|
+
if tag not in self.tag_list:
|
75
|
+
tag_list.append(tag)
|
76
|
+
return tag_list
|
File without changes
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from abc import abstractmethod
|
2
|
+
from typing import Tuple
|
3
|
+
|
4
|
+
from datapilot.core.platforms.dbt.insights.base import DBTInsight
|
5
|
+
|
6
|
+
|
7
|
+
class DBTTestInsight(DBTInsight):
|
8
|
+
TYPE = "Test"
|
9
|
+
|
10
|
+
@abstractmethod
|
11
|
+
def generate(self, *args, **kwargs) -> dict:
|
12
|
+
pass
|
13
|
+
|
14
|
+
@classmethod
|
15
|
+
def has_all_required_data(cls, has_manifest: bool, **kwargs) -> Tuple[bool, str]:
|
16
|
+
"""
|
17
|
+
Check if all required data is available for the insight to run.
|
18
|
+
:param has_manifest: A boolean indicating if manifest is available.
|
19
|
+
:return: A boolean indicating if all required data is available.
|
20
|
+
"""
|
21
|
+
if not has_manifest:
|
22
|
+
return False, "manifest is required for insight to run."
|
23
|
+
return True, ""
|
@@ -0,0 +1,130 @@
|
|
1
|
+
from typing import Dict
|
2
|
+
from typing import List
|
3
|
+
from typing import Optional
|
4
|
+
|
5
|
+
from datapilot.core.insights.utils import get_severity
|
6
|
+
from datapilot.core.platforms.dbt.constants import GENERIC
|
7
|
+
from datapilot.core.platforms.dbt.insights.dbt_test.base import DBTTestInsight
|
8
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTInsightResult
|
9
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTModelInsightResponse
|
10
|
+
from datapilot.core.platforms.dbt.schemas.manifest import AltimateResourceType
|
11
|
+
|
12
|
+
|
13
|
+
class MissingPrimaryKeyTests(DBTTestInsight):
|
14
|
+
"""
|
15
|
+
This class identifies DBT models that are missing primary key tests.
|
16
|
+
Primary key tests are essential for ensuring data integrity in DBT models.
|
17
|
+
This class generates insights for each model that lacks proper primary key tests.
|
18
|
+
"""
|
19
|
+
|
20
|
+
_ALL_TESTS_KEY = "_all_tests"
|
21
|
+
NOT_NULL = "not_null"
|
22
|
+
UNIQUE = "unique"
|
23
|
+
UNIQUE_COMBINATION_OF_COLUMNS = "unique_combination_of_columns"
|
24
|
+
NAME = "Missing primary key tests"
|
25
|
+
ALIAS = "missing_primary_key_tests"
|
26
|
+
DESCRIPTION = "Checks if the model has a primary key test. "
|
27
|
+
REASON_TO_FLAG = (
|
28
|
+
"dbt tests play a crucial role in asserting data correctness. The absence of primary key tests can increase "
|
29
|
+
"the risk of data integrity issues, affecting project reliability and scalability."
|
30
|
+
)
|
31
|
+
FAILURE_MESSAGE = (
|
32
|
+
"dbt model `{model_unique_id}` does not have a primary key test. " "This omission may lead to data integrity challenges."
|
33
|
+
)
|
34
|
+
RECOMMENDATION = (
|
35
|
+
"To address this, apply a uniqueness test and a not-null test to the column representing the model's grain. "
|
36
|
+
"For models with unique combinations of columns, consider adding a surrogate key and "
|
37
|
+
"applying these tests to that column. You can refer to dbt_utils for a surrogate_key macro"
|
38
|
+
" and unique_combination_of_columns test."
|
39
|
+
)
|
40
|
+
|
41
|
+
def _build_failure_result(self, model_unique_id: str) -> DBTInsightResult:
|
42
|
+
"""
|
43
|
+
Constructs a failure result for a given model.
|
44
|
+
|
45
|
+
:param model_unique_id: Unique ID of the model being evaluated.
|
46
|
+
:return: An instance of DBTInsightResult containing failure details.
|
47
|
+
"""
|
48
|
+
self.logger.debug(f"Building failure result for model {model_unique_id}")
|
49
|
+
failure = self.FAILURE_MESSAGE.format(model_unique_id=model_unique_id)
|
50
|
+
return DBTInsightResult(
|
51
|
+
type=self.TYPE,
|
52
|
+
name=self.NAME,
|
53
|
+
message=failure,
|
54
|
+
recommendation=self.RECOMMENDATION,
|
55
|
+
reason_to_flag=self.REASON_TO_FLAG,
|
56
|
+
metadata={"model_unique_id": model_unique_id},
|
57
|
+
)
|
58
|
+
|
59
|
+
def _has_primary_key_test(self, column_tests: Optional[Dict[str, List]]) -> bool:
|
60
|
+
"""
|
61
|
+
Checks if the given column tests include a primary key test.
|
62
|
+
|
63
|
+
:param column_tests: Dictionary of column tests.
|
64
|
+
:return: True if primary key test exists, False otherwise.
|
65
|
+
"""
|
66
|
+
self.logger.debug("Checking for primary key tests")
|
67
|
+
if not column_tests:
|
68
|
+
return False
|
69
|
+
|
70
|
+
if self.UNIQUE_COMBINATION_OF_COLUMNS in column_tests.get(self._ALL_TESTS_KEY, []):
|
71
|
+
return True
|
72
|
+
|
73
|
+
column_tests.pop(self._ALL_TESTS_KEY, None)
|
74
|
+
|
75
|
+
for tests in column_tests.values():
|
76
|
+
if self.NOT_NULL in tests and self.UNIQUE in tests:
|
77
|
+
return True
|
78
|
+
|
79
|
+
return False
|
80
|
+
|
81
|
+
def _get_nodes_which_need_tests(self) -> List[str]:
|
82
|
+
return [
|
83
|
+
node_id
|
84
|
+
for node_id, node in self.nodes.items()
|
85
|
+
if self.check_part_of_project(node.package_name) and node.resource_type == AltimateResourceType.model
|
86
|
+
]
|
87
|
+
|
88
|
+
def _get_nodes_with_tests(self, tests) -> Dict[str, Dict[str, List]]:
|
89
|
+
nodes_with_tests = {}
|
90
|
+
for test in tests.values():
|
91
|
+
for node_id in test.depends_on.nodes or []:
|
92
|
+
column = test.test_metadata.kwargs.get("column_name")
|
93
|
+
key = column if column else self._ALL_TESTS_KEY
|
94
|
+
nodes_with_tests.setdefault(node_id, {}).setdefault(key, []).append(test.test_metadata.name)
|
95
|
+
return nodes_with_tests
|
96
|
+
|
97
|
+
def generate(self, *args, **kwargs) -> List[DBTModelInsightResponse]:
|
98
|
+
"""
|
99
|
+
Generates insights for each DBT model in the project.
|
100
|
+
|
101
|
+
:return: A list of DBTModelInsightResponse objects with insights for each model.
|
102
|
+
"""
|
103
|
+
self.logger.debug("Generating insights for DBT models")
|
104
|
+
tests = self.manifest.get_tests(GENERIC)
|
105
|
+
|
106
|
+
nodes_which_need_tests = self._get_nodes_which_need_tests()
|
107
|
+
|
108
|
+
nodes_which_have_test = self._get_nodes_with_tests(tests)
|
109
|
+
|
110
|
+
insights = []
|
111
|
+
for node_id in nodes_which_need_tests:
|
112
|
+
if self.should_skip_model(node_id):
|
113
|
+
self.logger.debug(f"Skipping model {node_id} as it is not enabled for selected models")
|
114
|
+
continue
|
115
|
+
if not self._has_primary_key_test(nodes_which_have_test.get(node_id)):
|
116
|
+
node = self.get_node(node_id)
|
117
|
+
self.logger.debug(f"Adding insight for model {node_id}")
|
118
|
+
insights.append(
|
119
|
+
DBTModelInsightResponse(
|
120
|
+
unique_id=node_id,
|
121
|
+
package_name=node.package_name,
|
122
|
+
path=node.original_file_path,
|
123
|
+
original_file_path=node.original_file_path,
|
124
|
+
insight=self._build_failure_result(node_id),
|
125
|
+
severity=get_severity(self.config, self.ALIAS, self.DEFAULT_SEVERITY),
|
126
|
+
)
|
127
|
+
)
|
128
|
+
|
129
|
+
self.logger.debug("Completed generating insights")
|
130
|
+
return insights
|
@@ -0,0 +1,118 @@
|
|
1
|
+
from typing import List
|
2
|
+
|
3
|
+
from datapilot.core.insights.utils import get_severity
|
4
|
+
from datapilot.core.platforms.dbt.constants import SINGULAR
|
5
|
+
from datapilot.core.platforms.dbt.insights.dbt_test.base import DBTTestInsight
|
6
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTInsightResult
|
7
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTProjectInsightResponse
|
8
|
+
from datapilot.core.platforms.dbt.schemas.manifest import AltimateResourceType
|
9
|
+
|
10
|
+
|
11
|
+
class DBTTestCoverage(DBTTestInsight):
|
12
|
+
"""
|
13
|
+
This class identifies DBT models with test coverage below a specified threshold.
|
14
|
+
It aims to ensure that a minimum percentage of tests are applied to each model to maintain data integrity.
|
15
|
+
"""
|
16
|
+
|
17
|
+
NAME = "Low test coverage in dbt models"
|
18
|
+
ALIAS = "dbt_low_test_coverage"
|
19
|
+
DESCRIPTION = "Checks if the project test coverage is below the minimum threshold. "
|
20
|
+
REASON_TO_FLAG = (
|
21
|
+
"dbt models should have a minimum test coverage percentage to ensure the reliability and accuracy "
|
22
|
+
"of data transformations. Low test coverage can lead to data quality issues."
|
23
|
+
)
|
24
|
+
FAILURE_MESSAGE = (
|
25
|
+
"The test coverage {coverage_percent}% is below the minimum threshold"
|
26
|
+
" of {min_coverage_percent}%. Insufficient test coverage can impact data integrity and transformation accuracy."
|
27
|
+
)
|
28
|
+
RECOMMENDATION = (
|
29
|
+
"To address this issue, review and increase the number and variety of tests applied to your model to "
|
30
|
+
"improve its test coverage. Consider adding different types of tests such as uniqueness, not_null, "
|
31
|
+
"and referential integrity tests to ensure data quality and accuracy."
|
32
|
+
)
|
33
|
+
MIN_COVERAGE_PERCENT = 100
|
34
|
+
MIN_COVERAGE_PERCENT_STR = "min_test_coverage_percent"
|
35
|
+
|
36
|
+
def _build_failure_result(self, coverage: float, min_coverage=MIN_COVERAGE_PERCENT) -> DBTInsightResult:
|
37
|
+
"""
|
38
|
+
Constructs a failure result for a given model with low test coverage.
|
39
|
+
:param coverage: The calculated test coverage percentage for the model.
|
40
|
+
:param min_coverage: The minimum required test coverage percentage.
|
41
|
+
:return: An instance of DBTInsightResult containing failure details.
|
42
|
+
"""
|
43
|
+
self.logger.debug(f"CALCULATED COVERAGE: {coverage}")
|
44
|
+
failure = self.FAILURE_MESSAGE.format(min_coverage_percent=min_coverage, coverage_percent=coverage)
|
45
|
+
return DBTInsightResult(
|
46
|
+
type=self.TYPE,
|
47
|
+
name=self.NAME,
|
48
|
+
message=failure,
|
49
|
+
recommendation=self.RECOMMENDATION,
|
50
|
+
reason_to_flag=self.REASON_TO_FLAG,
|
51
|
+
metadata={"min_coverage_percent": min_coverage, "coverage": coverage},
|
52
|
+
)
|
53
|
+
|
54
|
+
def _calculate_coverage(self) -> float:
|
55
|
+
"""
|
56
|
+
:return: Test coverage percentage for the model.
|
57
|
+
"""
|
58
|
+
num_models = len(
|
59
|
+
[
|
60
|
+
node.unique_id
|
61
|
+
for node in self.nodes.values()
|
62
|
+
if node.resource_type == AltimateResourceType.model and self.check_part_of_project(node.package_name)
|
63
|
+
]
|
64
|
+
)
|
65
|
+
|
66
|
+
models_with_tests = set()
|
67
|
+
for test in self.tests.values():
|
68
|
+
if test.test_type == SINGULAR:
|
69
|
+
return 100
|
70
|
+
if test.package_name == self.project_name:
|
71
|
+
models_with_tests = models_with_tests.union(set(test.depends_on.nodes) if test.depends_on else set())
|
72
|
+
|
73
|
+
return round((len(models_with_tests) / num_models) * 100) if num_models > 0 else 100
|
74
|
+
|
75
|
+
def generate(self, *args, **kwargs) -> List[DBTProjectInsightResponse]:
|
76
|
+
"""
|
77
|
+
Generates insights for each DBT model in the project, focusing on test coverage.
|
78
|
+
|
79
|
+
:return: A list of DBTModelInsightResponse objects with insights for each model.
|
80
|
+
"""
|
81
|
+
self.logger.debug("Generating test coverage insights for DBT models")
|
82
|
+
|
83
|
+
min_coverage = self.get_check_config(self.MIN_COVERAGE_PERCENT_STR) or self.MIN_COVERAGE_PERCENT
|
84
|
+
coverage = self._calculate_coverage()
|
85
|
+
|
86
|
+
insights = []
|
87
|
+
if coverage < min_coverage:
|
88
|
+
insights.append(
|
89
|
+
DBTProjectInsightResponse(
|
90
|
+
package_name=self.project_name,
|
91
|
+
insights=[self._build_failure_result(coverage, min_coverage)],
|
92
|
+
severity=get_severity(self.config, self.ALIAS, self.DEFAULT_SEVERITY),
|
93
|
+
)
|
94
|
+
)
|
95
|
+
|
96
|
+
self.logger.debug("Completed generating test coverage insights")
|
97
|
+
return insights
|
98
|
+
|
99
|
+
@classmethod
|
100
|
+
def get_config_schema(cls):
|
101
|
+
"""
|
102
|
+
:return: The configuration schema for the test coverage insight.
|
103
|
+
"""
|
104
|
+
config_schema = super().get_config_schema()
|
105
|
+
|
106
|
+
config_schema["config"] = {
|
107
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
108
|
+
"type": "object",
|
109
|
+
"properties": {
|
110
|
+
cls.MIN_COVERAGE_PERCENT_STR: {
|
111
|
+
"type": "integer",
|
112
|
+
"description": "The minimum test coverage percentage required for the models in the project",
|
113
|
+
"default": cls.MIN_COVERAGE_PERCENT,
|
114
|
+
},
|
115
|
+
},
|
116
|
+
"required": [cls.MIN_COVERAGE_PERCENT_STR],
|
117
|
+
}
|
118
|
+
return config_schema
|
File without changes
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from abc import abstractmethod
|
2
|
+
from typing import Tuple
|
3
|
+
|
4
|
+
from datapilot.core.platforms.dbt.insights.base import DBTInsight
|
5
|
+
|
6
|
+
|
7
|
+
class DBTGovernanceInsight(DBTInsight):
|
8
|
+
TYPE = "governance"
|
9
|
+
|
10
|
+
@abstractmethod
|
11
|
+
def generate(self, *args, **kwargs) -> dict:
|
12
|
+
pass
|
13
|
+
|
14
|
+
@classmethod
|
15
|
+
def has_all_required_data(cls, has_manifest: bool, **kwargs) -> Tuple[bool, str]:
|
16
|
+
"""
|
17
|
+
Check if all required data is available for the insight to run.
|
18
|
+
:param has_manifest: A boolean indicating if manifest is available.
|
19
|
+
:return: A boolean indicating if all required data is available.
|
20
|
+
"""
|
21
|
+
if not has_manifest:
|
22
|
+
return False, "manifest is required for insight to run."
|
23
|
+
return True, ""
|
@@ -0,0 +1,130 @@
|
|
1
|
+
from typing import ClassVar
|
2
|
+
from typing import List
|
3
|
+
from typing import Tuple
|
4
|
+
|
5
|
+
from datapilot.core.insights.utils import get_severity
|
6
|
+
from datapilot.core.platforms.dbt.insights.governance.base import DBTGovernanceInsight
|
7
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTInsightResult
|
8
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTModelInsightResponse
|
9
|
+
from datapilot.core.platforms.dbt.schemas.manifest import AltimateResourceType
|
10
|
+
from datapilot.core.platforms.dbt.wrappers.catalog.wrapper import BaseCatalogWrapper
|
11
|
+
from datapilot.utils.formatting.utils import numbered_list
|
12
|
+
|
13
|
+
|
14
|
+
class DBTDocumentationStaleColumns(DBTGovernanceInsight):
|
15
|
+
"""
|
16
|
+
DBTDocumentationStaleColumns identifies columns that have been documented but are no longer present in the model.
|
17
|
+
"""
|
18
|
+
|
19
|
+
NAME = "Documentation of stale columns"
|
20
|
+
ALIAS = "documentation_on_stale_columns"
|
21
|
+
DESCRIPTION = (
|
22
|
+
"Identify columns that have been documented but are no longer present in the model. "
|
23
|
+
"This insight helps in maintaining accurate and up-to-date documentation."
|
24
|
+
)
|
25
|
+
REASON_TO_FLAG = (
|
26
|
+
"A column has been documented but is no longer present in the model/database. "
|
27
|
+
"This discrepancy can cause confusion and mislead users of the dbt project."
|
28
|
+
)
|
29
|
+
FAILURE_MESSAGE = (
|
30
|
+
"The following documented columns are no longer present in the model `{model_unique_id}`:\n{stale_columns}. "
|
31
|
+
"This inconsistency can lead to confusion regarding the model's current structure."
|
32
|
+
)
|
33
|
+
RECOMMENDATION = (
|
34
|
+
"Review and update the documentation for model `{model_unique_id}`. Remove documentation entries for columns "
|
35
|
+
"that are no longer present to maintain clarity and accuracy in the project documentation."
|
36
|
+
)
|
37
|
+
FILES_REQUIRED: ClassVar = ["Manifest", "Catalog"]
|
38
|
+
|
39
|
+
def __init__(self, catalog_wrapper: BaseCatalogWrapper, *args, **kwargs):
|
40
|
+
self.catalog = catalog_wrapper
|
41
|
+
super().__init__(*args, **kwargs)
|
42
|
+
|
43
|
+
def _build_failure_result(self, model_unique_id: str, columns: List[str]) -> DBTInsightResult:
|
44
|
+
"""
|
45
|
+
Build failure result for the insight if a model is a root model with 0 direct parents.
|
46
|
+
|
47
|
+
:param model_unique_id: Unique ID of the current model being evaluated.
|
48
|
+
:param columns: List of columns that are documented but no longer present in the model.
|
49
|
+
:return: An instance of InsightResult containing failure message and recommendation.
|
50
|
+
"""
|
51
|
+
self.logger.debug(f"Building failure result for model {model_unique_id} with stale columns {columns}")
|
52
|
+
|
53
|
+
failure = self.FAILURE_MESSAGE.format(
|
54
|
+
stale_columns=numbered_list(columns),
|
55
|
+
model_unique_id=model_unique_id,
|
56
|
+
)
|
57
|
+
|
58
|
+
recommendation = self.RECOMMENDATION.format(model_unique_id=model_unique_id)
|
59
|
+
|
60
|
+
return DBTInsightResult(
|
61
|
+
type=self.TYPE,
|
62
|
+
name=self.NAME,
|
63
|
+
message=failure,
|
64
|
+
recommendation=recommendation,
|
65
|
+
reason_to_flag=self.REASON_TO_FLAG,
|
66
|
+
metadata={"stale_columns": columns, "model_unique_id": model_unique_id},
|
67
|
+
)
|
68
|
+
|
69
|
+
def _get_columns_documented(self, node_id) -> List[str]:
|
70
|
+
"""
|
71
|
+
Get the list of columns that are documented for a given node.
|
72
|
+
:param node_id: The unique ID of the node.
|
73
|
+
:return: A list of column names.
|
74
|
+
"""
|
75
|
+
columns = []
|
76
|
+
for column_name, column_node in self.get_node(node_id).columns.items():
|
77
|
+
if column_node.description:
|
78
|
+
columns.append(column_name.lower())
|
79
|
+
return columns
|
80
|
+
|
81
|
+
def _get_columns_in_model(self, node_id) -> List[str]:
|
82
|
+
if node_id not in self.catalog.get_schema():
|
83
|
+
return []
|
84
|
+
return [k.lower() for k in self.catalog.get_schema()[node_id].keys()]
|
85
|
+
|
86
|
+
def generate(self, *args, **kwargs) -> List[DBTModelInsightResponse]:
|
87
|
+
"""
|
88
|
+
Generate a list of InsightResponse objects for each model in the DBT project,
|
89
|
+
identifying root models with 0 direct parents.
|
90
|
+
:return: A list of InsightResponse objects.
|
91
|
+
"""
|
92
|
+
insights = []
|
93
|
+
for node_id, node in self.nodes.items():
|
94
|
+
if self.should_skip_model(node_id):
|
95
|
+
self.logger.debug(f"Skipping model {node_id} as it is not enabled for selected models")
|
96
|
+
continue
|
97
|
+
if node.resource_type == AltimateResourceType.model:
|
98
|
+
columns_documented = self._get_columns_documented(node_id)
|
99
|
+
db_columns = self._get_columns_in_model(node_id)
|
100
|
+
columns_stale = list(set(columns_documented) - set(db_columns))
|
101
|
+
if columns_stale:
|
102
|
+
insights.append(
|
103
|
+
DBTModelInsightResponse(
|
104
|
+
unique_id=node_id,
|
105
|
+
package_name=node.package_name,
|
106
|
+
path=node.original_file_path,
|
107
|
+
original_file_path=node.original_file_path,
|
108
|
+
insight=self._build_failure_result(node_id, columns_stale),
|
109
|
+
severity=get_severity(self.config, self.ALIAS, self.DEFAULT_SEVERITY),
|
110
|
+
)
|
111
|
+
)
|
112
|
+
|
113
|
+
return insights
|
114
|
+
|
115
|
+
@classmethod
|
116
|
+
def has_all_required_data(cls, has_manifest: bool, has_catalog: bool, **kwargs) -> Tuple[bool, str]:
|
117
|
+
"""
|
118
|
+
return False
|
119
|
+
"""
|
120
|
+
if not has_manifest:
|
121
|
+
return False, "manifest is required for insight to run."
|
122
|
+
|
123
|
+
if not has_catalog:
|
124
|
+
return False, "catalog is required for insight to run."
|
125
|
+
|
126
|
+
return True, ""
|
127
|
+
|
128
|
+
@classmethod
|
129
|
+
def requires_catalog(cls) -> bool:
|
130
|
+
return True
|
@@ -0,0 +1,90 @@
|
|
1
|
+
from typing import List
|
2
|
+
|
3
|
+
from datapilot.core.insights.utils import get_severity
|
4
|
+
from datapilot.core.platforms.dbt.insights.governance.base import DBTGovernanceInsight
|
5
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTInsightResult
|
6
|
+
from datapilot.core.platforms.dbt.insights.schema import DBTModelInsightResponse
|
7
|
+
from datapilot.core.platforms.dbt.schemas.manifest import AltimateAccess
|
8
|
+
from datapilot.utils.formatting.utils import numbered_list
|
9
|
+
|
10
|
+
|
11
|
+
class DBTExposureDependentOnPrivateModels(DBTGovernanceInsight):
|
12
|
+
"""
|
13
|
+
DBTExposureDependentOnPrivateModels identifies exposures that are dependent on private models.
|
14
|
+
"""
|
15
|
+
|
16
|
+
NAME = "Exposures dependent on private models"
|
17
|
+
ALIAS = "exposures_dependent_on_private_models"
|
18
|
+
DESCRIPTION = "Identify exposures that are dependent on private models. "
|
19
|
+
REASON_TO_FLAG = (
|
20
|
+
"Exposures illustrate how and where data is consumed in downstream tools. These tools should utilize "
|
21
|
+
"data from public, trusted, and contracted sources to ensure data reliability and integrity."
|
22
|
+
)
|
23
|
+
FAILURE_MESSAGE = (
|
24
|
+
"Exposure `{exposure_unique_id}` is dependent on private models, which may not be ideal for "
|
25
|
+
"downstream consumption:\n`{private_models}`."
|
26
|
+
)
|
27
|
+
RECOMMENDATION = (
|
28
|
+
"Consider revising the yml file to ensure that the models your exposures depend on are fully "
|
29
|
+
"exposed and public. While this rule flags non-public models, it is also recommended to document"
|
30
|
+
" and formalize contracts for these public models for best practices."
|
31
|
+
)
|
32
|
+
|
33
|
+
def _build_failure_result(self, exposure_unique_id: str, private_models: List[str]) -> DBTInsightResult:
|
34
|
+
"""
|
35
|
+
Build failure result for the insight if a model is a root model with 0 direct parents.
|
36
|
+
|
37
|
+
:param exposure_unique_id: Unique ID of the current model being evaluated.
|
38
|
+
:return: An instance of InsightResult containing failure message and recommendation.
|
39
|
+
"""
|
40
|
+
self.logger.debug(f"Building failure result exposure {exposure_unique_id} depends on private models {private_models}")
|
41
|
+
|
42
|
+
failure = self.FAILURE_MESSAGE.format(
|
43
|
+
exposure_unique_id=exposure_unique_id,
|
44
|
+
private_models=numbered_list(private_models),
|
45
|
+
)
|
46
|
+
|
47
|
+
return DBTInsightResult(
|
48
|
+
type=self.TYPE,
|
49
|
+
name=self.NAME,
|
50
|
+
message=failure,
|
51
|
+
recommendation=self.RECOMMENDATION,
|
52
|
+
reason_to_flag=self.REASON_TO_FLAG,
|
53
|
+
metadata={"exposure": exposure_unique_id, "private_models": private_models},
|
54
|
+
)
|
55
|
+
|
56
|
+
def generate(self, *args, **kwargs) -> List[DBTModelInsightResponse]:
|
57
|
+
"""
|
58
|
+
Generate a list of InsightResponse objects for each model in the dbt project,
|
59
|
+
identifying root models with 0 direct parents.
|
60
|
+
:return: A list of InsightResponse objects.
|
61
|
+
"""
|
62
|
+
if len(self.exposures) == 0:
|
63
|
+
self.logger.debug(f"No exposures found in project {self.project_name}")
|
64
|
+
return []
|
65
|
+
insights = []
|
66
|
+
for exposure_id, exposure in self.exposures.items():
|
67
|
+
if self.should_skip_model(exposure_id):
|
68
|
+
self.logger.debug(f"Skipping model {exposure_id} as it is not enabled for selected models")
|
69
|
+
continue
|
70
|
+
self.logger.debug(f"Checking exposure {exposure_id}")
|
71
|
+
private_models = []
|
72
|
+
for dependency_id in exposure.depends_on.nodes:
|
73
|
+
dependency_node = self.get_node(dependency_id)
|
74
|
+
if dependency_node.access == AltimateAccess.private:
|
75
|
+
private_models.append(dependency_id)
|
76
|
+
|
77
|
+
if private_models:
|
78
|
+
insight_result = self._build_failure_result(exposure_unique_id=exposure_id, private_models=private_models)
|
79
|
+
insights.append(
|
80
|
+
DBTModelInsightResponse(
|
81
|
+
unique_id=exposure_id,
|
82
|
+
package_name=exposure.package_name,
|
83
|
+
path=exposure.original_file_path,
|
84
|
+
original_file_path=exposure.original_file_path,
|
85
|
+
insight=insight_result,
|
86
|
+
severity=get_severity(self.config, self.ALIAS, self.DEFAULT_SEVERITY),
|
87
|
+
)
|
88
|
+
)
|
89
|
+
|
90
|
+
return insights
|