From 727a88cb6b8aaf36547f3aa39d1377fb35154ea2 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:21:08 -0400 Subject: [PATCH] feat: add audit_pass notification event Signed-off-by: Chris (ChrisJr404) <11917633+ChrisJr404@users.noreply.github.com> --- docs/guides/notifications.md | 4 +++- sqlmesh/core/notification_target.py | 16 ++++++++++++++++ sqlmesh/core/scheduler.py | 14 ++++++++++++++ tests/core/test_notification_target.py | 20 ++++++++++++++++++++ tests/core/test_scheduler.py | 8 ++++++-- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/guides/notifications.md b/docs/guides/notifications.md index 749a71c842..3ddd02a309 100644 --- a/docs/guides/notifications.md +++ b/docs/guides/notifications.md @@ -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. @@ -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. @@ -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`. diff --git a/sqlmesh/core/notification_target.py b/sqlmesh/core/notification_target.py index fba6e36f66..6676522751 100644 --- a/sqlmesh/core/notification_target.py +++ b/sqlmesh/core/notification_target.py @@ -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" @@ -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. diff --git a/sqlmesh/core/scheduler.py b/sqlmesh/core/scheduler.py index 5eb0ff40ff..7b7efc05a2 100644 --- a/sqlmesh/core/scheduler.py +++ b/sqlmesh/core/scheduler.py @@ -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) diff --git a/tests/core/test_notification_target.py b/tests/core/test_notification_target.py index 57b21f2e0f..58e99a424a 100644 --- a/tests/core/test_notification_target.py +++ b/tests/core/test_notification_target.py @@ -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}, @@ -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( diff --git a/tests/core/test_scheduler.py b/tests/core/test_scheduler.py index cd32d2451d..714a65e57e 100644 --- a/tests/core/test_scheduler.py +++ b/tests/core/test_scheduler.py @@ -588,6 +588,7 @@ def _evaluate(): 0, ) + # A passing audit notifies AUDIT_PASS. evaluator_audit_mock.return_value = [ AuditResult( audit=audit, @@ -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,