Skip to content
4 changes: 4 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Added `client.user_defined_functions` for reusable expressions.
- Added `client.annotations` for data reviews, phases, and review history.
- Added `client.campaigns` for grouping report results across runs.
- Added `client.webhooks`, and `RuleAction.webhook` to call one when a rule is violated.
- `RuleCreate.action` and `RuleUpdate.action` accept a `Webhook` directly.
- `RuleCreate.asset_ids` and `RuleUpdate.asset_ids` now accept `Asset` objects as well as IDs.
- `WebhookTestResult.text` decodes the response body, and `Webhook.created_by`/`modified_by` resolve users.

### What's New

Expand Down
12 changes: 8 additions & 4 deletions python/lib/sift_client/_internal/low_level_wrappers/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from sift_client._internal.util.util import count_non_none
from sift_client.sift_types.rule import (
Rule,
RuleAction,
RuleCreate,
RuleUpdate,
RuleVersion,
Expand Down Expand Up @@ -144,7 +145,7 @@ def _update_rule_request_from_create(self, create: RuleCreate) -> UpdateRuleRequ
conditions_request = [
UpdateConditionRequest(
expression=expression_proto,
actions=[create.action._to_update_request()],
actions=[cast("RuleAction", create.action)._to_update_request()],
)
]
update_request = UpdateRuleRequest(
Expand All @@ -156,7 +157,7 @@ def _update_rule_request_from_create(self, create: RuleCreate) -> UpdateRuleRequ
is_live_evaluation_enabled=create.evaluate_on_live_data,
conditions=conditions_request,
asset_configuration=RuleAssetConfiguration(
asset_ids=create.asset_ids or [],
asset_ids=cast("list[str]", create.asset_ids or []),
tag_ids=create.asset_tag_ids or [],
),
contextual_channels=ContextualChannels(
Expand Down Expand Up @@ -259,7 +260,7 @@ def _update_rule_request_from_update(
if "channel_references" in model_dump
else rule.channel_references
) or []
action = update.action if "action" in model_dump else rule.action
action = cast("RuleAction", update.action if "action" in model_dump else rule.action)
if bool(expression) != bool(channel_references):
raise ValueError(
"Expression and channel_references must both be provided or both be None"
Expand Down Expand Up @@ -296,7 +297,10 @@ def _update_rule_request_from_update(

# This always needs to be set, so handle the defaults.
update_dict["asset_configuration"] = RuleAssetConfiguration( # type: ignore
asset_ids=(update.asset_ids if "asset_ids" in model_dump else rule.asset_ids or []),
asset_ids=cast(
"list[str]",
update.asset_ids if "asset_ids" in model_dump else rule.asset_ids or [],
),
tag_ids=(
update.asset_tag_ids if "asset_tag_ids" in model_dump else rule.asset_tag_ids or []
),
Expand Down
197 changes: 197 additions & 0 deletions python/lib/sift_client/_internal/low_level_wrappers/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, cast

from sift.webhooks.v1.webhooks_pb2 import (
CreateWebhookResponse,
GetWebhookRequest,
GetWebhookResponse,
ListWebhooksRequest,
ListWebhooksResponse,
TestWebhookRequest,
TestWebhookResponse,
UpdateWebhookRequest,
UpdateWebhookResponse,
)
from sift.webhooks.v1.webhooks_pb2 import Webhook as WebhookProto
from sift.webhooks.v1.webhooks_pb2_grpc import WebhookServiceStub

from sift_client._internal.low_level_wrappers.base import DEFAULT_PAGE_SIZE, LowLevelClientBase
from sift_client.sift_types.webhook import (
Webhook,
WebhookCreate,
WebhookTest,
WebhookUpdate,
)
from sift_client.transport import WithGrpcClient

if TYPE_CHECKING:
from sift_client.transport.grpc_transport import GrpcClient

# Configure logging
logger = logging.getLogger(__name__)


class WebhooksLowLevelClient(LowLevelClientBase, WithGrpcClient):
"""Low-level client for the WebhooksAPI.

A thin wrapper around the autogenerated bindings for the WebhookService.
"""

def __init__(self, grpc_client: GrpcClient):
"""Initialize the WebhooksLowLevelClient.

Args:
grpc_client: The gRPC client to use for making API calls.
"""
super().__init__(grpc_client)

async def get_webhook(self, webhook_id: str) -> Webhook:
"""Get a webhook by webhook_id.

Args:
webhook_id: The webhook ID to get.

Returns:
The Webhook.
"""
return Webhook._from_proto(await self._get_webhook_proto(webhook_id))

async def _get_webhook_proto(self, webhook_id: str) -> WebhookProto:
request = GetWebhookRequest(webhook_id=webhook_id)
response = await self._grpc_client.get_stub(WebhookServiceStub).GetWebhook(request)
return cast("GetWebhookResponse", response).webhook

async def list_webhooks(
self,
*,
page_size: int | None = DEFAULT_PAGE_SIZE,
page_token: str | None = None,
query_filter: str | None = None,
order_by: str | None = None,
) -> tuple[list[Webhook], str]:
"""List webhooks with optional filtering and pagination.

Args:
page_size: The maximum number of webhooks to return.
page_token: A page token for pagination.
query_filter: A CEL filter string.
order_by: How to order the retrieved webhooks.

Returns:
A tuple of (webhooks, next_page_token).
"""
request_kwargs: dict[str, Any] = {}
if page_size is not None:
request_kwargs["page_size"] = page_size
if page_token is not None:
request_kwargs["page_token"] = page_token
if query_filter is not None:
request_kwargs["filter"] = query_filter
if order_by is not None:
request_kwargs["order_by"] = order_by

request = ListWebhooksRequest(**request_kwargs)
response = await self._grpc_client.get_stub(WebhookServiceStub).ListWebhooks(request)
response = cast("ListWebhooksResponse", response)

webhooks = [Webhook._from_proto(webhook) for webhook in response.webhooks]
return webhooks, response.next_page_token

async def list_all_webhooks(
self,
*,
query_filter: str | None = None,
order_by: str | None = None,
page_size: int | None = DEFAULT_PAGE_SIZE,
max_results: int | None = None,
) -> list[Webhook]:
"""List all webhooks with optional filtering.

Args:
query_filter: A CEL filter string.
order_by: How to order the retrieved webhooks.
page_size: The number of results to fetch per request.
max_results: Maximum number of results to return.

Returns:
A list of all matching webhooks.
"""
return await self._handle_pagination(
self.list_webhooks,
kwargs={"query_filter": query_filter},
order_by=order_by,
max_results=max_results,
page_size=page_size,
)

async def create_webhook(self, *, create: WebhookCreate) -> Webhook:
"""Create a webhook.

Args:
create: The webhook definition.

Returns:
The created Webhook.
"""
request_proto = create.to_proto()
response = await self._grpc_client.get_stub(WebhookServiceStub).CreateWebhook(request_proto)
grpc_webhook = cast("CreateWebhookResponse", response).webhook
return Webhook._from_proto(grpc_webhook)

async def update_webhook(self, update: WebhookUpdate) -> Webhook:
Comment thread
alexluck-sift marked this conversation as resolved.
"""Update select fields of a webhook.

Reads the webhook first and merges the patch over it. UpdateWebhook runs
protovalidate across the whole `Webhook` message and ignores the update mask, so
a sparse patch fails with `webhook.organization_id: value must be a valid UUID`
and three similar errors. The cost is a round trip, and concurrent updates
overwrite each other. Remove this once the service validates only masked fields.

Args:
update: The updates to apply. Its `resource_id` must be set.

Returns:
The updated Webhook.
"""
patch, update_mask = update.to_proto_with_mask()
grpc_webhook = await self._get_webhook_proto(patch.webhook_id)
update_mask.MergeMessage(patch, grpc_webhook, replace_repeated_field=True)
request = UpdateWebhookRequest(webhook=grpc_webhook, update_mask=update_mask)
response = await self._grpc_client.get_stub(WebhookServiceStub).UpdateWebhook(request)
updated_grpc_webhook = cast("UpdateWebhookResponse", response).webhook
return Webhook._from_proto(updated_grpc_webhook)

async def test_webhook(
self,
*,
webhook_id: str | None = None,
create: WebhookCreate | None = None,
) -> WebhookTest:
"""Send a real request to a webhook's target URL. Pass exactly one identifier.

Args:
webhook_id: The ID of an existing webhook to test.
create: An unsaved webhook definition to test.

Returns:
The response the target URL returned.

Raises:
ValueError: If neither or both arguments are provided.
"""
if (webhook_id is None) == (create is None):
raise ValueError("Exactly one of webhook_id or create must be provided")

if webhook_id is not None:
request = TestWebhookRequest(webhook_id=webhook_id)
else:
request = TestWebhookRequest(create_request=cast("WebhookCreate", create).to_proto())

response = await self._grpc_client.get_stub(WebhookServiceStub).TestWebhook(request)
response = cast("TestWebhookResponse", response)
return WebhookTest(
http_response_code=response.http_response_code,
http_response_body=response.http_response_body,
)
1 change: 1 addition & 0 deletions python/lib/sift_client/_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ def mock_client():
client.data_import = MagicMock()
client.data_export = MagicMock()
client.user_defined_functions = MagicMock()
client.webhooks = MagicMock()
client.async_ = MagicMock(spec=AsyncAPIs)
client.async_.ingestion = MagicMock()
return client
Expand Down
Loading
Loading