Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion docs/guides/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ This example stops all notifications other than those for `User1`:

SQLMesh notifications are triggered by events. The events that should trigger a notification are specified in the notification target's `notify_on` field.

Notifications are supported for [`plan` application](../concepts/plans.md) start/end/failure, [`run`](../reference/cli.md#run) start/end/failure, and [`audit`](../concepts/audits.md) failures.
Notifications are supported for [`plan` application](../concepts/plans.md) start/end/failure, [`run`](../reference/cli.md#run) start/end/failure, and [`audit`](../concepts/audits.md) passes and failures.

For `plan` and `run` start/end, the target environment name is included in the notification message. For failures, the Python exception or error text is included in the notification message.

Expand All @@ -145,6 +145,7 @@ This table lists each event, its associated `notify_on` value, and its notificat
| SQLMesh run end | run_end | "SQLMesh run finished for environment `{environment}`." |
| SQLMesh run failure | run_failure | "Failed to run SQLMesh.\n{exception}" |
| Audit failure | audit_failure | "{audit_error}" |
| Audit pass | audit_pass | "Audit `{audit_name}` passed for model `{model_name}`." |

Any combination of these events can be specified in a notification target's `notify_on` field.

Expand Down Expand Up @@ -269,6 +270,7 @@ Each of those notification target classes is a subclass of `BaseNotificationTarg
| notify_run_end | Environment name: `env` |
| notify_run_failure | Exception stack trace: `exc` |
| notify_audit_failure | Audit error trace: `audit_error` |
| notify_audit_pass | Audit name: `audit_name`, model name: `model_name` |

This example creates a new notification target class `CustomSMTPNotificationTarget`.

Expand Down
16 changes: 16 additions & 0 deletions sqlmesh/core/notification_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class NotificationEvent(str, Enum):
APPLY_FAILURE = "apply_failure"
RUN_FAILURE = "run_failure"
AUDIT_FAILURE = "audit_failure"
AUDIT_PASS = "audit_pass"
MIGRATION_FAILURE = "migration_failure"


Expand Down Expand Up @@ -172,6 +173,21 @@ def notify_audit_failure(self, audit_error: AuditError, *args: t.Any, **kwargs:
"""
self.send(NotificationStatus.FAILURE, "Audit failure.", audit_error=audit_error)

def notify_audit_pass(
self, audit_name: str, model_name: t.Optional[str] = None, *args: t.Any, **kwargs: t.Any
) -> None:
"""Notify when an audit passes.

Args:
audit_name: The name of the audit that passed.
model_name: The name of the model the audit ran against, if any.
"""
if model_name:
msg = f"Audit `{audit_name}` passed for model `{model_name}`."
else:
msg = f"Audit `{audit_name}` passed."
self.send(NotificationStatus.SUCCESS, msg)

def notify_migration_failure(self, exc: str, *args: t.Any, **kwargs: t.Any) -> None:
"""Notify in the case of a migration failure.

Expand Down
14 changes: 14 additions & 0 deletions sqlmesh/core/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,20 @@ def _audit_snapshot(
else:
audit_errors_to_warn.append(error)

model = snapshot.model_or_none
model_name = model.name if model else None
for audit_result in audit_results:
if audit_result.skipped or audit_result.count:
continue
audit_name = audit_result.audit.name
self.notification_target_manager.notify(
NotificationEvent.AUDIT_PASS, audit_name, model_name
)
if is_deployable and snapshot.node.owner:
self.notification_target_manager.notify_user(
NotificationEvent.AUDIT_PASS, snapshot.node.owner, audit_name, model_name
)

if audit_errors_to_raise:
raise NodeAuditsErrors(audit_errors_to_raise)

Expand Down
20 changes: 20 additions & 0 deletions tests/core/test_notification_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def notification_target_manager_with_spy(mocker) -> tuple[NotificationTargetMana
notification_targets={
NotificationEvent.APPLY_START: {console_notification_target},
NotificationEvent.APPLY_END: {console_notification_target},
NotificationEvent.AUDIT_PASS: {console_notification_target},
},
user_notification_targets={
"test_user": {test_user_console_notification_target},
Expand Down Expand Up @@ -57,6 +58,25 @@ def test_notify(notification_target_manager_with_spy):
spy.assert_not_called()


def test_notify_audit_pass(notification_target_manager_with_spy):
notification_target_manager, spy = notification_target_manager_with_spy
notification_target_manager.notify(NotificationEvent.AUDIT_PASS, "not_null", "sushi.orders")
spy.assert_called_once_with(
mock.ANY,
NotificationStatus.SUCCESS,
"Audit `not_null` passed for model `sushi.orders`.",
)

# Without a model name the message omits the model reference
spy.reset_mock()
notification_target_manager.notify(NotificationEvent.AUDIT_PASS, "not_null")
spy.assert_called_once_with(
mock.ANY,
NotificationStatus.SUCCESS,
"Audit `not_null` passed.",
)


def test_notify_user(notification_target_manager_with_spy):
notification_target_manager, spy = notification_target_manager_with_spy
notification_target_manager.notify_user(
Expand Down
8 changes: 6 additions & 2 deletions tests/core/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ def _evaluate():
0,
)

# A passing audit notifies AUDIT_PASS.
evaluator_audit_mock.return_value = [
AuditResult(
audit=audit,
Expand All @@ -599,9 +600,12 @@ def _evaluate():
)
]
_evaluate()
assert notify_user_mock.call_count == 0
assert notify_mock.call_count == 0
assert notify_user_mock.call_count == 1
assert notify_mock.call_count == 1
notify_user_mock.reset_mock()
notify_mock.reset_mock()

# A skipped audit is neither a pass nor a failure, so nothing fires.
evaluator_audit_mock.return_value = [
AuditResult(
audit=audit,
Expand Down
Loading