great-expectations-cloud 20240523.0.dev0__py3-none-any.whl → 20251124.0.dev1__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.
- great_expectations_cloud/agent/__init__.py +3 -0
- great_expectations_cloud/agent/actions/__init__.py +8 -5
- great_expectations_cloud/agent/actions/agent_action.py +21 -6
- great_expectations_cloud/agent/actions/draft_datasource_config_action.py +45 -24
- great_expectations_cloud/agent/actions/generate_data_quality_check_expectations_action.py +557 -0
- great_expectations_cloud/agent/actions/list_asset_names.py +65 -0
- great_expectations_cloud/agent/actions/run_checkpoint.py +74 -27
- great_expectations_cloud/agent/actions/run_metric_list_action.py +11 -5
- great_expectations_cloud/agent/actions/run_scheduled_checkpoint.py +67 -0
- great_expectations_cloud/agent/actions/run_window_checkpoint.py +66 -0
- great_expectations_cloud/agent/actions/utils.py +35 -0
- great_expectations_cloud/agent/agent.py +444 -101
- great_expectations_cloud/agent/cli.py +2 -2
- great_expectations_cloud/agent/config.py +19 -5
- great_expectations_cloud/agent/event_handler.py +49 -12
- great_expectations_cloud/agent/exceptions.py +9 -0
- great_expectations_cloud/agent/message_service/asyncio_rabbit_mq_client.py +80 -14
- great_expectations_cloud/agent/message_service/subscriber.py +8 -5
- great_expectations_cloud/agent/models.py +197 -20
- great_expectations_cloud/agent/utils.py +84 -0
- great_expectations_cloud/logging/logging_cfg.py +20 -4
- great_expectations_cloud/py.typed +0 -0
- {great_expectations_cloud-20240523.0.dev0.dist-info → great_expectations_cloud-20251124.0.dev1.dist-info}/METADATA +54 -46
- great_expectations_cloud-20251124.0.dev1.dist-info/RECORD +34 -0
- {great_expectations_cloud-20240523.0.dev0.dist-info → great_expectations_cloud-20251124.0.dev1.dist-info}/WHEEL +1 -1
- great_expectations_cloud/agent/actions/data_assistants/__init__.py +0 -8
- great_expectations_cloud/agent/actions/data_assistants/run_missingness_data_assistant.py +0 -45
- great_expectations_cloud/agent/actions/data_assistants/run_onboarding_data_assistant.py +0 -45
- great_expectations_cloud/agent/actions/data_assistants/utils.py +0 -123
- great_expectations_cloud/agent/actions/list_table_names.py +0 -76
- great_expectations_cloud-20240523.0.dev0.dist-info/RECORD +0 -32
- {great_expectations_cloud-20240523.0.dev0.dist-info → great_expectations_cloud-20251124.0.dev1.dist-info}/entry_points.txt +0 -0
- {great_expectations_cloud-20240523.0.dev0.dist-info → great_expectations_cloud-20251124.0.dev1.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
import great_expectations.expectations as gx_expectations
|
|
12
|
+
from great_expectations.core.http import create_session
|
|
13
|
+
from great_expectations.exceptions import (
|
|
14
|
+
GXCloudError,
|
|
15
|
+
InvalidExpectationConfigurationError,
|
|
16
|
+
)
|
|
17
|
+
from great_expectations.expectations.metadata_types import (
|
|
18
|
+
DataQualityIssues,
|
|
19
|
+
FailureSeverity,
|
|
20
|
+
)
|
|
21
|
+
from great_expectations.expectations.window import Offset, Window
|
|
22
|
+
from great_expectations.experimental.metric_repository.batch_inspector import (
|
|
23
|
+
BatchInspector,
|
|
24
|
+
)
|
|
25
|
+
from great_expectations.experimental.metric_repository.cloud_data_store import (
|
|
26
|
+
CloudDataStore,
|
|
27
|
+
)
|
|
28
|
+
from great_expectations.experimental.metric_repository.metric_list_metric_retriever import (
|
|
29
|
+
MetricListMetricRetriever,
|
|
30
|
+
)
|
|
31
|
+
from great_expectations.experimental.metric_repository.metric_repository import (
|
|
32
|
+
MetricRepository,
|
|
33
|
+
)
|
|
34
|
+
from great_expectations.experimental.metric_repository.metrics import (
|
|
35
|
+
ColumnMetric,
|
|
36
|
+
MetricRun,
|
|
37
|
+
MetricTypes,
|
|
38
|
+
)
|
|
39
|
+
from typing_extensions import override
|
|
40
|
+
|
|
41
|
+
from great_expectations_cloud.agent.actions import ActionResult, AgentAction
|
|
42
|
+
from great_expectations_cloud.agent.event_handler import register_event_action
|
|
43
|
+
from great_expectations_cloud.agent.exceptions import GXAgentError
|
|
44
|
+
from great_expectations_cloud.agent.models import (
|
|
45
|
+
CreatedResource,
|
|
46
|
+
DomainContext,
|
|
47
|
+
GenerateDataQualityCheckExpectationsEvent,
|
|
48
|
+
)
|
|
49
|
+
from great_expectations_cloud.agent.utils import (
|
|
50
|
+
TriangularInterpolationOptions,
|
|
51
|
+
param_safe_unique_id,
|
|
52
|
+
triangular_interpolation,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from great_expectations.core.suite_parameters import SuiteParameterDict
|
|
57
|
+
from great_expectations.data_context import CloudDataContext
|
|
58
|
+
from great_expectations.datasource.fluent import DataAsset
|
|
59
|
+
|
|
60
|
+
LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
61
|
+
LOGGER.setLevel(logging.DEBUG)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ExpectationConstraintFunction(str, Enum):
|
|
65
|
+
"""Expectation constraint functions."""
|
|
66
|
+
|
|
67
|
+
FORECAST = "forecast"
|
|
68
|
+
MEAN = "mean"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PartialGenerateDataQualityCheckExpectationError(GXAgentError):
|
|
72
|
+
def __init__(self, assets_with_errors: list[str], assets_attempted: int):
|
|
73
|
+
message_header = f"Unable to autogenerate expectations for {len(assets_with_errors)} of {assets_attempted} Data Assets."
|
|
74
|
+
errors = ", ".join(assets_with_errors)
|
|
75
|
+
message_footer = "Check your connection details, delete and recreate these Data Assets."
|
|
76
|
+
message = f"{message_header}\n\u2022 {errors}\n{message_footer}"
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GenerateDataQualityCheckExpectationsAction(
|
|
81
|
+
AgentAction[GenerateDataQualityCheckExpectationsEvent]
|
|
82
|
+
):
|
|
83
|
+
def __init__( # noqa: PLR0913 # Refactor opportunity
|
|
84
|
+
self,
|
|
85
|
+
context: CloudDataContext,
|
|
86
|
+
base_url: str,
|
|
87
|
+
domain_context: DomainContext,
|
|
88
|
+
auth_key: str,
|
|
89
|
+
metric_repository: MetricRepository | None = None,
|
|
90
|
+
batch_inspector: BatchInspector | None = None,
|
|
91
|
+
):
|
|
92
|
+
super().__init__(
|
|
93
|
+
context=context, base_url=base_url, domain_context=domain_context, auth_key=auth_key
|
|
94
|
+
)
|
|
95
|
+
self._metric_repository = metric_repository or MetricRepository(
|
|
96
|
+
data_store=CloudDataStore(self._context)
|
|
97
|
+
)
|
|
98
|
+
self._batch_inspector = batch_inspector or BatchInspector(
|
|
99
|
+
context, [MetricListMetricRetriever(self._context)]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
def run(self, event: GenerateDataQualityCheckExpectationsEvent, id: str) -> ActionResult:
|
|
104
|
+
created_resources: list[CreatedResource] = []
|
|
105
|
+
assets_with_errors: list[str] = []
|
|
106
|
+
selected_dqis: Sequence[DataQualityIssues] = event.selected_data_quality_issues or []
|
|
107
|
+
created_via: str | None = event.created_via or None
|
|
108
|
+
for asset_name in event.data_assets:
|
|
109
|
+
try:
|
|
110
|
+
data_asset = self._retrieve_asset_from_asset_name(event, asset_name)
|
|
111
|
+
|
|
112
|
+
metric_run, metric_run_id = self._get_metrics(data_asset)
|
|
113
|
+
created_resources.append(
|
|
114
|
+
CreatedResource(resource_id=str(metric_run_id), type="MetricRun")
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if selected_dqis:
|
|
118
|
+
pre_existing_anomaly_detection_coverage = (
|
|
119
|
+
self._get_current_anomaly_detection_coverage(data_asset.id)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if self._should_add_volume_change_detection_coverage(
|
|
123
|
+
selected_data_quality_issues=selected_dqis,
|
|
124
|
+
pre_existing_anomaly_detection_coverage=pre_existing_anomaly_detection_coverage,
|
|
125
|
+
):
|
|
126
|
+
volume_change_expectation_id = self._add_volume_change_expectation(
|
|
127
|
+
asset_id=data_asset.id,
|
|
128
|
+
use_forecast=event.use_forecast,
|
|
129
|
+
created_via=created_via,
|
|
130
|
+
)
|
|
131
|
+
created_resources.append(
|
|
132
|
+
CreatedResource(
|
|
133
|
+
resource_id=str(volume_change_expectation_id), type="Expectation"
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if self._should_add_schema_change_detection_coverage(
|
|
138
|
+
selected_data_quality_issues=selected_dqis,
|
|
139
|
+
pre_existing_anomaly_detection_coverage=pre_existing_anomaly_detection_coverage,
|
|
140
|
+
):
|
|
141
|
+
schema_change_expectation_id = self._add_schema_change_expectation(
|
|
142
|
+
metric_run=metric_run, asset_id=data_asset.id, created_via=created_via
|
|
143
|
+
)
|
|
144
|
+
created_resources.append(
|
|
145
|
+
CreatedResource(
|
|
146
|
+
resource_id=str(schema_change_expectation_id), type="Expectation"
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if DataQualityIssues.COMPLETENESS in selected_dqis:
|
|
151
|
+
pre_existing_completeness_change_expectations = (
|
|
152
|
+
pre_existing_anomaly_detection_coverage.get(
|
|
153
|
+
DataQualityIssues.COMPLETENESS, []
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
completeness_change_expectation_ids = self._add_completeness_change_expectations(
|
|
157
|
+
metric_run=metric_run,
|
|
158
|
+
asset_id=data_asset.id,
|
|
159
|
+
pre_existing_completeness_change_expectations=pre_existing_completeness_change_expectations,
|
|
160
|
+
created_via=created_via,
|
|
161
|
+
use_forecast=event.use_forecast,
|
|
162
|
+
)
|
|
163
|
+
for exp_id in completeness_change_expectation_ids:
|
|
164
|
+
created_resources.append(
|
|
165
|
+
CreatedResource(resource_id=str(exp_id), type="Expectation")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
LOGGER.exception("Failed to generate expectations for %s: %s", asset_name, str(e)) # noqa: TRY401
|
|
170
|
+
assets_with_errors.append(asset_name)
|
|
171
|
+
|
|
172
|
+
if assets_with_errors:
|
|
173
|
+
raise PartialGenerateDataQualityCheckExpectationError(
|
|
174
|
+
assets_with_errors=assets_with_errors,
|
|
175
|
+
assets_attempted=len(event.data_assets),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return ActionResult(
|
|
179
|
+
id=id,
|
|
180
|
+
type=event.type,
|
|
181
|
+
created_resources=created_resources,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _retrieve_asset_from_asset_name(
|
|
185
|
+
self, event: GenerateDataQualityCheckExpectationsEvent, asset_name: str
|
|
186
|
+
) -> DataAsset[Any, Any]:
|
|
187
|
+
try:
|
|
188
|
+
datasource = self._context.data_sources.get(event.datasource_name)
|
|
189
|
+
data_asset = datasource.get_asset(asset_name)
|
|
190
|
+
data_asset.test_connection() # raises `TestConnectionError` on failure
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
# TODO - see if this can be made more specific
|
|
194
|
+
raise RuntimeError(f"Failed to retrieve asset: {e}") from e # noqa: TRY003 # want to keep this informative for now
|
|
195
|
+
|
|
196
|
+
return data_asset # type: ignore[no-any-return] # unable to narrow types strictly based on names
|
|
197
|
+
|
|
198
|
+
def _get_metrics(self, data_asset: DataAsset[Any, Any]) -> tuple[MetricRun, UUID]:
|
|
199
|
+
batch_request = data_asset.build_batch_request()
|
|
200
|
+
if data_asset.id is None:
|
|
201
|
+
raise RuntimeError("DataAsset.id is None") # noqa: TRY003
|
|
202
|
+
metric_run = self._batch_inspector.compute_metric_list_run(
|
|
203
|
+
data_asset_id=data_asset.id,
|
|
204
|
+
batch_request=batch_request,
|
|
205
|
+
metric_list=[
|
|
206
|
+
MetricTypes.TABLE_COLUMNS,
|
|
207
|
+
MetricTypes.TABLE_COLUMN_TYPES,
|
|
208
|
+
MetricTypes.COLUMN_NON_NULL_COUNT,
|
|
209
|
+
MetricTypes.TABLE_ROW_COUNT,
|
|
210
|
+
],
|
|
211
|
+
)
|
|
212
|
+
metric_run_id = self._metric_repository.add_metric_run(metric_run)
|
|
213
|
+
# Note: This exception is raised after the metric run is added to the repository so that
|
|
214
|
+
# the user can still access any computed metrics even if one of the metrics fails.
|
|
215
|
+
self._raise_on_any_metric_exception(metric_run)
|
|
216
|
+
|
|
217
|
+
return metric_run, metric_run_id
|
|
218
|
+
|
|
219
|
+
def _get_current_anomaly_detection_coverage(
|
|
220
|
+
self, data_asset_id: UUID | None
|
|
221
|
+
) -> dict[DataQualityIssues, list[dict[Any, Any]]]:
|
|
222
|
+
"""
|
|
223
|
+
This function returns a dict mapping Data Quality Issues to a list of ExpectationConfiguration dicts.
|
|
224
|
+
"""
|
|
225
|
+
url = urljoin(
|
|
226
|
+
base=self._base_url,
|
|
227
|
+
url=f"/api/v1/organizations/{self._domain_context.organization_id}/workspaces/{self._domain_context.workspace_id}/expectations/",
|
|
228
|
+
)
|
|
229
|
+
with create_session(access_token=self._auth_key) as session:
|
|
230
|
+
response = session.get(
|
|
231
|
+
url=url,
|
|
232
|
+
params={"anomaly_detection": str(True), "data_asset_id": str(data_asset_id)},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if not response.ok:
|
|
236
|
+
raise GXCloudError(
|
|
237
|
+
message=f"GenerateDataQualityCheckExpectationsAction encountered an error while connecting to GX Cloud. "
|
|
238
|
+
f"Unable to retrieve Anomaly Detection Expectations for Asset with ID={data_asset_id}.",
|
|
239
|
+
response=response,
|
|
240
|
+
)
|
|
241
|
+
data = response.json()
|
|
242
|
+
try:
|
|
243
|
+
return data["data"] # type: ignore[no-any-return]
|
|
244
|
+
|
|
245
|
+
except KeyError as e:
|
|
246
|
+
raise GXCloudError(
|
|
247
|
+
message="Malformed response received from GX Cloud",
|
|
248
|
+
response=response,
|
|
249
|
+
) from e
|
|
250
|
+
|
|
251
|
+
def _should_add_volume_change_detection_coverage(
|
|
252
|
+
self,
|
|
253
|
+
selected_data_quality_issues: Sequence[DataQualityIssues],
|
|
254
|
+
pre_existing_anomaly_detection_coverage: dict[
|
|
255
|
+
DataQualityIssues, list[dict[Any, Any]]
|
|
256
|
+
], # list of ExpectationConfiguration dicts
|
|
257
|
+
) -> bool:
|
|
258
|
+
return (
|
|
259
|
+
DataQualityIssues.VOLUME in selected_data_quality_issues
|
|
260
|
+
and len(pre_existing_anomaly_detection_coverage.get(DataQualityIssues.VOLUME, [])) == 0
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _should_add_schema_change_detection_coverage(
|
|
264
|
+
self,
|
|
265
|
+
selected_data_quality_issues: Sequence[DataQualityIssues],
|
|
266
|
+
pre_existing_anomaly_detection_coverage: dict[
|
|
267
|
+
DataQualityIssues, list[dict[Any, Any]]
|
|
268
|
+
], # list of ExpectationConfiguration dicts
|
|
269
|
+
) -> bool:
|
|
270
|
+
return (
|
|
271
|
+
DataQualityIssues.SCHEMA in selected_data_quality_issues
|
|
272
|
+
and len(pre_existing_anomaly_detection_coverage.get(DataQualityIssues.SCHEMA, [])) == 0
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def _add_volume_change_expectation(
|
|
276
|
+
self, asset_id: UUID | None, use_forecast: bool, created_via: str | None
|
|
277
|
+
) -> UUID:
|
|
278
|
+
unique_id = param_safe_unique_id(16)
|
|
279
|
+
lower_bound_param_name = f"{unique_id}_min_value_min"
|
|
280
|
+
upper_bound_param_name = f"{unique_id}_max_value_max"
|
|
281
|
+
min_value: SuiteParameterDict[str, str] | None = {"$PARAMETER": lower_bound_param_name}
|
|
282
|
+
max_value: SuiteParameterDict[str, str] | None = {"$PARAMETER": upper_bound_param_name}
|
|
283
|
+
windows = []
|
|
284
|
+
strict_min = False
|
|
285
|
+
strict_max = False
|
|
286
|
+
|
|
287
|
+
if use_forecast:
|
|
288
|
+
windows += [
|
|
289
|
+
Window(
|
|
290
|
+
constraint_fn=ExpectationConstraintFunction.FORECAST,
|
|
291
|
+
parameter_name=lower_bound_param_name,
|
|
292
|
+
range=1,
|
|
293
|
+
offset=Offset(positive=0.0, negative=0.0),
|
|
294
|
+
strict=True,
|
|
295
|
+
),
|
|
296
|
+
Window(
|
|
297
|
+
constraint_fn=ExpectationConstraintFunction.FORECAST,
|
|
298
|
+
parameter_name=upper_bound_param_name,
|
|
299
|
+
range=1,
|
|
300
|
+
offset=Offset(positive=0.0, negative=0.0),
|
|
301
|
+
strict=True,
|
|
302
|
+
),
|
|
303
|
+
]
|
|
304
|
+
else:
|
|
305
|
+
windows += [
|
|
306
|
+
Window(
|
|
307
|
+
constraint_fn=ExpectationConstraintFunction.MEAN,
|
|
308
|
+
parameter_name=lower_bound_param_name,
|
|
309
|
+
range=1,
|
|
310
|
+
offset=Offset(positive=0.0, negative=0.0),
|
|
311
|
+
strict=True,
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
max_value = None
|
|
315
|
+
strict_min = True
|
|
316
|
+
|
|
317
|
+
expectation = gx_expectations.ExpectTableRowCountToBeBetween(
|
|
318
|
+
windows=windows,
|
|
319
|
+
strict_min=strict_min,
|
|
320
|
+
strict_max=strict_max,
|
|
321
|
+
min_value=min_value,
|
|
322
|
+
max_value=max_value,
|
|
323
|
+
severity=FailureSeverity.WARNING,
|
|
324
|
+
)
|
|
325
|
+
expectation_id = self._create_expectation_for_asset(
|
|
326
|
+
expectation=expectation, asset_id=asset_id, created_via=created_via
|
|
327
|
+
)
|
|
328
|
+
return expectation_id
|
|
329
|
+
|
|
330
|
+
def _add_schema_change_expectation(
|
|
331
|
+
self, metric_run: MetricRun, asset_id: UUID | None, created_via: str | None
|
|
332
|
+
) -> UUID:
|
|
333
|
+
# Find the TABLE_COLUMNS metric by type instead of assuming it's at position 0
|
|
334
|
+
table_columns_metric = next(
|
|
335
|
+
(
|
|
336
|
+
metric
|
|
337
|
+
for metric in metric_run.metrics
|
|
338
|
+
if metric.metric_name == MetricTypes.TABLE_COLUMNS
|
|
339
|
+
),
|
|
340
|
+
None,
|
|
341
|
+
)
|
|
342
|
+
if not table_columns_metric:
|
|
343
|
+
raise RuntimeError("missing TABLE_COLUMNS metric") # noqa: TRY003
|
|
344
|
+
|
|
345
|
+
expectation = gx_expectations.ExpectTableColumnsToMatchSet(
|
|
346
|
+
column_set=table_columns_metric.value,
|
|
347
|
+
severity=FailureSeverity.WARNING,
|
|
348
|
+
)
|
|
349
|
+
expectation_id = self._create_expectation_for_asset(
|
|
350
|
+
expectation=expectation, asset_id=asset_id, created_via=created_via
|
|
351
|
+
)
|
|
352
|
+
return expectation_id
|
|
353
|
+
|
|
354
|
+
def _add_completeness_change_expectations(
|
|
355
|
+
self,
|
|
356
|
+
metric_run: MetricRun,
|
|
357
|
+
asset_id: UUID | None,
|
|
358
|
+
pre_existing_completeness_change_expectations: list[
|
|
359
|
+
dict[Any, Any]
|
|
360
|
+
], # list of ExpectationConfiguration dicts
|
|
361
|
+
created_via: str | None,
|
|
362
|
+
use_forecast: bool = False,
|
|
363
|
+
) -> list[UUID]:
|
|
364
|
+
table_row_count = next(
|
|
365
|
+
metric
|
|
366
|
+
for metric in metric_run.metrics
|
|
367
|
+
if metric.metric_name == MetricTypes.TABLE_ROW_COUNT
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if not table_row_count:
|
|
371
|
+
raise RuntimeError("missing TABLE_ROW_COUNT metric") # noqa: TRY003
|
|
372
|
+
|
|
373
|
+
column_null_values_metric: list[ColumnMetric[int]] = [
|
|
374
|
+
metric
|
|
375
|
+
for metric in metric_run.metrics
|
|
376
|
+
if isinstance(metric, ColumnMetric)
|
|
377
|
+
and metric.metric_name == MetricTypes.COLUMN_NON_NULL_COUNT
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
if not column_null_values_metric or len(column_null_values_metric) == 0:
|
|
381
|
+
raise RuntimeError("missing COLUMN_NON_NULL_COUNT metrics") # noqa: TRY003
|
|
382
|
+
|
|
383
|
+
expectation_ids = []
|
|
384
|
+
# Single-expectation approach using ExpectColumnProportionOfNonNullValuesToBeBetween
|
|
385
|
+
# Expectations are only added to columns that do not have coverage
|
|
386
|
+
columns_missing_completeness_coverage = self._get_columns_missing_completeness_coverage(
|
|
387
|
+
column_null_values_metric=column_null_values_metric,
|
|
388
|
+
pre_existing_completeness_change_expectations=pre_existing_completeness_change_expectations,
|
|
389
|
+
)
|
|
390
|
+
for column in columns_missing_completeness_coverage:
|
|
391
|
+
column_name = column.column
|
|
392
|
+
non_null_count = column.value
|
|
393
|
+
row_count = table_row_count.value
|
|
394
|
+
expectation: gx_expectations.Expectation
|
|
395
|
+
|
|
396
|
+
# Single-expectation approach using ExpectColumnProportionOfNonNullValuesToBeBetween
|
|
397
|
+
unique_id = param_safe_unique_id(16)
|
|
398
|
+
min_param_name = f"{unique_id}_proportion_min"
|
|
399
|
+
max_param_name = f"{unique_id}_proportion_max"
|
|
400
|
+
|
|
401
|
+
# Calculate non-null proportion
|
|
402
|
+
non_null_proportion = non_null_count / row_count if row_count > 0 else 0
|
|
403
|
+
|
|
404
|
+
if use_forecast:
|
|
405
|
+
expectation = gx_expectations.ExpectColumnProportionOfNonNullValuesToBeBetween(
|
|
406
|
+
windows=[
|
|
407
|
+
Window(
|
|
408
|
+
constraint_fn=ExpectationConstraintFunction.FORECAST,
|
|
409
|
+
parameter_name=min_param_name,
|
|
410
|
+
range=1,
|
|
411
|
+
offset=Offset(positive=0.0, negative=0.0),
|
|
412
|
+
strict=True,
|
|
413
|
+
),
|
|
414
|
+
Window(
|
|
415
|
+
constraint_fn=ExpectationConstraintFunction.FORECAST,
|
|
416
|
+
parameter_name=max_param_name,
|
|
417
|
+
range=1,
|
|
418
|
+
offset=Offset(positive=0.0, negative=0.0),
|
|
419
|
+
strict=True,
|
|
420
|
+
),
|
|
421
|
+
],
|
|
422
|
+
column=column_name,
|
|
423
|
+
min_value={"$PARAMETER": min_param_name},
|
|
424
|
+
max_value={"$PARAMETER": max_param_name},
|
|
425
|
+
severity=FailureSeverity.WARNING,
|
|
426
|
+
)
|
|
427
|
+
elif non_null_proportion == 0:
|
|
428
|
+
expectation = gx_expectations.ExpectColumnProportionOfNonNullValuesToBeBetween(
|
|
429
|
+
column=column_name,
|
|
430
|
+
max_value=0,
|
|
431
|
+
severity=FailureSeverity.WARNING,
|
|
432
|
+
)
|
|
433
|
+
elif non_null_proportion == 1:
|
|
434
|
+
expectation = gx_expectations.ExpectColumnProportionOfNonNullValuesToBeBetween(
|
|
435
|
+
column=column_name,
|
|
436
|
+
min_value=1,
|
|
437
|
+
severity=FailureSeverity.WARNING,
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
# Use triangular interpolation to compute min/max values
|
|
441
|
+
interpolated_offset = self._compute_triangular_interpolation_offset(
|
|
442
|
+
value=non_null_proportion, input_range=(0.0, 1.0)
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
expectation = gx_expectations.ExpectColumnProportionOfNonNullValuesToBeBetween(
|
|
446
|
+
windows=[
|
|
447
|
+
Window(
|
|
448
|
+
constraint_fn=ExpectationConstraintFunction.MEAN,
|
|
449
|
+
parameter_name=min_param_name,
|
|
450
|
+
range=5,
|
|
451
|
+
offset=Offset(
|
|
452
|
+
positive=interpolated_offset, negative=interpolated_offset
|
|
453
|
+
),
|
|
454
|
+
strict=False,
|
|
455
|
+
),
|
|
456
|
+
Window(
|
|
457
|
+
constraint_fn=ExpectationConstraintFunction.MEAN,
|
|
458
|
+
parameter_name=max_param_name,
|
|
459
|
+
range=5,
|
|
460
|
+
offset=Offset(
|
|
461
|
+
positive=interpolated_offset, negative=interpolated_offset
|
|
462
|
+
),
|
|
463
|
+
strict=False,
|
|
464
|
+
),
|
|
465
|
+
],
|
|
466
|
+
column=column_name,
|
|
467
|
+
min_value={"$PARAMETER": min_param_name},
|
|
468
|
+
max_value={"$PARAMETER": max_param_name},
|
|
469
|
+
severity=FailureSeverity.WARNING,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
expectation_id = self._create_expectation_for_asset(
|
|
473
|
+
expectation=expectation, asset_id=asset_id, created_via=created_via
|
|
474
|
+
)
|
|
475
|
+
expectation_ids.append(expectation_id)
|
|
476
|
+
|
|
477
|
+
return expectation_ids
|
|
478
|
+
|
|
479
|
+
def _get_columns_missing_completeness_coverage(
|
|
480
|
+
self,
|
|
481
|
+
column_null_values_metric: list[ColumnMetric[int]],
|
|
482
|
+
pre_existing_completeness_change_expectations: list[
|
|
483
|
+
dict[Any, Any]
|
|
484
|
+
], # list of ExpectationConfiguration dicts
|
|
485
|
+
) -> list[ColumnMetric[int]]:
|
|
486
|
+
try:
|
|
487
|
+
columns_with_completeness_coverage = {
|
|
488
|
+
expectation.get("config").get("kwargs").get("column") # type: ignore[union-attr]
|
|
489
|
+
for expectation in pre_existing_completeness_change_expectations
|
|
490
|
+
}
|
|
491
|
+
except AttributeError as e:
|
|
492
|
+
raise InvalidExpectationConfigurationError(str(e)) from e
|
|
493
|
+
columns_without_completeness_coverage = [
|
|
494
|
+
column
|
|
495
|
+
for column in column_null_values_metric
|
|
496
|
+
if column.column not in columns_with_completeness_coverage
|
|
497
|
+
]
|
|
498
|
+
return columns_without_completeness_coverage
|
|
499
|
+
|
|
500
|
+
def _compute_triangular_interpolation_offset(
|
|
501
|
+
self, value: float, input_range: tuple[float, float]
|
|
502
|
+
) -> float:
|
|
503
|
+
"""
|
|
504
|
+
Compute triangular interpolation offset for expectation windows.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
value: The input value to interpolate
|
|
508
|
+
input_range: The input range as (min, max) tuple
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
The computed interpolation offset
|
|
512
|
+
"""
|
|
513
|
+
options = TriangularInterpolationOptions(
|
|
514
|
+
input_range=input_range,
|
|
515
|
+
output_range=(0, 0.1),
|
|
516
|
+
round_precision=5,
|
|
517
|
+
)
|
|
518
|
+
return max(0.0001, round(triangular_interpolation(value, options), 5))
|
|
519
|
+
|
|
520
|
+
def _create_expectation_for_asset(
|
|
521
|
+
self,
|
|
522
|
+
expectation: gx_expectations.Expectation,
|
|
523
|
+
asset_id: UUID | None,
|
|
524
|
+
created_via: str | None,
|
|
525
|
+
) -> UUID:
|
|
526
|
+
url = urljoin(
|
|
527
|
+
base=self._base_url,
|
|
528
|
+
url=f"/api/v1/organizations/{self._domain_context.organization_id}/workspaces/{self._domain_context.workspace_id}/expectations/{asset_id}",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
expectation_payload = expectation.configuration.to_json_dict()
|
|
532
|
+
expectation_payload["autogenerated"] = True
|
|
533
|
+
if created_via is not None:
|
|
534
|
+
expectation_payload["created_via"] = created_via
|
|
535
|
+
|
|
536
|
+
# Backend expects `expectation_type` instead of `type`:
|
|
537
|
+
expectation_type = expectation_payload.pop("type")
|
|
538
|
+
expectation_payload["expectation_type"] = expectation_type
|
|
539
|
+
|
|
540
|
+
with create_session(access_token=self._auth_key) as session:
|
|
541
|
+
response = session.post(url=url, json=expectation_payload)
|
|
542
|
+
|
|
543
|
+
if response.status_code != HTTPStatus.CREATED:
|
|
544
|
+
message = f"Failed to add autogenerated expectation: {expectation_type}"
|
|
545
|
+
raise GXAgentError(message)
|
|
546
|
+
return UUID(response.json()["data"]["id"])
|
|
547
|
+
|
|
548
|
+
def _raise_on_any_metric_exception(self, metric_run: MetricRun) -> None:
|
|
549
|
+
if any(metric.exception for metric in metric_run.metrics):
|
|
550
|
+
raise RuntimeError( # noqa: TRY003 # one off error
|
|
551
|
+
"One or more metrics failed to compute."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
register_event_action(
|
|
556
|
+
"1", GenerateDataQualityCheckExpectationsEvent, GenerateDataQualityCheckExpectationsAction
|
|
557
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
from great_expectations.core.http import create_session
|
|
6
|
+
from great_expectations.datasource.fluent import SQLDatasource
|
|
7
|
+
from great_expectations.exceptions import GXCloudError
|
|
8
|
+
from typing_extensions import override
|
|
9
|
+
|
|
10
|
+
from great_expectations_cloud.agent.actions.agent_action import (
|
|
11
|
+
ActionResult,
|
|
12
|
+
AgentAction,
|
|
13
|
+
)
|
|
14
|
+
from great_expectations_cloud.agent.actions.utils import get_asset_names
|
|
15
|
+
from great_expectations_cloud.agent.event_handler import register_event_action
|
|
16
|
+
from great_expectations_cloud.agent.models import ListAssetNamesEvent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ListAssetNamesAction(AgentAction[ListAssetNamesEvent]):
|
|
20
|
+
# TODO: New actions need to be created that are compatible with GX v1 and registered for v1.
|
|
21
|
+
# This action is registered for v0, see register_event_action()
|
|
22
|
+
|
|
23
|
+
@override
|
|
24
|
+
def run(self, event: ListAssetNamesEvent, id: str) -> ActionResult:
|
|
25
|
+
datasource_name: str = event.datasource_name
|
|
26
|
+
datasource = self._context.data_sources.get(name=datasource_name)
|
|
27
|
+
if not isinstance(datasource, SQLDatasource):
|
|
28
|
+
raise TypeError( # noqa: TRY003 # one off error
|
|
29
|
+
f"This operation requires a SQL Data Source but got {type(datasource).__name__}."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
asset_names = get_asset_names(datasource)
|
|
33
|
+
|
|
34
|
+
self._add_or_update_asset_names_list(
|
|
35
|
+
datasource_id=str(datasource.id),
|
|
36
|
+
asset_names=asset_names,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return ActionResult(
|
|
40
|
+
id=id,
|
|
41
|
+
type=event.type,
|
|
42
|
+
created_resources=[],
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def _add_or_update_asset_names_list(self, datasource_id: str, asset_names: list[str]) -> None:
|
|
46
|
+
with create_session(access_token=self._auth_key) as session:
|
|
47
|
+
url = urljoin(
|
|
48
|
+
base=self._base_url,
|
|
49
|
+
url=f"/api/v1/organizations/{self._domain_context.organization_id}/workspaces/{self._domain_context.workspace_id}/table-names/{datasource_id}",
|
|
50
|
+
)
|
|
51
|
+
response = session.put(
|
|
52
|
+
url=url,
|
|
53
|
+
json={"data": {"table_names": asset_names}},
|
|
54
|
+
)
|
|
55
|
+
if response.status_code != 200: # noqa: PLR2004
|
|
56
|
+
raise GXCloudError(
|
|
57
|
+
message=f"ListTableNamesAction encountered an error while connecting to GX Cloud. "
|
|
58
|
+
f"Unable to update "
|
|
59
|
+
f"table_names for Data Source with ID"
|
|
60
|
+
f"={datasource_id}.",
|
|
61
|
+
response=response,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
register_event_action("1", ListAssetNamesEvent, ListAssetNamesAction)
|