From fd0230463851a4e63a9c2f3f6b31c61fd3abbfcc Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Mon, 14 Sep 2026 16:42:34 -0700 Subject: [PATCH 01/11] python(feature): add webhooks resource and webhook rule actions --- python/CHANGELOG.md | 1 + .../_internal/low_level_wrappers/webhooks.py | 196 +++++++++++++++ python/lib/sift_client/_tests/conftest.py | 1 + .../_tests/resources/test_webhooks.py | 229 ++++++++++++++++++ .../_tests/sift_types/test_rule.py | 67 +++++ .../_tests/sift_types/test_webhook.py | 222 +++++++++++++++++ python/lib/sift_client/client.py | 7 + python/lib/sift_client/resources/__init__.py | 4 + .../resources/sync_stubs/__init__.py | 3 + python/lib/sift_client/resources/webhooks.py | 219 +++++++++++++++++ python/lib/sift_client/sift_types/__init__.py | 14 ++ python/lib/sift_client/sift_types/rule.py | 33 ++- python/lib/sift_client/sift_types/webhook.py | 205 ++++++++++++++++ python/lib/sift_client/util/util.py | 4 + 14 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 python/lib/sift_client/_internal/low_level_wrappers/webhooks.py create mode 100644 python/lib/sift_client/_tests/resources/test_webhooks.py create mode 100644 python/lib/sift_client/_tests/sift_types/test_webhook.py create mode 100644 python/lib/sift_client/resources/webhooks.py create mode 100644 python/lib/sift_client/sift_types/webhook.py diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 0b4f327568..cc16bc6db3 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -8,6 +8,7 @@ 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. ### What's New diff --git a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py new file mode 100644 index 0000000000..0260665163 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py @@ -0,0 +1,196 @@ +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, + WebhookTestResult, + 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. + + This class provides a thin wrapper around the autogenerated bindings for the WebhooksAPI. + """ + + 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_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 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 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: + """Update select fields of a webhook. + + Fetches the webhook first. WebhookService validates the whole `Webhook` + message, not just the masked fields, so it rejects a sparse patch. + + 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, + ) -> WebhookTestResult: + """Send a live request to a webhook's target URL and return its response. + + Exactly one of `webhook_id` or `create` must be provided. + + 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 WebhookTestResult( + http_response_code=response.http_response_code, + http_response_body=response.http_response_body, + ) diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index 2fd3d35f3f..c1054ecc66 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -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 diff --git a/python/lib/sift_client/_tests/resources/test_webhooks.py b/python/lib/sift_client/_tests/resources/test_webhooks.py new file mode 100644 index 0000000000..4a2d0d14f5 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_webhooks.py @@ -0,0 +1,229 @@ +"""Pytest tests for the Webhooks API. + +These tests demonstrate and validate the usage of the Webhooks API including: +- Basic webhook operations (get, list, find) +- Webhook filtering and searching +- Webhook creation, updates, and archiving +- Error handling and edge cases + +Note that these tests never call `test()`, because that RPC sends a real HTTP +request to the webhook's target URL. +""" + +from datetime import datetime, timezone + +import pytest + +from sift_client import SiftClient +from sift_client.resources import WebhooksAPI, WebhooksAPIAsync +from sift_client.sift_types import Webhook +from sift_client.sift_types.webhook import ( + WebhookCreate, + WebhookEventType, + WebhookHttpHeader, + WebhookUpdate, +) + +pytestmark = pytest.mark.integration + +TEST_TARGET_URL = "https://example.com/sift-client-pytest" + + +def test_client_binding(sift_client): + assert sift_client.webhooks + assert isinstance(sift_client.webhooks, WebhooksAPI) + assert sift_client.async_.webhooks + assert isinstance(sift_client.async_.webhooks, WebhooksAPIAsync) + + +@pytest.fixture +def webhooks_api_async(sift_client: SiftClient): + """Get the async webhooks API instance.""" + return sift_client.async_.webhooks + + +@pytest.fixture +def webhooks_api_sync(sift_client: SiftClient): + """Get the synchronous webhooks API instance.""" + return sift_client.webhooks + + +@pytest.fixture(scope="session") +def test_timestamp_str(): + """A per-session suffix so webhook names stay unique across runs.""" + return datetime.now(timezone.utc).isoformat() + + +@pytest.fixture(scope="session") +def new_webhook(sift_client, test_timestamp_str): + """Create a webhook for the session and archive it on teardown.""" + created = sift_client.webhooks.create( + WebhookCreate( + name=f"test_webhook_{test_timestamp_str}", + target_url=TEST_TARGET_URL, + http_headers={"X-Sift-Client-Pytest": "true"}, + ) + ) + yield created + sift_client.webhooks.archive(created) + + +class TestWebhooks: + """Tests for the Webhooks API.""" + + def test_create(self, new_webhook, test_timestamp_str): + """Test that create returns a fully populated webhook.""" + assert isinstance(new_webhook, Webhook) + assert new_webhook.id_ is not None + assert new_webhook.name == f"test_webhook_{test_timestamp_str}" + assert new_webhook.target_url == TEST_TARGET_URL + assert new_webhook.event_type == WebhookEventType.RULE_VIOLATION + assert new_webhook.http_headers == [ + WebhookHttpHeader(name="X-Sift-Client-Pytest", value="true") + ] + assert new_webhook.is_archived is False + assert new_webhook.organization_id + + def test_get(self, webhooks_api_sync, new_webhook): + """Test getting a webhook by ID.""" + fetched = webhooks_api_sync.get(new_webhook._id_or_error) + + assert isinstance(fetched, Webhook) + assert fetched.id_ == new_webhook.id_ + assert fetched.name == new_webhook.name + + def test_basic_list(self, webhooks_api_sync, new_webhook): + """Test basic webhook listing functionality.""" + webhooks = webhooks_api_sync.list_(limit=5) + + assert isinstance(webhooks, list) + assert len(webhooks) >= 1 + for webhook in webhooks: + assert isinstance(webhook, Webhook) + assert webhook.id_ is not None + + def test_list_with_name_filter(self, webhooks_api_sync, new_webhook): + """Test webhook listing with name filtering.""" + by_name = webhooks_api_sync.list_(name=new_webhook.name) + by_contains = webhooks_api_sync.list_(name_contains=new_webhook.name) + + assert len(by_name) == 1 + assert by_name[0].id_ == new_webhook.id_ + assert by_contains[0].id_ == new_webhook.id_ + + def test_list_with_id_filter(self, webhooks_api_sync, new_webhook): + """Test webhook listing filtered to specific IDs.""" + webhooks = webhooks_api_sync.list_(webhook_ids=[new_webhook._id_or_error]) + + assert len(webhooks) == 1 + assert webhooks[0].id_ == new_webhook.id_ + + def test_list_with_event_type_filter(self, webhooks_api_sync, new_webhook): + """Test webhook listing filtered by event type.""" + webhooks = webhooks_api_sync.list_( + webhook_ids=[new_webhook._id_or_error], + event_type=WebhookEventType.RULE_VIOLATION, + ) + + assert len(webhooks) == 1 + assert webhooks[0].id_ == new_webhook.id_ + + def test_find(self, webhooks_api_sync, new_webhook): + """Test finding a single webhook.""" + found = webhooks_api_sync.find(name=new_webhook.name) + + assert found is not None + assert found.id_ == new_webhook.id_ + + def test_find_nonexistent(self, webhooks_api_sync): + """Test finding a non-existent webhook returns None.""" + found = webhooks_api_sync.find( + name=f"nonexistent_webhook_{datetime.now(timezone.utc).timestamp()}" + ) + assert found is None + + def test_update(self, webhooks_api_sync, new_webhook): + """Test updating a webhook's target URL and headers.""" + updated_url = "https://example.com/sift-client-pytest-updated" + updated = webhooks_api_sync.update( + new_webhook, + WebhookUpdate( + target_url=updated_url, + http_headers={"X-Sift-Client-Pytest": "updated"}, + ), + ) + + assert updated.id_ == new_webhook.id_ + assert updated.target_url == updated_url + assert updated.http_headers == [ + WebhookHttpHeader(name="X-Sift-Client-Pytest", value="updated") + ] + # The name was not in the mask, so it is unchanged. + assert updated.name == new_webhook.name + + # Restore the original target so later tests see a stable fixture. + webhooks_api_sync.update(new_webhook, {"target_url": TEST_TARGET_URL}) + + def test_update_accepts_dict(self, webhooks_api_sync, new_webhook, test_timestamp_str): + """Test that update accepts a plain dict.""" + renamed = f"test_webhook_renamed_{test_timestamp_str}" + updated = webhooks_api_sync.update(new_webhook._id_or_error, {"name": renamed}) + + assert updated.name == renamed + + webhooks_api_sync.update(new_webhook, {"name": new_webhook.name}) + + def test_archive_and_unarchive(self, webhooks_api_sync, test_timestamp_str): + """Test archiving and unarchiving a webhook.""" + webhook = webhooks_api_sync.create( + WebhookCreate( + name=f"test_webhook_archive_{test_timestamp_str}", + target_url=TEST_TARGET_URL, + ) + ) + + archived = webhooks_api_sync.archive(webhook) + assert archived.is_archived is True + + # Archived webhooks are excluded from list_ by default. + assert webhooks_api_sync.find(name=webhook.name) is None + assert webhooks_api_sync.find(name=webhook.name, include_archived=True).id_ == webhook.id_ + + unarchived = webhooks_api_sync.unarchive(webhook) + assert unarchived.is_archived is False + + webhooks_api_sync.archive(webhook) + + def test_instance_methods(self, webhooks_api_sync, test_timestamp_str): + """Test the update and archive methods on the Webhook instance itself.""" + webhook = webhooks_api_sync.create( + WebhookCreate( + name=f"test_webhook_instance_{test_timestamp_str}", + target_url=TEST_TARGET_URL, + ) + ) + + webhook.update({"payload": "custom-payload"}) + assert webhook.payload == "custom-payload" + + webhook.archive() + assert webhook.is_archived is True + + def test_test_requires_exactly_one_argument(self, webhooks_api_sync, new_webhook): + """Test that test() rejects ambiguous arguments before making any request.""" + with pytest.raises(ValueError, match="Exactly one of webhook or create"): + webhooks_api_sync.test() + + with pytest.raises(ValueError, match="Exactly one of webhook or create"): + webhooks_api_sync.test( + new_webhook, + create=WebhookCreate(name="ignored", target_url=TEST_TARGET_URL), + ) + + @pytest.mark.asyncio + async def test_async_list(self, webhooks_api_async, new_webhook): + """Test the async API returns the same webhooks.""" + webhooks = await webhooks_api_async.list_(webhook_ids=[new_webhook._id_or_error]) + + assert len(webhooks) == 1 + assert webhooks[0].id_ == new_webhook.id_ diff --git a/python/lib/sift_client/_tests/sift_types/test_rule.py b/python/lib/sift_client/_tests/sift_types/test_rule.py index f91443c6a9..f528cc84da 100644 --- a/python/lib/sift_client/_tests/sift_types/test_rule.py +++ b/python/lib/sift_client/_tests/sift_types/test_rule.py @@ -220,3 +220,70 @@ def test_from_proto_reads_version_id_when_present(self): ref = rule.channel_references[0] assert ref.calculated_channel == "v-xyz" assert ref.channel_identifier is None + + +class TestRuleActionWebhook: + """Unit tests for the webhook rule action.""" + + WEBHOOK_ID = "3bc0c34b-5c63-446d-845c-2b319b7b1a78" + + def test_from_webhook_id(self): + """Test building the action from a webhook ID string.""" + action = RuleAction.webhook(self.WEBHOOK_ID) + + assert action.action_type == RuleActionType.WEBHOOK + assert action.webhook_id == self.WEBHOOK_ID + + def test_from_webhook_object(self): + """Test building the action from a Webhook instance.""" + webhook = MagicMock() + webhook._id_or_error = self.WEBHOOK_ID + from sift_client.sift_types.webhook import Webhook + + webhook.__class__ = Webhook + + action = RuleAction.webhook(webhook) + + assert action.webhook_id == self.WEBHOOK_ID + + def test_rejects_non_uuid(self): + """Test that a malformed webhook ID is caught before it reaches the server.""" + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + RuleAction.webhook("not-a-uuid") + + def test_to_update_request_sets_webhook_configuration(self): + """Test the action serializes into the webhook branch of the oneof.""" + request = RuleAction.webhook(self.WEBHOOK_ID)._to_update_request() + + assert request.action_type == RuleActionType.WEBHOOK.value + assert request.configuration.webhook.webhook_id == self.WEBHOOK_ID + assert not request.configuration.annotation.tag_ids + + def test_annotation_action_sets_no_webhook(self): + """Test the annotation action is unaffected by the webhook branch.""" + request = RuleAction.annotation( + annotation_type=RuleAnnotationType.DATA_REVIEW, tags=[] + )._to_update_request() + + assert request.configuration.webhook.webhook_id == "" + + def test_from_proto_reads_webhook_id(self): + """Test the webhook ID round trips back out of the proto.""" + from sift.rules.v1.rules_pb2 import ( + ActionKind, + RuleActionConfiguration, + WebhookActionConfiguration, + ) + from sift.rules.v1.rules_pb2 import RuleAction as RuleActionProto + + proto = RuleActionProto( + action_type=ActionKind.WEBHOOK, + configuration=RuleActionConfiguration( + webhook=WebhookActionConfiguration(webhook_id=self.WEBHOOK_ID) + ), + ) + action = RuleAction._from_proto(proto) + + assert action.action_type == RuleActionType.WEBHOOK + assert action.webhook_id == self.WEBHOOK_ID + assert action.annotation_type is None diff --git a/python/lib/sift_client/_tests/sift_types/test_webhook.py b/python/lib/sift_client/_tests/sift_types/test_webhook.py new file mode 100644 index 0000000000..a0c01efa6a --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_webhook.py @@ -0,0 +1,222 @@ +"""Tests for sift_types.Webhook model.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from sift_client.sift_types import Webhook +from sift_client.sift_types.webhook import ( + WebhookCreate, + WebhookEventType, + WebhookHttpHeader, + WebhookUpdate, +) + + +class TestWebhookEventType: + """Unit tests for the WebhookEventType enum.""" + + def test_to_filter_str(self): + """Test the CEL filter representation matches the proto enum value name.""" + assert WebhookEventType.RULE_VIOLATION.to_filter_str() == ( + "WEBHOOK_EVENT_TYPE_RULE_VIOLATION" + ) + + +class TestWebhookCreate: + """Unit tests for WebhookCreate model - tests _to_proto_helpers and validators.""" + + def test_minimal_create(self): + """Test a create with only required fields sets a concrete event type.""" + create = WebhookCreate(name="test_webhook", target_url="https://example.com/hook") + proto = create.to_proto() + + assert proto.name == "test_webhook" + assert proto.target_url == "https://example.com/hook" + # Left unset, the proto would default to UNSPECIFIED, which the server rejects. + assert proto.event_type == WebhookEventType.RULE_VIOLATION.value + + def test_http_headers_list_converter(self): + """Test that a list of headers is converted using _to_proto_helpers.""" + create = WebhookCreate( + name="test_webhook", + target_url="https://example.com/hook", + http_headers=[WebhookHttpHeader(name="X-Token", value="secret")], + ) + proto = create.to_proto() + + assert len(proto.http_headers) == 1 + assert proto.http_headers[0].name == "X-Token" + assert proto.http_headers[0].value == "secret" + + def test_http_headers_dict_is_normalized(self): + """Test that a name-to-value mapping is accepted in place of a list.""" + create = WebhookCreate( + name="test_webhook", + target_url="https://example.com/hook", + http_headers={"X-Token": "secret", "X-Env": "staging"}, + ) + + assert create.http_headers == [ + WebhookHttpHeader(name="X-Token", value="secret"), + WebhookHttpHeader(name="X-Env", value="staging"), + ] + + proto = create.to_proto() + assert {header.name: header.value for header in proto.http_headers} == { + "X-Token": "secret", + "X-Env": "staging", + } + + def test_explicit_event_type_is_preserved(self): + """Test that an explicitly set event type is not overwritten by the default.""" + create = WebhookCreate( + name="test_webhook", + target_url="https://example.com/hook", + event_type=WebhookEventType.RULE_VIOLATION, + ) + assert create.event_type == WebhookEventType.RULE_VIOLATION + + def test_payload_is_optional(self): + """Test that payload is omitted from the proto when not provided.""" + create = WebhookCreate(name="test_webhook", target_url="https://example.com/hook") + proto = create.to_proto() + + assert not proto.HasField("payload") + + @pytest.mark.parametrize("target_url", ["example.com/hook", "ftp://example.com", ""]) + def test_rejects_non_http_target_url(self, target_url): + """Test the target URL validator rejects URLs that are not absolute HTTP(S).""" + with pytest.raises(ValueError, match="target_url must start with"): + WebhookCreate(name="test_webhook", target_url=target_url) + + +class TestWebhookUpdate: + """Unit tests for WebhookUpdate model - tests field masks and validators.""" + + def test_update_mask_only_includes_set_fields(self): + """Test that the field mask covers exactly the fields that were set.""" + update = WebhookUpdate(name="renamed", target_url="https://example.com/v2") + update.resource_id = "test_webhook_id" + + proto, mask = update.to_proto_with_mask() + + assert proto.webhook_id == "test_webhook_id" + assert proto.name == "renamed" + assert proto.target_url == "https://example.com/v2" + assert set(mask.paths) == {"name", "target_url"} + + def test_http_headers_converter(self): + """Test that headers are converted and named in the field mask.""" + update = WebhookUpdate(http_headers={"X-Token": "rotated"}) + update.resource_id = "test_webhook_id" + + proto, mask = update.to_proto_with_mask() + + assert len(proto.http_headers) == 1 + assert proto.http_headers[0].name == "X-Token" + assert proto.http_headers[0].value == "rotated" + assert "http_headers" in mask.paths + + def test_archive_update(self): + """Test that is_archived is masked so archive() takes effect.""" + update = WebhookUpdate(is_archived=True) + update.resource_id = "test_webhook_id" + + proto, mask = update.to_proto_with_mask() + + assert proto.is_archived is True + assert mask.paths == ["is_archived"] + + def test_requires_resource_id(self): + """Test that converting without a resource ID raises.""" + update = WebhookUpdate(name="renamed") + + with pytest.raises(ValueError, match="Resource ID must be set"): + update.to_proto_with_mask() + + def test_rejects_non_http_target_url(self): + """Test the target URL validator also applies to updates.""" + with pytest.raises(ValueError, match="target_url must start with"): + WebhookUpdate(target_url="example.com/hook") + + +@pytest.fixture +def mock_webhook(mock_client): + """Create a mock Webhook instance for testing.""" + webhook = Webhook( + proto=MagicMock(), + id_="test_webhook_id", + name="test_webhook", + organization_id="org1", + target_url="https://example.com/hook", + event_type=WebhookEventType.RULE_VIOLATION, + http_headers=[WebhookHttpHeader(name="X-Token", value="secret")], + created_date=datetime.now(timezone.utc), + modified_date=datetime.now(timezone.utc), + created_by_user_id="user1", + modified_by_user_id="user1", + is_archived=False, + payload=None, + archived_date=None, + ) + webhook._apply_client_to_instance(mock_client) + return webhook + + +class TestWebhook: + """Unit tests for Webhook model - tests methods.""" + + def test_update_calls_client_and_updates_self(self, mock_webhook, mock_client): + """Test that update() calls client.webhooks.update and calls _update.""" + updated_webhook = MagicMock() + mock_client.webhooks.update.return_value = updated_webhook + + with MagicMock() as mock_update: + mock_webhook._update = mock_update + + update = WebhookUpdate(name="renamed") + result = mock_webhook.update(update) + + mock_client.webhooks.update.assert_called_once_with(webhook=mock_webhook, update=update) + mock_update.assert_called_once_with(updated_webhook) + assert result is mock_webhook + + def test_archive_calls_client_and_updates_self(self, mock_webhook, mock_client): + """Test that archive() calls client.webhooks.archive and calls _update.""" + archived_webhook = MagicMock() + mock_client.webhooks.archive.return_value = archived_webhook + + with MagicMock() as mock_update: + mock_webhook._update = mock_update + + result = mock_webhook.archive() + + mock_client.webhooks.archive.assert_called_once_with(webhook=mock_webhook) + mock_update.assert_called_once_with(archived_webhook) + assert result is mock_webhook + + def test_unarchive_calls_client_and_updates_self(self, mock_webhook, mock_client): + """Test that unarchive() calls client.webhooks.unarchive and calls _update.""" + unarchived_webhook = MagicMock() + mock_client.webhooks.unarchive.return_value = unarchived_webhook + + with MagicMock() as mock_update: + mock_webhook._update = mock_update + + result = mock_webhook.unarchive() + + mock_client.webhooks.unarchive.assert_called_once_with(webhook=mock_webhook) + mock_update.assert_called_once_with(unarchived_webhook) + assert result is mock_webhook + + def test_test_calls_client(self, mock_webhook, mock_client): + """Test that test() delegates to client.webhooks.test.""" + expected = MagicMock() + mock_client.webhooks.test.return_value = expected + + result = mock_webhook.test() + + mock_client.webhooks.test.assert_called_once_with(webhook=mock_webhook) + assert result is expected diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index ae5b2a283e..dafb2cd3ed 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -48,6 +48,8 @@ UserDefinedFunctionsAPIAsync, UsersAPI, UsersAPIAsync, + WebhooksAPI, + WebhooksAPIAsync, ) from sift_client.resources.access_control import AccessControlAPI, AccessControlAPIAsync from sift_client.transport import ( @@ -155,6 +157,9 @@ class SiftClient( users: UsersAPI """Instance of the Users API for making synchronous requests.""" + webhooks: WebhooksAPI + """Instance of the Webhooks API for making synchronous requests.""" + data_export: DataExportAPI """Instance of the Data Export API for making synchronous requests.""" @@ -267,6 +272,7 @@ def __init__( self.test_results = TestResultsAPI(self) self.user_defined_functions = UserDefinedFunctionsAPI(self) self.users = UsersAPI(self) + self.webhooks = WebhooksAPI(self) self.data_export = DataExportAPI(self) self.data_import = DataImportAPI(self) @@ -292,6 +298,7 @@ def __init__( test_results=TestResultsAPIAsync(self), user_defined_functions=UserDefinedFunctionsAPIAsync(self), users=UsersAPIAsync(self), + webhooks=WebhooksAPIAsync(self), data_export=DataExportAPIAsync(self), data_import=DataImportAPIAsync(self), ) diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 0e8c0e1bfe..65bb284fa0 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -187,6 +187,7 @@ async def main(): UserDefinedFunctionVersionsAPIAsync, ) from sift_client.resources.users import UsersAPIAsync +from sift_client.resources.webhooks import WebhooksAPIAsync from sift_client.resources.data_imports import DataImportAPIAsync from sift_client.resources.exports import DataExportAPIAsync @@ -217,6 +218,7 @@ async def main(): UserDefinedFunctionsAPI, UserDefinedFunctionVersionsAPI, UsersAPI, + WebhooksAPI, FileAttachmentsAPI, DataExportAPI, DataImportAPI, @@ -288,6 +290,8 @@ async def main(): "UserDefinedFunctionsAPIAsync", "UsersAPI", "UsersAPIAsync", + "WebhooksAPI", + "WebhooksAPIAsync", "DataExportAPI", "DataExportAPIAsync", "DataImportAPI", diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 9f1b9240b6..00c204e9fd 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -32,6 +32,7 @@ UserDefinedFunctionsAPIAsync, UserDefinedFunctionVersionsAPIAsync, UsersAPIAsync, + WebhooksAPIAsync, ) PingAPI = generate_sync_api(PingAPIAsync, "PingAPI") @@ -103,6 +104,7 @@ nested_resources={"versions": UserDefinedFunctionVersionsAPI}, ) UsersAPI = generate_sync_api(UsersAPIAsync, "UsersAPI") +WebhooksAPI = generate_sync_api(WebhooksAPIAsync, "WebhooksAPI") DataExportAPI = generate_sync_api(DataExportAPIAsync, "DataExportAPI") DataImportAPI = generate_sync_api(DataImportAPIAsync, "DataImportAPI") @@ -135,4 +137,5 @@ "UserDefinedFunctionVersionsAPI", "UserDefinedFunctionsAPI", "UsersAPI", + "WebhooksAPI", ] diff --git a/python/lib/sift_client/resources/webhooks.py b/python/lib/sift_client/resources/webhooks.py new file mode 100644 index 0000000000..06f84d3454 --- /dev/null +++ b/python/lib/sift_client/resources/webhooks.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.webhooks import WebhooksLowLevelClient +from sift_client.resources._base import ResourceBase +from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookEventType, + WebhookTestResult, + WebhookUpdate, +) +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + + from sift_client.client import SiftClient + + +class WebhooksAPIAsync(ResourceBase): + """High-level API for interacting with webhooks. + + A webhook registers an HTTP endpoint that Sift calls when an event occurs, such as a + rule violation. This class provides a Pythonic, notebook-friendly interface for + interacting with the WebhooksAPI. It handles automatic handling of gRPC services, + seamless type conversion, and clear error handling. + + All methods in this class use the Webhook class from the low-level wrapper, which is a + user-friendly representation of a webhook using standard Python data structures and types. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the WebhooksAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = WebhooksLowLevelClient(grpc_client=self.client.grpc_client) + + async def get(self, webhook_id: str) -> Webhook: + """Get a Webhook. + + Args: + webhook_id: The ID of the webhook. + + Returns: + The Webhook. + """ + webhook = await self._low_level_client.get_webhook(webhook_id=webhook_id) + return self._apply_client_to_instance(webhook) + + async def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + # self ids + webhook_ids: list[str] | None = None, + # webhook specific + event_type: WebhookEventType | None = None, + # common filters + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[Webhook]: + """List webhooks with optional filtering. + + Args: + name: Exact name of the webhook. + names: List of webhook names to filter by. + name_contains: Partial name of the webhook. + name_regex: Regular expression to filter webhooks by name. + webhook_ids: Filter to webhooks with any of these IDs. + event_type: Filter to webhooks triggered by this event type. + include_archived: If True, include archived webhooks in results. + filter_query: Explicit CEL query to filter webhooks. + order_by: Field and direction to order results by. Only `created_date` is + supported, e.g. "created_date desc". + limit: Maximum number of webhooks to return. If None, returns all matches. + page_size: Number of results to fetch per request. Lower this if you hit gRPC + message size limits on responses. If None, uses the server default. + + Returns: + A list of Webhook objects that match the filter criteria. + """ + filter_parts = [ + *self._build_name_cel_filters( + name=name, names=names, name_contains=name_contains, name_regex=name_regex + ), + *self._build_common_cel_filters( + include_archived=include_archived, + filter_query=filter_query, + ), + ] + if webhook_ids: + filter_parts.append(cel.in_("webhook_id", webhook_ids)) + if event_type: + filter_parts.append(cel.equals("event_type", event_type.to_filter_str())) + query_filter = cel.and_(*filter_parts) + + webhooks = await self._low_level_client.list_all_webhooks( + query_filter=query_filter or None, + order_by=order_by, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(webhooks) + + async def find(self, **kwargs) -> Webhook | None: + """Find a single webhook matching the given query. Takes the same arguments as `list_`. + If more than one webhook is found, raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The Webhook found or None. + """ + webhooks = await self.list_(**kwargs) + if len(webhooks) > 1: + raise ValueError(f"Multiple ({len(webhooks)}) webhooks found for query") + elif len(webhooks) == 1: + return webhooks[0] + return None + + async def create(self, create: WebhookCreate | dict) -> Webhook: + """Create a new webhook. + + Args: + create: The webhook definition. `http_headers` accepts either a list of + WebhookHttpHeader or a `{name: value}` mapping. + + Returns: + The created Webhook. + """ + if isinstance(create, dict): + create = WebhookCreate.model_validate(create) + created_webhook = await self._low_level_client.create_webhook(create=create) + return self._apply_client_to_instance(created_webhook) + + async def update(self, webhook: str | Webhook, update: WebhookUpdate | dict) -> Webhook: + """Update a Webhook. + + Note that `http_headers` is replaced wholesale, not merged. + + Args: + webhook: The Webhook or webhook ID to update. + update: Updates to apply to the Webhook. + + Returns: + The updated Webhook. + """ + webhook_id = webhook._id_or_error if isinstance(webhook, Webhook) else webhook + if isinstance(update, dict): + update = WebhookUpdate.model_validate(update) + update.resource_id = webhook_id + updated_webhook = await self._low_level_client.update_webhook(update) + return self._apply_client_to_instance(updated_webhook) + + async def archive(self, webhook: str | Webhook) -> Webhook: + """Archive a webhook. Archived webhooks stop receiving events. + + Args: + webhook: The Webhook or webhook ID to archive. + + Returns: + The archived Webhook. + """ + return await self.update(webhook, WebhookUpdate(is_archived=True)) + + async def unarchive(self, webhook: str | Webhook) -> Webhook: + """Unarchive a webhook. + + Args: + webhook: The Webhook or webhook ID to unarchive. + + Returns: + The unarchived Webhook. + """ + return await self.update(webhook, WebhookUpdate(is_archived=False)) + + async def test( + self, + webhook: str | Webhook | None = None, + *, + create: WebhookCreate | dict | None = None, + ) -> WebhookTestResult: + """Send a live request to a webhook's target URL and return its response. + + This performs a real HTTP request against the target URL. Exactly one of `webhook` + or `create` must be provided. Use `create` to check an endpoint before saving it. + + Args: + webhook: The Webhook or webhook ID 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 is None) == (create is None): + raise ValueError("Exactly one of webhook or create must be provided") + + if webhook is not None: + webhook_id = webhook._id_or_error if isinstance(webhook, Webhook) else webhook + return await self._low_level_client.test_webhook(webhook_id=webhook_id) + + if isinstance(create, dict): + create = WebhookCreate.model_validate(create) + return await self._low_level_client.test_webhook(create=create) diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index a6d4f647e8..5b078fe9d4 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -247,6 +247,14 @@ UserDefinedFunctionValidation, UserDefinedFunctionVersion, ) +from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookEventType, + WebhookHttpHeader, + WebhookTestResult, + WebhookUpdate, +) if "pytest" in sys.modules: # These are not test classes, so we need to set __test__ to False to avoid pytest warnings. @@ -365,4 +373,10 @@ "UserDefinedFunctionValidation", "UserDefinedFunctionVersion", "UserOrganization", + "Webhook", + "WebhookCreate", + "WebhookEventType", + "WebhookHttpHeader", + "WebhookTestResult", + "WebhookUpdate", ] diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index 5112b964c3..d6ec56c102 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -5,12 +5,14 @@ from typing import TYPE_CHECKING from uuid import UUID +from pydantic import field_validator from sift.rules.v1.rules_pb2 import ( ActionKind, AnnotationActionConfiguration, CalculatedChannelConfig, RuleActionConfiguration, UpdateActionRequest, + WebhookActionConfiguration, ) # Extract nested class. @@ -31,12 +33,13 @@ ) from sift_client.sift_types._base import BaseType, ModelCreate, ModelCreateUpdateBase, ModelUpdate +from sift_client.sift_types.asset import Asset from sift_client.sift_types.channel import ChannelReference from sift_client.sift_types.tag import Tag +from sift_client.sift_types.webhook import Webhook if TYPE_CHECKING: from sift_client.client import SiftClient - from sift_client.sift_types.asset import Asset class Rule(BaseType[RuleProto, "Rule"]): @@ -167,9 +170,14 @@ def _from_proto(cls, proto: RuleProto, sift_client: SiftClient | None = None) -> class RuleCreateUpdateBase(ModelCreateUpdateBase): """Base class for Rule create and update models with shared fields and validation.""" + @field_validator("asset_ids", mode="after") + @classmethod + def _asset_ids_to_strings(cls, value): + return [a.id_ if isinstance(a, Asset) else a for a in value] if value else value + organization_id: str | None = None client_key: str | None = None - asset_ids: list[str] | None = None + asset_ids: list[str | Asset] | None = None asset_tag_ids: list[str] | None = None contextual_channels: list[str] | None = None is_external: bool = False @@ -282,6 +290,17 @@ class RuleAction(BaseType[RuleActionProto, "RuleAction"]): annotation_type: RuleAnnotationType | None = None tags_ids: list[str] | None = None default_assignee_user: str | None = None + webhook_id: str | None = None + + @classmethod + def webhook(cls, webhook: str | Webhook) -> RuleAction: + """Create a webhook action. + + Args: + webhook: The Webhook or webhook ID to call when the rule is violated. + """ + webhook_id = webhook.id_ if isinstance(webhook, Webhook) else webhook + return cls(action_type=RuleActionType.WEBHOOK, webhook_id=str(UUID(webhook_id))) @classmethod def annotation( @@ -341,6 +360,11 @@ def _from_proto( if action_type == RuleActionType.ANNOTATION else None ), + webhook_id=( + proto.configuration.webhook.webhook_id + if action_type == RuleActionType.WEBHOOK + else None + ), _client=sift_client, ) @@ -358,6 +382,11 @@ def _to_update_request(self) -> UpdateActionRequest: if self.action_type == RuleActionType.ANNOTATION else None ), + webhook=( + WebhookActionConfiguration(webhook_id=self.webhook_id) + if self.action_type == RuleActionType.WEBHOOK + else None + ), ), ) diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py new file mode 100644 index 0000000000..718f81dc27 --- /dev/null +++ b/python/lib/sift_client/sift_types/webhook.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, ClassVar + +from pydantic import BaseModel, field_validator, model_validator +from sift.webhooks.v1.webhooks_pb2 import CreateWebhookRequest as CreateWebhookRequestProto +from sift.webhooks.v1.webhooks_pb2 import Webhook as WebhookProto +from sift.webhooks.v1.webhooks_pb2 import WebhookEventType as WebhookEventTypeProto +from sift.webhooks.v1.webhooks_pb2 import WebhookHttpHeader as WebhookHttpHeaderProto + +from sift_client.sift_types._base import ( + BaseType, + MappingHelper, + ModelCreate, + ModelCreateUpdateBase, + ModelUpdate, +) + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class WebhookEventType(Enum): + """Enum for the events that trigger a webhook.""" + + UNSPECIFIED = WebhookEventTypeProto.WEBHOOK_EVENT_TYPE_UNSPECIFIED # 0 + RULE_VIOLATION = WebhookEventTypeProto.WEBHOOK_EVENT_TYPE_RULE_VIOLATION # 1 + + def to_filter_str(self) -> str: + """Convert to the string used in CEL filters.""" + return f"WEBHOOK_EVENT_TYPE_{self.name}" + + +class WebhookHttpHeader(BaseModel): + """An HTTP header sent with every request to the webhook's target URL. + + Attributes: + name: The header name. + value: The header value. + """ + + name: str + value: str + + @classmethod + def _from_proto(cls, proto: WebhookHttpHeaderProto) -> WebhookHttpHeader: + return cls(name=proto.name, value=proto.value) + + +class WebhookTestResult(BaseModel): + """The response the target URL returned for a test request. + + Attributes: + http_response_code: The HTTP status code the target returned. + http_response_body: The raw response body the target returned. + """ + + http_response_code: int + http_response_body: bytes + + +class Webhook(BaseType[WebhookProto, "Webhook"]): + """Webhook model representing an HTTP callback registered with Sift.""" + + # Required fields + name: str + organization_id: str + target_url: str + event_type: WebhookEventType + http_headers: list[WebhookHttpHeader] + created_date: datetime + modified_date: datetime + created_by_user_id: str + modified_by_user_id: str + is_archived: bool + + # Optional fields + payload: str | None + archived_date: datetime | None + + @classmethod + def _from_proto(cls, proto: WebhookProto, sift_client: SiftClient | None = None) -> Webhook: + return cls( + proto=proto, + id_=proto.webhook_id, + name=proto.name, + organization_id=proto.organization_id, + target_url=proto.target_url, + event_type=WebhookEventType(proto.event_type), + http_headers=[WebhookHttpHeader._from_proto(header) for header in proto.http_headers], + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), + created_by_user_id=proto.created_by_user_id, + modified_by_user_id=proto.modified_by_user_id, + is_archived=proto.is_archived, + payload=proto.payload if proto.HasField("payload") else None, + archived_date=( + proto.archived_date.ToDatetime(tzinfo=timezone.utc) + if proto.HasField("archived_date") + else None + ), + _client=sift_client, + ) + + def update(self, update: WebhookUpdate | dict) -> Webhook: + """Update the Webhook. + + Args: + update: The update to apply to the webhook. See WebhookUpdate for updatable fields. + + Returns: + The updated webhook. + """ + updated_webhook = self.client.webhooks.update(webhook=self, update=update) + self._update(updated_webhook) + return self + + def archive(self) -> Webhook: + """Archive the webhook. Archived webhooks stop receiving events.""" + updated_webhook = self.client.webhooks.archive(webhook=self) + self._update(updated_webhook) + return self + + def unarchive(self) -> Webhook: + """Unarchive the webhook.""" + updated_webhook = self.client.webhooks.unarchive(webhook=self) + self._update(updated_webhook) + return self + + def test(self) -> WebhookTestResult: + """Send a live request to this webhook's target URL and return its response. + + This performs a real HTTP request against `target_url`. + """ + return self.client.webhooks.test(webhook=self) + + +class WebhookBase(ModelCreateUpdateBase): + """Base class for Webhook create and update models with shared fields and validation.""" + + target_url: str | None = None + event_type: WebhookEventType | None = None + payload: str | None = None + http_headers: list[WebhookHttpHeader] | dict[str, str] | None = None + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "http_headers": MappingHelper( + proto_attr_path="http_headers", + update_field="http_headers", + converter=WebhookHttpHeaderProto, + ), + } + + @field_validator("http_headers", mode="before") + @classmethod + def _normalize_http_headers(cls, value): + """Accept a ``{name: value}`` mapping in place of a list of headers.""" + if isinstance(value, dict): + return [WebhookHttpHeader(name=name, value=header) for name, header in value.items()] + return value + + @model_validator(mode="after") + def _validate_target_url(self): + """Reject target URLs that are not absolute HTTP(S) URLs.""" + if self.target_url is not None and not self.target_url.startswith(("http://", "https://")): + raise ValueError("target_url must start with http:// or https://") + return self + + +class WebhookCreate(WebhookBase, ModelCreate[CreateWebhookRequestProto]): + """Create model for Webhook.""" + + name: str + target_url: str + + def _get_proto_class(self) -> type[CreateWebhookRequestProto]: + return CreateWebhookRequestProto + + @model_validator(mode="after") + def _default_event_type(self): + """Default to the only event type Sift emits. + + Assign here, not as a field default. `to_proto` excludes unset fields, so a + field default would send `UNSPECIFIED`. + """ + if self.event_type is None: + self.event_type = WebhookEventType.RULE_VIOLATION + return self + + +class WebhookUpdate(WebhookBase, ModelUpdate[WebhookProto]): + """Update model for Webhook.""" + + name: str | None = None + is_archived: bool | None = None + + def _get_proto_class(self) -> type[WebhookProto]: + return WebhookProto + + def _add_resource_id_to_proto(self, proto_msg: WebhookProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.webhook_id = self._resource_id diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 5b46fc1695..a9da1b9206 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -22,6 +22,7 @@ TestResultsAPIAsync, UserDefinedFunctionsAPIAsync, UsersAPIAsync, + WebhooksAPIAsync, ) from sift_client.resources.access_control import AccessControlAPIAsync @@ -80,6 +81,9 @@ class AsyncAPIs(NamedTuple): users: UsersAPIAsync """Instance of the Users API for making asynchronous requests.""" + webhooks: WebhooksAPIAsync + """Instance of the Webhooks API for making asynchronous requests.""" + data_export: DataExportAPIAsync """Instance of the Data Export API for making asynchronous requests.""" From bc48cac779958f35022aaf4e7ce76d685a3ff4eb Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Mon, 14 Sep 2026 16:53:20 -0700 Subject: [PATCH 02/11] update wording --- .../_internal/low_level_wrappers/webhooks.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py index 0260665163..d3feb6bae5 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py @@ -47,11 +47,6 @@ def __init__(self, grpc_client: GrpcClient): """ super().__init__(grpc_client) - 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 get_webhook(self, webhook_id: str) -> Webhook: """Get a webhook by webhook_id. @@ -63,6 +58,11 @@ async def get_webhook(self, webhook_id: str) -> 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, *, From 908b57850813ab0e33925bfdaacd8f50462a68bc Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Mon, 14 Sep 2026 17:05:40 -0700 Subject: [PATCH 03/11] fix ci tests --- python/lib/sift_client/_tests/sift_types/test_rule.py | 6 +++--- python/lib/sift_client/sift_types/rule.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/lib/sift_client/_tests/sift_types/test_rule.py b/python/lib/sift_client/_tests/sift_types/test_rule.py index f528cc84da..1b2a6e1c19 100644 --- a/python/lib/sift_client/_tests/sift_types/test_rule.py +++ b/python/lib/sift_client/_tests/sift_types/test_rule.py @@ -236,11 +236,11 @@ def test_from_webhook_id(self): def test_from_webhook_object(self): """Test building the action from a Webhook instance.""" - webhook = MagicMock() - webhook._id_or_error = self.WEBHOOK_ID + from sift.webhooks.v1.webhooks_pb2 import Webhook as WebhookProto + from sift_client.sift_types.webhook import Webhook - webhook.__class__ = Webhook + webhook = Webhook._from_proto(WebhookProto(webhook_id=self.WEBHOOK_ID)) action = RuleAction.webhook(webhook) diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index d6ec56c102..41486add2e 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -383,7 +383,7 @@ def _to_update_request(self) -> UpdateActionRequest: else None ), webhook=( - WebhookActionConfiguration(webhook_id=self.webhook_id) + WebhookActionConfiguration(webhook_id=self.webhook_id) # type: ignore if self.action_type == RuleActionType.WEBHOOK else None ), From 0ab123cc0c315ed9a52c5fd5b0bc043c9db5771c Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 15 Sep 2026 10:23:51 -0700 Subject: [PATCH 04/11] pyright model --- python/lib/sift_client/sift_types/webhook.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py index 718f81dc27..a11c128df4 100644 --- a/python/lib/sift_client/sift_types/webhook.py +++ b/python/lib/sift_client/sift_types/webhook.py @@ -140,7 +140,6 @@ def test(self) -> WebhookTestResult: class WebhookBase(ModelCreateUpdateBase): """Base class for Webhook create and update models with shared fields and validation.""" - target_url: str | None = None event_type: WebhookEventType | None = None payload: str | None = None http_headers: list[WebhookHttpHeader] | dict[str, str] | None = None @@ -164,7 +163,8 @@ def _normalize_http_headers(cls, value): @model_validator(mode="after") def _validate_target_url(self): """Reject target URLs that are not absolute HTTP(S) URLs.""" - if self.target_url is not None and not self.target_url.startswith(("http://", "https://")): + target_url = getattr(self, "target_url", None) + if target_url is not None and not target_url.startswith(("http://", "https://")): raise ValueError("target_url must start with http:// or https://") return self @@ -194,6 +194,7 @@ class WebhookUpdate(WebhookBase, ModelUpdate[WebhookProto]): """Update model for Webhook.""" name: str | None = None + target_url: str | None = None is_archived: bool | None = None def _get_proto_class(self) -> type[WebhookProto]: From 037fad1653f15a91b168674ec9482a0a1790b2a2 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Mon, 21 Sep 2026 09:00:41 -0700 Subject: [PATCH 05/11] address review: keep RuleAction.annotation, drop Ref alias, accept Asset in asset_ids --- python/CHANGELOG.md | 1 + .../_internal/low_level_wrappers/rules.py | 7 +- .../_internal/low_level_wrappers/webhooks.py | 6 +- .../resources/sync_stubs/__init__.pyi | 665 ++++-------------- python/lib/sift_client/resources/webhooks.py | 14 +- python/lib/sift_client/sift_types/webhook.py | 11 +- 6 files changed, 135 insertions(+), 569 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index cc16bc6db3..ee80fba96a 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - 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.asset_ids` and `RuleUpdate.asset_ids` now accept `Asset` objects as well as IDs. ### What's New diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index 5a49acf113..c0404cb173 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -156,7 +156,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( @@ -296,7 +296,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 [] ), diff --git a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py index d3feb6bae5..75a3423581 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py @@ -36,7 +36,7 @@ class WebhooksLowLevelClient(LowLevelClientBase, WithGrpcClient): """Low-level client for the WebhooksAPI. - This class provides a thin wrapper around the autogenerated bindings for the WebhooksAPI. + A thin wrapper around the autogenerated bindings for the WebhookService. """ def __init__(self, grpc_client: GrpcClient): @@ -166,9 +166,7 @@ async def test_webhook( webhook_id: str | None = None, create: WebhookCreate | None = None, ) -> WebhookTestResult: - """Send a live request to a webhook's target URL and return its response. - - Exactly one of `webhook_id` or `create` must be provided. + """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. diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 2ce2e65ee3..cd4796ae4c 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: import re - from collections.abc import Iterable from datetime import datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING, Any, Sequence @@ -18,16 +17,6 @@ if TYPE_CHECKING: ReplayResult, ) from sift_client.client import SiftClient - from sift_client.sift_types.annotation import ( - Annotation, - AnnotationCommentElement, - AnnotationCreateBase, - AnnotationLog, - AnnotationLogKind, - AnnotationState, - AnnotationType, - AnnotationUpdate, - ) from sift_client.sift_types.asset import Asset, AssetUpdate from sift_client.sift_types.calculated_channel import ( CalculatedChannel, @@ -102,324 +91,14 @@ if TYPE_CHECKING: TestStepUpdate, ) from sift_client.sift_types.user import User - from sift_client.sift_types.user_defined_function import ( - FunctionInput, - FunctionUsage, - UserDefinedFunction, - UserDefinedFunctionCreate, - UserDefinedFunctionUpdate, - UserDefinedFunctionValidation, - UserDefinedFunctionVersion, + from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookEventType, + WebhookTestResult, + WebhookUpdate, ) -class AnnotationLogsAPI: - """Sync counterpart to `AnnotationLogsAPIAsync`. - - High-level API for an annotation's history. - - Each log records one event: an assignment, a state change, or a comment. - Reachable as `client.annotations.logs`. - """ - - def __init__(self, sift_client: SiftClient): - """Initialize the AnnotationLogsAPI. - - Args: - sift_client: The Sift client to use. - """ - ... - - def _run(self, coro): ... - def add_comment( - self, annotation: str | Annotation, text: str | list[AnnotationCommentElement] - ) -> AnnotationLog: - """Add a comment to an annotation. - - Args: - annotation: The Annotation or annotation ID to comment on. - text: Plain text, or a list of elements to mix text with user mentions. - - Returns: - The created AnnotationLog. - """ - ... - - def list_( - self, - *, - annotation: str | Annotation, - annotation_logs: list[str | AnnotationLog] | None = None, - created_after: datetime | None = None, - created_before: datetime | None = None, - modified_after: datetime | None = None, - modified_before: datetime | None = None, - created_by: Any | str | None = None, - kind: AnnotationLogKind | None = None, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[AnnotationLog]: - """List annotation logs. - - Args: - annotation: The Annotation or annotation ID whose history to list. - annotation_logs: Filter to these AnnotationLogs or log IDs. - created_after: Filter logs created after this datetime. - created_before: Filter logs created before this datetime. - modified_after: Filter logs modified after this datetime. - modified_before: Filter logs modified before this datetime. - created_by: Filter logs created by this user ID. - kind: Filter to comments, state updates, or assignments. - filter_query: Explicit CEL query to filter logs. - order_by: Field and direction to order results by. - limit: Maximum number of logs to return. If None, returns all matches. - page_size: Number of results to fetch per request. - - Returns: - A list of AnnotationLog objects that match the filter criteria. - """ - ... - -class AnnotationsAPI: - """Sync counterpart to `AnnotationsAPIAsync`. - - High-level API for interacting with annotations. - - An annotation marks a time range on one or more assets. A data review annotation - carries a review state and an assignee. A phase annotation marks a segment of a run - and carries no state. - """ - - def __init__(self, sift_client: SiftClient): - """Initialize the AnnotationsAPI. - - Args: - sift_client: The Sift client to use. - """ - ... - - def _run(self, coro): ... - def archive(self, annotation: str | Annotation) -> Annotation: - """Archive an annotation. - - Args: - annotation: The Annotation or annotation ID to archive. - - Returns: - The archived Annotation. - """ - ... - - def assign_to_user(self, annotation: str | Annotation, user: str | User) -> Annotation: - """Assign an annotation to a user for review. - - Args: - annotation: The Annotation or annotation ID to assign. - user: The User or user ID to assign to. - - Returns: - The updated Annotation. - """ - ... - - def batch_archive(self, annotations: list[str | Annotation]) -> list[Annotation]: - """Archive many annotations, one call per `BATCH_LIMIT` of them. - - Args: - annotations: The Annotations or annotation IDs to archive. - - Returns: - The archived Annotations. - """ - ... - - def batch_unarchive(self, annotations: list[str | Annotation]) -> list[Annotation]: - """Unarchive many annotations, one call per `BATCH_LIMIT` of them. - - Args: - annotations: The Annotations or annotation IDs to unarchive. - - Returns: - The unarchived Annotations. - """ - ... - - def create(self, create: AnnotationCreateBase | dict) -> Annotation: - """Create an annotation. - - Pass an `AnnotationCreate` for a data review or a `PhaseCreate` for a phase. A - dict picks the model from its `annotation_type`, which may be a name or a number. - - Args: - create: The annotation definition. `assets` takes Assets or asset IDs, - `tags` takes Tags or tag names. - - Returns: - The created Annotation. - """ - ... - - def find(self, **kwargs) -> Annotation | None: - """Find one annotation. Takes the same arguments as `list_`. - - Raises if more than one matches. - - Args: - **kwargs: Keyword arguments to pass to `list_`. - - Returns: - The Annotation found or None. - """ - ... - - def get(self, *, annotation_id: str) -> Annotation: - """Get an Annotation. - - Args: - annotation_id: The ID of the annotation. - - Returns: - The Annotation. - """ - ... - - def list_( - self, - *, - name: str | Iterable[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - annotation_ids: list[str] | None = None, - created_after: datetime | None = None, - created_before: datetime | None = None, - modified_after: datetime | None = None, - modified_before: datetime | None = None, - created_by: Any | str | None = None, - modified_by: Any | str | None = None, - tags: list[str] | list[Tag] | None = None, - metadata: dict[str, Any] | None = None, - annotation_type: AnnotationType | None = None, - state: AnnotationState | None = None, - assigned_to: Any | str | None = None, - pending: bool | None = None, - assets: list[Asset] | list[str] | None = None, - runs: list[Run] | list[str] | None = None, - rules: list[str | Rule] | None = None, - reports: list[str | Report] | None = None, - start_time_after: datetime | None = None, - start_time_before: datetime | None = None, - end_time_after: datetime | None = None, - end_time_before: datetime | None = None, - description_contains: str | None = None, - include_archived: bool = False, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[Annotation]: - """List annotations. - - Args: - name: Exact name, or any iterable of names to match against. - name_contains: Partial name of the annotation. - name_regex: Regular expression to filter annotations by name. - annotation_ids: Filter to annotations with any of these IDs. - created_after: Filter annotations created after this datetime. - created_before: Filter annotations created before this datetime. - modified_after: Filter annotations modified after this datetime. - modified_before: Filter annotations modified before this datetime. - created_by: Filter annotations created by this user ID. - modified_by: Filter annotations last modified by this user ID. - tags: Filter annotations with any of these Tags or tag names. - metadata: Filter annotations by metadata criteria. - annotation_type: Filter to DATA_REVIEW or PHASE annotations. - state: Filter to a review state. - assigned_to: Filter to annotations assigned to this user's name. - pending: Filter to annotations from an ongoing rule violation. - rules: Filter to annotations created by any of these Rules or rule IDs. - reports: Filter to annotations in any of these Reports or report IDs. - assets: Filter annotations on any of these Assets or asset IDs. - runs: Filter annotations on any of these Runs or run IDs. - start_time_after: Filter annotations that start after this datetime. - start_time_before: Filter annotations that start before this datetime. - end_time_after: Filter annotations that end after this datetime. - end_time_before: Filter annotations that end before this datetime. - description_contains: Partial description of the annotation. - include_archived: If True, include archived annotations in results. - filter_query: Explicit CEL query to filter annotations. - order_by: Field and direction to order results by. - limit: Maximum number of annotations to return. If None, returns all matches. - page_size: Number of results to fetch per request. Lower this if you hit gRPC - message size limits on responses. If None, uses the server default. - - Returns: - A list of Annotation objects that match the filter criteria. - """ - ... - - def set_accepted(self, annotation: str | Annotation) -> Annotation: - """Set the review state to Accepted. - - Args: - annotation: The Annotation or annotation ID. - - Returns: - The updated Annotation. - """ - ... - - def set_failed(self, annotation: str | Annotation) -> Annotation: - """Set the review state to Failed. - - Args: - annotation: The Annotation or annotation ID. - - Returns: - The updated Annotation. - """ - ... - - def set_open(self, annotation: str | Annotation) -> Annotation: - """Set the review state to Open. - - Args: - annotation: The Annotation or annotation ID. - - Returns: - The updated Annotation. - """ - ... - - def unarchive(self, annotation: str | Annotation) -> Annotation: - """Unarchive an annotation. - - Args: - annotation: The Annotation or annotation ID to unarchive. - - Returns: - The unarchived Annotation. - """ - ... - - def update(self, annotation: str | Annotation, update: AnnotationUpdate | dict) -> Annotation: - """Update an Annotation. - - `tags`, `linked_channels`, and `metadata` are replaced, not merged. - - Args: - annotation: The Annotation or annotation ID to update. - update: Updates to apply to the Annotation. - - Returns: - The updated Annotation. - """ - ... - @property - def logs(self) -> AnnotationLogsAPI: - """Nested AnnotationLogsAPI for making synchronous requests.""" - ... - class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. @@ -3991,17 +3670,16 @@ class TestResultsAPI: """ ... -class UserDefinedFunctionVersionsAPI: - """Sync counterpart to `UserDefinedFunctionVersionsAPIAsync`. +class UsersAPI: + """Sync counterpart to `UsersAPIAsync`. - High-level API for a function's version history. + High-level API for users. - Every save produces a new version. Reachable as - `client.user_defined_functions.versions`. + A user's ``name`` is their login name, typically their email address. """ def __init__(self, sift_client: SiftClient): - """Initialize the UserDefinedFunctionVersionsAPI. + """Initialize the UsersAPI. Args: sift_client: The Sift client to use. @@ -4009,72 +3687,87 @@ class UserDefinedFunctionVersionsAPI: ... def _run(self, coro): ... - def batch_get( - self, *, versions: list[str] | list[UserDefinedFunctionVersion] - ) -> list[UserDefinedFunctionVersion]: - """Get many versions in one call. - - Args: - versions: The UserDefinedFunctionVersions or version IDs. + def find(self, **kwargs) -> User | None: + """Find a single user matching the query. Raises if more than one matches. - Returns: - The UserDefinedFunctionVersions. + Takes the same arguments as ``list_``. """ ... - def get(self, *, version: str | UserDefinedFunctionVersion) -> UserDefinedFunctionVersion: - """Get one version. + def get(self, *, user_id: str) -> User: + """Get a user by ID. Args: - version: The UserDefinedFunctionVersion or version ID. + user_id: The ID of the user to retrieve. Returns: - The UserDefinedFunctionVersion. + The User. """ ... def list_( self, *, - user_defined_function: str | UserDefinedFunction | None = None, name: str | None = None, - version: int | None = None, - include_archived: bool = False, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + include_inactive: bool = False, + organization_id: str | None = None, filter_query: str | None = None, order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[UserDefinedFunctionVersion]: - """List a function's versions. + ) -> list[User]: + """List users with optional filtering. Args: - user_defined_function: The UserDefinedFunction or function ID whose versions to list. - name: The function name, as an alternative to `user_defined_function`. - version: Filter to a single version number. - include_archived: If True, include archived versions in results. - filter_query: Explicit CEL query to filter versions. - order_by: Field and direction to order results by. - limit: Maximum number of versions to return. If None, returns all matches. - page_size: Number of results to fetch per request. + name: Exact login name (typically the email address). + names: Login names to filter by. + name_contains: Substring match on the login name. + name_regex: Regex match on the login name. + include_inactive: If True, include inactive users. + organization_id: Scope the search to this organization. Only supported when + listing active users. + filter_query: Explicit CEL query. + order_by: Field and direction to order by. + limit: Maximum number of users to return. + page_size: Results to fetch per request. Returns: - A list of UserDefinedFunctionVersion objects in the service's order, which is - by name unless `order_by` says otherwise. Every version shares the function's - name, so pass `order_by="version desc"` for newest first. + The matching users. """ ... -class UserDefinedFunctionsAPI: - """Sync counterpart to `UserDefinedFunctionsAPIAsync`. + def resolve_ids(self, emails: list[str]) -> dict[str, str]: + """Resolve user login emails (their user names) to user IDs. - High-level API for interacting with user-defined functions. + Matching is case-insensitive. Login names are stored and compared + case-sensitively, so emails that miss on exact casing fall back to a + case-insensitive match against the full user list. Inactive users are + resolved too. + + Returns a mapping of email (as passed) to user ID for the emails that were + found. Emails with no matching user are omitted. + + Args: + emails: The login emails to resolve. + + Raises: + ValueError: If an email matches multiple users case-insensitively. + """ + ... + +class WebhooksAPI: + """Sync counterpart to `WebhooksAPIAsync`. + + High-level API for interacting with webhooks. - A user-defined function is a named expression that calculated channels and rules - can call. Every save produces a new version. + A webhook registers an HTTP endpoint that Sift calls when a rule is violated. """ def __init__(self, sift_client: SiftClient): - """Initialize the UserDefinedFunctionsAPI. + """Initialize the WebhooksAPI. Args: sift_client: The Sift client to use. @@ -4082,77 +3775,49 @@ class UserDefinedFunctionsAPI: ... def _run(self, coro): ... - def archive(self, user_defined_function: str | UserDefinedFunction) -> UserDefinedFunction: - """Archive a function. + def archive(self, webhook: str | Webhook) -> Webhook: + """Archive a webhook. Archived webhooks stop receiving events. Args: - user_defined_function: The UserDefinedFunction or function ID to archive. + webhook: The Webhook or webhook ID to archive. Returns: - The archived UserDefinedFunction. + The archived Webhook. """ ... - def create(self, create: UserDefinedFunctionCreate | dict) -> UserDefinedFunction: - """Create a new function. + def create(self, create: WebhookCreate | dict) -> Webhook: + """Create a new webhook. Args: - create: The function definition. + create: The webhook definition. `http_headers` accepts either a list of + WebhookHttpHeader or a `{name: value}` mapping. Returns: - The created UserDefinedFunction. + The created Webhook. """ ... - def find(self, **kwargs) -> UserDefinedFunction | None: - """Find one function. Takes the same arguments as `list_`. - - Raises if more than one matches. + def find(self, **kwargs) -> Webhook | None: + """Find a single webhook matching the given query. Takes the same arguments as `list_`. + If more than one webhook is found, raises an error. Args: **kwargs: Keyword arguments to pass to `list_`. Returns: - The UserDefinedFunction found or None. + The Webhook found or None. """ ... - def get(self, *, user_defined_function_id: str) -> UserDefinedFunction: - """Get a UserDefinedFunction. + def get(self, webhook_id: str) -> Webhook: + """Get a Webhook. Args: - user_defined_function_id: The ID of the function. + webhook_id: The ID of the webhook. Returns: - The UserDefinedFunction. - """ - ... - - def get_where_used( - self, - user_defined_function: str | UserDefinedFunction | None = None, - *, - version: str | UserDefinedFunctionVersion | None = None, - ) -> FunctionUsage: - """Get what uses a function. - - Check this before changing inputs or the output type. The server refuses those - changes once a function is in use. - - Each result set is capped at 1000. The service pages them separately but accepts - no page token, so the rest cannot be fetched; `FunctionUsage.truncated` says when - that happened. - - Args: - user_defined_function: The UserDefinedFunction or function ID. - version: A specific UserDefinedFunctionVersion or version ID, instead of the - function as a whole. - - Returns: - The functions, calculated channels, and rules that use it. - - Raises: - ValueError: If neither or both are provided. + The Webhook. """ ... @@ -4160,168 +3825,80 @@ class UserDefinedFunctionsAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, - user_defined_function_ids: list[str] | list[UserDefinedFunction] | None = None, + webhook_ids: list[str] | None = None, + event_type: WebhookEventType | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[UserDefinedFunction]: - """List user-defined functions. - - Args: - name: Exact name of the function. - name_contains: Partial name of the function. - name_regex: Regular expression to filter functions by name. - user_defined_function_ids: Filter to these UserDefinedFunctions or function IDs. - include_archived: If True, include archived functions in results. - filter_query: Explicit CEL query to filter functions. - order_by: Field and direction to order results by. - limit: Maximum number of functions to return. If None, returns all matches. - page_size: Number of results to fetch per request. + ) -> list[Webhook]: + """List webhooks with optional filtering. + + Args: + name: Exact name of the webhook. + names: List of webhook names to filter by. + name_contains: Partial name of the webhook. + name_regex: Regular expression to filter webhooks by name. + webhook_ids: Filter to webhooks with any of these IDs. + event_type: Filter to webhooks triggered by this event type. + include_archived: If True, include archived webhooks in results. + filter_query: Explicit CEL query to filter webhooks. + order_by: Field and direction to order results by. Only `created_date` is + supported, e.g. "created_date desc". + limit: Maximum number of webhooks to return. If None, returns all matches. + page_size: Number of results to fetch per request. Lower this if you hit gRPC + message size limits on responses. If None, uses the server default. Returns: - A list of UserDefinedFunction objects that match the filter criteria. + A list of Webhook objects that match the filter criteria. """ ... - def unarchive(self, user_defined_function: str | UserDefinedFunction) -> UserDefinedFunction: - """Unarchive a function. - - Args: - user_defined_function: The UserDefinedFunction or function ID to unarchive. + def test( + self, webhook: str | Webhook | None = None, *, create: WebhookCreate | dict | None = None + ) -> WebhookTestResult: + """Send a real request to a webhook's target URL and return its response. - Returns: - The unarchived UserDefinedFunction. - """ - ... - - def update( - self, - user_defined_function: str | UserDefinedFunction, - update: UserDefinedFunctionUpdate | dict, - change_notes: str | None = None, - ) -> UserDefinedFunction: - """Update a function. - - Changes to the expression, inputs, description, or metadata create a new version. + Pass exactly one of `webhook` or `create`. Use `create` to check an endpoint + before saving it. Args: - user_defined_function: The UserDefinedFunction or function ID to update. - update: Updates to apply to the function. - change_notes: A note to attach to the new version. The server treats an empty - note as a change, so the current version's note carries over when omitted. + webhook: The Webhook or webhook ID to test. + create: An unsaved webhook definition to test. Returns: - The updated UserDefinedFunction. - """ - ... - - def validate_expression( - self, expression: str, function_inputs: list[FunctionInput] - ) -> UserDefinedFunctionValidation: - """Check an expression without saving it. - - Args: - expression: The expression to check. - function_inputs: The inputs the expression refers to. The server rejects an - empty list. - - Returns: - Whether the expression compiles, and its output type or error. - """ - ... - @property - def versions(self) -> UserDefinedFunctionVersionsAPI: - """Nested UserDefinedFunctionVersionsAPI for making synchronous requests.""" - ... - -class UsersAPI: - """Sync counterpart to `UsersAPIAsync`. - - High-level API for users. - - A user's ``name`` is their login name, typically their email address. - """ - - def __init__(self, sift_client: SiftClient): - """Initialize the UsersAPI. - - Args: - sift_client: The Sift client to use. - """ - ... - - def _run(self, coro): ... - def find(self, **kwargs) -> User | None: - """Find a single user matching the query. Raises if more than one matches. - - Takes the same arguments as ``list_``. - """ - ... - - def get(self, *, user_id: str) -> User: - """Get a user by ID. - - Args: - user_id: The ID of the user to retrieve. + The response the target URL returned. - Returns: - The User. + Raises: + ValueError: If neither or both arguments are provided. """ ... - def list_( - self, - *, - name: str | None = None, - names: list[str] | None = None, - name_contains: str | None = None, - name_regex: str | re.Pattern | None = None, - include_inactive: bool = False, - organization_id: str | None = None, - filter_query: str | None = None, - order_by: str | None = None, - limit: int | None = None, - page_size: int | None = None, - ) -> list[User]: - """List users with optional filtering. + def unarchive(self, webhook: str | Webhook) -> Webhook: + """Unarchive a webhook. Args: - name: Exact login name (typically the email address). - names: Login names to filter by. - name_contains: Substring match on the login name. - name_regex: Regex match on the login name. - include_inactive: If True, include inactive users. - organization_id: Scope the search to this organization. Only supported when - listing active users. - filter_query: Explicit CEL query. - order_by: Field and direction to order by. - limit: Maximum number of users to return. - page_size: Results to fetch per request. + webhook: The Webhook or webhook ID to unarchive. Returns: - The matching users. + The unarchived Webhook. """ ... - def resolve_ids(self, emails: list[str]) -> dict[str, str]: - """Resolve user login emails (their user names) to user IDs. + def update(self, webhook: str | Webhook, update: WebhookUpdate | dict) -> Webhook: + """Update a Webhook. - Matching is case-insensitive. Login names are stored and compared - case-sensitively, so emails that miss on exact casing fall back to a - case-insensitive match against the full user list. Inactive users are - resolved too. - - Returns a mapping of email (as passed) to user ID for the emails that were - found. Emails with no matching user are omitted. + Note that `http_headers` is replaced wholesale, not merged. Args: - emails: The login emails to resolve. + webhook: The Webhook or webhook ID to update. + update: Updates to apply to the Webhook. - Raises: - ValueError: If an email matches multiple users case-insensitively. + Returns: + The updated Webhook. """ ... diff --git a/python/lib/sift_client/resources/webhooks.py b/python/lib/sift_client/resources/webhooks.py index 06f84d3454..fc0b9876d0 100644 --- a/python/lib/sift_client/resources/webhooks.py +++ b/python/lib/sift_client/resources/webhooks.py @@ -22,13 +22,7 @@ class WebhooksAPIAsync(ResourceBase): """High-level API for interacting with webhooks. - A webhook registers an HTTP endpoint that Sift calls when an event occurs, such as a - rule violation. This class provides a Pythonic, notebook-friendly interface for - interacting with the WebhooksAPI. It handles automatic handling of gRPC services, - seamless type conversion, and clear error handling. - - All methods in this class use the Webhook class from the low-level wrapper, which is a - user-friendly representation of a webhook using standard Python data structures and types. + A webhook registers an HTTP endpoint that Sift calls when a rule is violated. """ def __init__(self, sift_client: SiftClient): @@ -192,10 +186,10 @@ async def test( *, create: WebhookCreate | dict | None = None, ) -> WebhookTestResult: - """Send a live request to a webhook's target URL and return its response. + """Send a real request to a webhook's target URL and return its response. - This performs a real HTTP request against the target URL. Exactly one of `webhook` - or `create` must be provided. Use `create` to check an endpoint before saving it. + Pass exactly one of `webhook` or `create`. Use `create` to check an endpoint + before saving it. Args: webhook: The Webhook or webhook ID to test. diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py index a11c128df4..aa28421594 100644 --- a/python/lib/sift_client/sift_types/webhook.py +++ b/python/lib/sift_client/sift_types/webhook.py @@ -130,10 +130,7 @@ def unarchive(self) -> Webhook: return self def test(self) -> WebhookTestResult: - """Send a live request to this webhook's target URL and return its response. - - This performs a real HTTP request against `target_url`. - """ + """Send a real request to this webhook's target URL and return its response.""" return self.client.webhooks.test(webhook=self) @@ -180,11 +177,7 @@ def _get_proto_class(self) -> type[CreateWebhookRequestProto]: @model_validator(mode="after") def _default_event_type(self): - """Default to the only event type Sift emits. - - Assign here, not as a field default. `to_proto` excludes unset fields, so a - field default would send `UNSPECIFIED`. - """ + """Assign rather than default, since `to_proto` drops unset fields.""" if self.event_type is None: self.event_type = WebhookEventType.RULE_VIOLATION return self From d04aed54219509ded6a336098b5e2f2a0d8bb76a Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 22 Sep 2026 18:12:03 -0700 Subject: [PATCH 06/11] python: address changes --- python/CHANGELOG.md | 1 + .../_tests/sift_types/test_rule.py | 4 +- .../_tests/sift_types/test_webhook.py | 8 ++-- .../resources/sync_stubs/__init__.pyi | 4 +- python/lib/sift_client/resources/webhooks.py | 4 +- python/lib/sift_client/sift_types/rule.py | 22 ++++++++-- python/lib/sift_client/sift_types/webhook.py | 44 +++++++++++++------ 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index ee80fba96a..8c850fec8f 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Added `client.campaigns` for grouping report results across runs. - Added `client.webhooks`, and `RuleAction.webhook` to call one when a rule is violated. - `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 diff --git a/python/lib/sift_client/_tests/sift_types/test_rule.py b/python/lib/sift_client/_tests/sift_types/test_rule.py index 1b2a6e1c19..9dda2d4db6 100644 --- a/python/lib/sift_client/_tests/sift_types/test_rule.py +++ b/python/lib/sift_client/_tests/sift_types/test_rule.py @@ -233,6 +233,7 @@ def test_from_webhook_id(self): assert action.action_type == RuleActionType.WEBHOOK assert action.webhook_id == self.WEBHOOK_ID + assert action._to_update_request().configuration.webhook.webhook_id == self.WEBHOOK_ID def test_from_webhook_object(self): """Test building the action from a Webhook instance.""" @@ -244,7 +245,8 @@ def test_from_webhook_object(self): action = RuleAction.webhook(webhook) - assert action.webhook_id == self.WEBHOOK_ID + assert action.webhook_id is webhook + assert action._to_update_request().configuration.webhook.webhook_id == self.WEBHOOK_ID def test_rejects_non_uuid(self): """Test that a malformed webhook ID is caught before it reaches the server.""" diff --git a/python/lib/sift_client/_tests/sift_types/test_webhook.py b/python/lib/sift_client/_tests/sift_types/test_webhook.py index a0c01efa6a..399992f145 100644 --- a/python/lib/sift_client/_tests/sift_types/test_webhook.py +++ b/python/lib/sift_client/_tests/sift_types/test_webhook.py @@ -212,11 +212,11 @@ def test_unarchive_calls_client_and_updates_self(self, mock_webhook, mock_client assert result is mock_webhook def test_test_calls_client(self, mock_webhook, mock_client): - """Test that test() delegates to client.webhooks.test.""" + """Test that test() delegates to client.webhooks.send_test_request.""" expected = MagicMock() - mock_client.webhooks.test.return_value = expected + mock_client.webhooks.send_test_request.return_value = expected - result = mock_webhook.test() + result = mock_webhook.send_test_request() - mock_client.webhooks.test.assert_called_once_with(webhook=mock_webhook) + mock_client.webhooks.send_test_request.assert_called_once_with(webhook=mock_webhook) assert result is expected diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index cd4796ae4c..51ff2b2a38 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -3858,8 +3858,8 @@ class WebhooksAPI: """ ... - def test( - self, webhook: str | Webhook | None = None, *, create: WebhookCreate | dict | None = None + def send_test_request( + self, *, webhook: str | Webhook | None = None, create: WebhookCreate | dict | None = None ) -> WebhookTestResult: """Send a real request to a webhook's target URL and return its response. diff --git a/python/lib/sift_client/resources/webhooks.py b/python/lib/sift_client/resources/webhooks.py index fc0b9876d0..6f5393eb64 100644 --- a/python/lib/sift_client/resources/webhooks.py +++ b/python/lib/sift_client/resources/webhooks.py @@ -180,10 +180,10 @@ async def unarchive(self, webhook: str | Webhook) -> Webhook: """ return await self.update(webhook, WebhookUpdate(is_archived=False)) - async def test( + async def send_test_request( self, - webhook: str | Webhook | None = None, *, + webhook: str | Webhook | None = None, create: WebhookCreate | dict | None = None, ) -> WebhookTestResult: """Send a real request to a webhook's target URL and return its response. diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index 41486add2e..c995ddce35 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -277,6 +277,14 @@ def from_str(cls, val: str) -> RuleAnnotationType | None: return cls(int(val)) +def _webhook_id(webhook: str | Webhook | None) -> str: + """Resolve a Webhook or webhook ID to the UUID string the proto expects.""" + if webhook is None: + raise ValueError("webhook is required for a webhook action") + raw = webhook.id_ if isinstance(webhook, Webhook) else webhook + return str(UUID(raw)) + + class RuleAction(BaseType[RuleActionProto, "RuleAction"]): """Model of a Rule Action.""" @@ -290,7 +298,14 @@ class RuleAction(BaseType[RuleActionProto, "RuleAction"]): annotation_type: RuleAnnotationType | None = None tags_ids: list[str] | None = None default_assignee_user: str | None = None - webhook_id: str | None = None + webhook_id: str | Webhook | None = None + + @field_validator("webhook_id", mode="after") + @classmethod + def _validate_webhook_id(cls, value): + if isinstance(value, str): + UUID(value) + return value @classmethod def webhook(cls, webhook: str | Webhook) -> RuleAction: @@ -299,8 +314,7 @@ def webhook(cls, webhook: str | Webhook) -> RuleAction: Args: webhook: The Webhook or webhook ID to call when the rule is violated. """ - webhook_id = webhook.id_ if isinstance(webhook, Webhook) else webhook - return cls(action_type=RuleActionType.WEBHOOK, webhook_id=str(UUID(webhook_id))) + return cls(action_type=RuleActionType.WEBHOOK, webhook_id=webhook) @classmethod def annotation( @@ -383,7 +397,7 @@ def _to_update_request(self) -> UpdateActionRequest: else None ), webhook=( - WebhookActionConfiguration(webhook_id=self.webhook_id) # type: ignore + WebhookActionConfiguration(webhook_id=_webhook_id(self.webhook_id)) if self.action_type == RuleActionType.WEBHOOK else None ), diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py index aa28421594..cc16ef46dc 100644 --- a/python/lib/sift_client/sift_types/webhook.py +++ b/python/lib/sift_client/sift_types/webhook.py @@ -20,12 +20,12 @@ if TYPE_CHECKING: from sift_client.client import SiftClient + from sift_client.sift_types.user import User class WebhookEventType(Enum): """Enum for the events that trigger a webhook.""" - UNSPECIFIED = WebhookEventTypeProto.WEBHOOK_EVENT_TYPE_UNSPECIFIED # 0 RULE_VIOLATION = WebhookEventTypeProto.WEBHOOK_EVENT_TYPE_RULE_VIOLATION # 1 def to_filter_str(self) -> str: @@ -60,6 +60,11 @@ class WebhookTestResult(BaseModel): http_response_code: int http_response_body: bytes + @property + def text(self) -> str: + """The response body decoded as text, with undecodable bytes replaced.""" + return self.http_response_body.decode(errors="replace") + class Webhook(BaseType[WebhookProto, "Webhook"]): """Webhook model representing an HTTP callback registered with Sift.""" @@ -68,7 +73,7 @@ class Webhook(BaseType[WebhookProto, "Webhook"]): name: str organization_id: str target_url: str - event_type: WebhookEventType + event_type: WebhookEventType | None http_headers: list[WebhookHttpHeader] created_date: datetime modified_date: datetime @@ -88,7 +93,7 @@ def _from_proto(cls, proto: WebhookProto, sift_client: SiftClient | None = None) name=proto.name, organization_id=proto.organization_id, target_url=proto.target_url, - event_type=WebhookEventType(proto.event_type), + event_type=(WebhookEventType(proto.event_type) if proto.event_type else None), http_headers=[WebhookHttpHeader._from_proto(header) for header in proto.http_headers], created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), @@ -104,6 +109,16 @@ def _from_proto(cls, proto: WebhookProto, sift_client: SiftClient | None = None) _client=sift_client, ) + @property + def created_by(self) -> User: + """Fetch the User that created this webhook.""" + return self.client.users.get(user_id=self.created_by_user_id) + + @property + def modified_by(self) -> User: + """Fetch the User that last modified this webhook.""" + return self.client.users.get(user_id=self.modified_by_user_id) + def update(self, update: WebhookUpdate | dict) -> Webhook: """Update the Webhook. @@ -129,9 +144,16 @@ def unarchive(self) -> Webhook: self._update(updated_webhook) return self - def test(self) -> WebhookTestResult: + def send_test_request(self) -> WebhookTestResult: """Send a real request to this webhook's target URL and return its response.""" - return self.client.webhooks.test(webhook=self) + return self.client.webhooks.send_test_request(webhook=self) + + +def _check_target_url(value: str | None) -> str | None: + """Reject target URLs that are not absolute HTTP(S) URLs.""" + if value is not None and not value.startswith(("http://", "https://")): + raise ValueError("target_url must start with http:// or https://") + return value class WebhookBase(ModelCreateUpdateBase): @@ -157,14 +179,6 @@ def _normalize_http_headers(cls, value): return [WebhookHttpHeader(name=name, value=header) for name, header in value.items()] return value - @model_validator(mode="after") - def _validate_target_url(self): - """Reject target URLs that are not absolute HTTP(S) URLs.""" - target_url = getattr(self, "target_url", None) - if target_url is not None and not target_url.startswith(("http://", "https://")): - raise ValueError("target_url must start with http:// or https://") - return self - class WebhookCreate(WebhookBase, ModelCreate[CreateWebhookRequestProto]): """Create model for Webhook.""" @@ -172,6 +186,8 @@ class WebhookCreate(WebhookBase, ModelCreate[CreateWebhookRequestProto]): name: str target_url: str + _validate_target_url = field_validator("target_url")(_check_target_url) + def _get_proto_class(self) -> type[CreateWebhookRequestProto]: return CreateWebhookRequestProto @@ -190,6 +206,8 @@ class WebhookUpdate(WebhookBase, ModelUpdate[WebhookProto]): target_url: str | None = None is_archived: bool | None = None + _validate_target_url = field_validator("target_url")(_check_target_url) + def _get_proto_class(self) -> type[WebhookProto]: return WebhookProto From 48dee5345e5356e819327c06186899171ec85933 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 22 Sep 2026 18:50:57 -0700 Subject: [PATCH 07/11] fix ci, mkdocstrings err --- python/lib/sift_client/sift_types/webhook.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py index cc16ef46dc..2ec69ccad5 100644 --- a/python/lib/sift_client/sift_types/webhook.py +++ b/python/lib/sift_client/sift_types/webhook.py @@ -149,11 +149,10 @@ def send_test_request(self) -> WebhookTestResult: return self.client.webhooks.send_test_request(webhook=self) -def _check_target_url(value: str | None) -> str | None: +def _check_target_url(value: str | None) -> None: """Reject target URLs that are not absolute HTTP(S) URLs.""" if value is not None and not value.startswith(("http://", "https://")): raise ValueError("target_url must start with http:// or https://") - return value class WebhookBase(ModelCreateUpdateBase): @@ -186,7 +185,11 @@ class WebhookCreate(WebhookBase, ModelCreate[CreateWebhookRequestProto]): name: str target_url: str - _validate_target_url = field_validator("target_url")(_check_target_url) + @field_validator("target_url") + @classmethod + def _validate_target_url(cls, value: str) -> str: + _check_target_url(value) + return value def _get_proto_class(self) -> type[CreateWebhookRequestProto]: return CreateWebhookRequestProto @@ -206,7 +209,11 @@ class WebhookUpdate(WebhookBase, ModelUpdate[WebhookProto]): target_url: str | None = None is_archived: bool | None = None - _validate_target_url = field_validator("target_url")(_check_target_url) + @field_validator("target_url") + @classmethod + def _validate_target_url(cls, value: str | None) -> str | None: + _check_target_url(value) + return value def _get_proto_class(self) -> type[WebhookProto]: return WebhookProto From cdee2388570f060eaa46538dc1f6db73b56954f8 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Tue, 22 Sep 2026 19:28:47 -0700 Subject: [PATCH 08/11] python: update WebhookTest --- .../_internal/low_level_wrappers/webhooks.py | 13 ++++++++----- .../sift_client/resources/sync_stubs/__init__.pyi | 4 ++-- python/lib/sift_client/resources/webhooks.py | 4 ++-- python/lib/sift_client/sift_types/__init__.py | 4 ++-- python/lib/sift_client/sift_types/webhook.py | 4 ++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py index 75a3423581..8153fcd7fc 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py @@ -21,7 +21,7 @@ from sift_client.sift_types.webhook import ( Webhook, WebhookCreate, - WebhookTestResult, + WebhookTest, WebhookUpdate, ) from sift_client.transport import WithGrpcClient @@ -143,8 +143,11 @@ async def create_webhook(self, *, create: WebhookCreate) -> Webhook: async def update_webhook(self, update: WebhookUpdate) -> Webhook: """Update select fields of a webhook. - Fetches the webhook first. WebhookService validates the whole `Webhook` - message, not just the masked fields, so it rejects a sparse patch. + 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. @@ -165,7 +168,7 @@ async def test_webhook( *, webhook_id: str | None = None, create: WebhookCreate | None = None, - ) -> WebhookTestResult: + ) -> WebhookTest: """Send a real request to a webhook's target URL. Pass exactly one identifier. Args: @@ -188,7 +191,7 @@ async def test_webhook( response = await self._grpc_client.get_stub(WebhookServiceStub).TestWebhook(request) response = cast("TestWebhookResponse", response) - return WebhookTestResult( + return WebhookTest( http_response_code=response.http_response_code, http_response_body=response.http_response_body, ) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 51ff2b2a38..87ba90a955 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -95,7 +95,7 @@ if TYPE_CHECKING: Webhook, WebhookCreate, WebhookEventType, - WebhookTestResult, + WebhookTest, WebhookUpdate, ) @@ -3860,7 +3860,7 @@ class WebhooksAPI: def send_test_request( self, *, webhook: str | Webhook | None = None, create: WebhookCreate | dict | None = None - ) -> WebhookTestResult: + ) -> WebhookTest: """Send a real request to a webhook's target URL and return its response. Pass exactly one of `webhook` or `create`. Use `create` to check an endpoint diff --git a/python/lib/sift_client/resources/webhooks.py b/python/lib/sift_client/resources/webhooks.py index 6f5393eb64..342ed0512d 100644 --- a/python/lib/sift_client/resources/webhooks.py +++ b/python/lib/sift_client/resources/webhooks.py @@ -8,7 +8,7 @@ Webhook, WebhookCreate, WebhookEventType, - WebhookTestResult, + WebhookTest, WebhookUpdate, ) from sift_client.util import cel_utils as cel @@ -185,7 +185,7 @@ async def send_test_request( *, webhook: str | Webhook | None = None, create: WebhookCreate | dict | None = None, - ) -> WebhookTestResult: + ) -> WebhookTest: """Send a real request to a webhook's target URL and return its response. Pass exactly one of `webhook` or `create`. Use `create` to check an endpoint diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index 5b078fe9d4..4cc53e68ff 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -252,7 +252,7 @@ WebhookCreate, WebhookEventType, WebhookHttpHeader, - WebhookTestResult, + WebhookTest, WebhookUpdate, ) @@ -377,6 +377,6 @@ "WebhookCreate", "WebhookEventType", "WebhookHttpHeader", - "WebhookTestResult", + "WebhookTest", "WebhookUpdate", ] diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py index 2ec69ccad5..4e8ccb234d 100644 --- a/python/lib/sift_client/sift_types/webhook.py +++ b/python/lib/sift_client/sift_types/webhook.py @@ -49,7 +49,7 @@ def _from_proto(cls, proto: WebhookHttpHeaderProto) -> WebhookHttpHeader: return cls(name=proto.name, value=proto.value) -class WebhookTestResult(BaseModel): +class WebhookTest(BaseModel): """The response the target URL returned for a test request. Attributes: @@ -144,7 +144,7 @@ def unarchive(self) -> Webhook: self._update(updated_webhook) return self - def send_test_request(self) -> WebhookTestResult: + def send_test_request(self) -> WebhookTest: """Send a real request to this webhook's target URL and return its response.""" return self.client.webhooks.send_test_request(webhook=self) From 0f1514cfcd1edc9d034065521f57d103ea53d17c Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Thu, 24 Sep 2026 00:39:08 -0400 Subject: [PATCH 09/11] python: accept a Webhook directly as a rule action --- python/CHANGELOG.md | 1 + .../_internal/low_level_wrappers/rules.py | 5 +++-- python/lib/sift_client/sift_types/rule.py | 20 +++++++++++++++++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 8c850fec8f..fb75970681 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -9,6 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - 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. diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index c0404cb173..6a8665a0a4 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -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, @@ -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( @@ -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" diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index c995ddce35..340ff20019 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -167,6 +167,11 @@ def _from_proto(cls, proto: RuleProto, sift_client: SiftClient | None = None) -> ) +def _wrap_webhook_action(value): + """A webhook action carries nothing else, so a bare Webhook is enough.""" + return RuleAction.webhook(value) if isinstance(value, Webhook) else value + + class RuleCreateUpdateBase(ModelCreateUpdateBase): """Base class for Rule create and update models with shared fields and validation.""" @@ -196,7 +201,12 @@ class RuleCreate(RuleCreateUpdateBase, ModelCreate[CreateRuleRequest]): description: str expression: str channel_references: list[ChannelReference] - action: RuleAction + action: RuleAction | Webhook + + @field_validator("action", mode="after") + @classmethod + def _wrap_webhook(cls, value): + return _wrap_webhook_action(value) def _get_proto_class(self) -> type[CreateRuleRequest]: return CreateRuleRequest @@ -215,7 +225,13 @@ class RuleUpdate(RuleCreateUpdateBase, ModelUpdate[RuleProto]): description: str | None = None expression: str | None = None channel_references: list[ChannelReference] | None = None - action: RuleAction | None = None + action: RuleAction | Webhook | None = None + + @field_validator("action", mode="after") + @classmethod + def _wrap_webhook(cls, value): + return _wrap_webhook_action(value) + is_archived: bool | None = None def _get_proto_class(self) -> type[RuleProto]: From f5d645a501bf438548b2beca2133e275fc5bfc3f Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 18:15:54 -0400 Subject: [PATCH 10/11] python: raise valueerror --- python/lib/sift_client/sift_types/rule.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index 340ff20019..13dfd8f313 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -178,7 +178,7 @@ class RuleCreateUpdateBase(ModelCreateUpdateBase): @field_validator("asset_ids", mode="after") @classmethod def _asset_ids_to_strings(cls, value): - return [a.id_ if isinstance(a, Asset) else a for a in value] if value else value + return [a._id_or_error if isinstance(a, Asset) else a for a in value] if value else value organization_id: str | None = None client_key: str | None = None @@ -297,7 +297,7 @@ def _webhook_id(webhook: str | Webhook | None) -> str: """Resolve a Webhook or webhook ID to the UUID string the proto expects.""" if webhook is None: raise ValueError("webhook is required for a webhook action") - raw = webhook.id_ if isinstance(webhook, Webhook) else webhook + raw = webhook._id_or_error if isinstance(webhook, Webhook) else webhook return str(UUID(raw)) From d3a014b9dba9f64c0304a2deab9ac44242109a10 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 19:14:02 -0700 Subject: [PATCH 11/11] python: merge conflicts --- .../_tests/resources/test_webhooks.py | 10 +- .../resources/sync_stubs/__init__.pyi | 575 ++++++++++++++++++ 2 files changed, 580 insertions(+), 5 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_webhooks.py b/python/lib/sift_client/_tests/resources/test_webhooks.py index 4a2d0d14f5..1a5fe7a4ac 100644 --- a/python/lib/sift_client/_tests/resources/test_webhooks.py +++ b/python/lib/sift_client/_tests/resources/test_webhooks.py @@ -209,14 +209,14 @@ def test_instance_methods(self, webhooks_api_sync, test_timestamp_str): webhook.archive() assert webhook.is_archived is True - def test_test_requires_exactly_one_argument(self, webhooks_api_sync, new_webhook): - """Test that test() rejects ambiguous arguments before making any request.""" + def test_send_test_request_requires_exactly_one_argument(self, webhooks_api_sync, new_webhook): + """Test that send_test_request() rejects ambiguous arguments before any request.""" with pytest.raises(ValueError, match="Exactly one of webhook or create"): - webhooks_api_sync.test() + webhooks_api_sync.send_test_request() with pytest.raises(ValueError, match="Exactly one of webhook or create"): - webhooks_api_sync.test( - new_webhook, + webhooks_api_sync.send_test_request( + webhook=new_webhook, create=WebhookCreate(name="ignored", target_url=TEST_TARGET_URL), ) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 87ba90a955..792947a475 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: import re + from collections.abc import Iterable from datetime import datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING, Any, Sequence @@ -17,6 +18,16 @@ if TYPE_CHECKING: ReplayResult, ) from sift_client.client import SiftClient + from sift_client.sift_types.annotation import ( + Annotation, + AnnotationCommentElement, + AnnotationCreateBase, + AnnotationLog, + AnnotationLogKind, + AnnotationState, + AnnotationType, + AnnotationUpdate, + ) from sift_client.sift_types.asset import Asset, AssetUpdate from sift_client.sift_types.calculated_channel import ( CalculatedChannel, @@ -91,6 +102,15 @@ if TYPE_CHECKING: TestStepUpdate, ) from sift_client.sift_types.user import User + from sift_client.sift_types.user_defined_function import ( + FunctionInput, + FunctionUsage, + UserDefinedFunction, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, + UserDefinedFunctionValidation, + UserDefinedFunctionVersion, + ) from sift_client.sift_types.webhook import ( Webhook, WebhookCreate, @@ -99,6 +119,314 @@ if TYPE_CHECKING: WebhookUpdate, ) +class AnnotationLogsAPI: + """Sync counterpart to `AnnotationLogsAPIAsync`. + + High-level API for an annotation's history. + + Each log records one event: an assignment, a state change, or a comment. + Reachable as `client.annotations.logs`. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the AnnotationLogsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def add_comment( + self, annotation: str | Annotation, text: str | list[AnnotationCommentElement] + ) -> AnnotationLog: + """Add a comment to an annotation. + + Args: + annotation: The Annotation or annotation ID to comment on. + text: Plain text, or a list of elements to mix text with user mentions. + + Returns: + The created AnnotationLog. + """ + ... + + def list_( + self, + *, + annotation: str | Annotation, + annotation_logs: list[str | AnnotationLog] | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | None = None, + created_by: Any | str | None = None, + kind: AnnotationLogKind | None = None, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[AnnotationLog]: + """List annotation logs. + + Args: + annotation: The Annotation or annotation ID whose history to list. + annotation_logs: Filter to these AnnotationLogs or log IDs. + created_after: Filter logs created after this datetime. + created_before: Filter logs created before this datetime. + modified_after: Filter logs modified after this datetime. + modified_before: Filter logs modified before this datetime. + created_by: Filter logs created by this user ID. + kind: Filter to comments, state updates, or assignments. + filter_query: Explicit CEL query to filter logs. + order_by: Field and direction to order results by. + limit: Maximum number of logs to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of AnnotationLog objects that match the filter criteria. + """ + ... + +class AnnotationsAPI: + """Sync counterpart to `AnnotationsAPIAsync`. + + High-level API for interacting with annotations. + + An annotation marks a time range on one or more assets. A data review annotation + carries a review state and an assignee. A phase annotation marks a segment of a run + and carries no state. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the AnnotationsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive(self, annotation: str | Annotation) -> Annotation: + """Archive an annotation. + + Args: + annotation: The Annotation or annotation ID to archive. + + Returns: + The archived Annotation. + """ + ... + + def assign_to_user(self, annotation: str | Annotation, user: str | User) -> Annotation: + """Assign an annotation to a user for review. + + Args: + annotation: The Annotation or annotation ID to assign. + user: The User or user ID to assign to. + + Returns: + The updated Annotation. + """ + ... + + def batch_archive(self, annotations: list[str | Annotation]) -> list[Annotation]: + """Archive many annotations, one call per `BATCH_LIMIT` of them. + + Args: + annotations: The Annotations or annotation IDs to archive. + + Returns: + The archived Annotations. + """ + ... + + def batch_unarchive(self, annotations: list[str | Annotation]) -> list[Annotation]: + """Unarchive many annotations, one call per `BATCH_LIMIT` of them. + + Args: + annotations: The Annotations or annotation IDs to unarchive. + + Returns: + The unarchived Annotations. + """ + ... + + def create(self, create: AnnotationCreateBase | dict) -> Annotation: + """Create an annotation. + + Pass an `AnnotationCreate` for a data review or a `PhaseCreate` for a phase. A + dict picks the model from its `annotation_type`, which may be a name or a number. + + Args: + create: The annotation definition. `assets` takes Assets or asset IDs, + `tags` takes Tags or tag names. + + Returns: + The created Annotation. + """ + ... + + def find(self, **kwargs) -> Annotation | None: + """Find one annotation. Takes the same arguments as `list_`. + + Raises if more than one matches. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The Annotation found or None. + """ + ... + + def get(self, *, annotation_id: str) -> Annotation: + """Get an Annotation. + + Args: + annotation_id: The ID of the annotation. + + Returns: + The Annotation. + """ + ... + + def list_( + self, + *, + name: str | Iterable[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + annotation_ids: list[str] | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | None = None, + created_by: Any | str | None = None, + modified_by: Any | str | None = None, + tags: list[str] | list[Tag] | None = None, + metadata: dict[str, Any] | None = None, + annotation_type: AnnotationType | None = None, + state: AnnotationState | None = None, + assigned_to: Any | str | None = None, + pending: bool | None = None, + assets: list[Asset] | list[str] | None = None, + runs: list[Run] | list[str] | None = None, + rules: list[str | Rule] | None = None, + reports: list[str | Report] | None = None, + start_time_after: datetime | None = None, + start_time_before: datetime | None = None, + end_time_after: datetime | None = None, + end_time_before: datetime | None = None, + description_contains: str | None = None, + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[Annotation]: + """List annotations. + + Args: + name: Exact name, or any iterable of names to match against. + name_contains: Partial name of the annotation. + name_regex: Regular expression to filter annotations by name. + annotation_ids: Filter to annotations with any of these IDs. + created_after: Filter annotations created after this datetime. + created_before: Filter annotations created before this datetime. + modified_after: Filter annotations modified after this datetime. + modified_before: Filter annotations modified before this datetime. + created_by: Filter annotations created by this user ID. + modified_by: Filter annotations last modified by this user ID. + tags: Filter annotations with any of these Tags or tag names. + metadata: Filter annotations by metadata criteria. + annotation_type: Filter to DATA_REVIEW or PHASE annotations. + state: Filter to a review state. + assigned_to: Filter to annotations assigned to this user's name. + pending: Filter to annotations from an ongoing rule violation. + rules: Filter to annotations created by any of these Rules or rule IDs. + reports: Filter to annotations in any of these Reports or report IDs. + assets: Filter annotations on any of these Assets or asset IDs. + runs: Filter annotations on any of these Runs or run IDs. + start_time_after: Filter annotations that start after this datetime. + start_time_before: Filter annotations that start before this datetime. + end_time_after: Filter annotations that end after this datetime. + end_time_before: Filter annotations that end before this datetime. + description_contains: Partial description of the annotation. + include_archived: If True, include archived annotations in results. + filter_query: Explicit CEL query to filter annotations. + order_by: Field and direction to order results by. + limit: Maximum number of annotations to return. If None, returns all matches. + page_size: Number of results to fetch per request. Lower this if you hit gRPC + message size limits on responses. If None, uses the server default. + + Returns: + A list of Annotation objects that match the filter criteria. + """ + ... + + def set_accepted(self, annotation: str | Annotation) -> Annotation: + """Set the review state to Accepted. + + Args: + annotation: The Annotation or annotation ID. + + Returns: + The updated Annotation. + """ + ... + + def set_failed(self, annotation: str | Annotation) -> Annotation: + """Set the review state to Failed. + + Args: + annotation: The Annotation or annotation ID. + + Returns: + The updated Annotation. + """ + ... + + def set_open(self, annotation: str | Annotation) -> Annotation: + """Set the review state to Open. + + Args: + annotation: The Annotation or annotation ID. + + Returns: + The updated Annotation. + """ + ... + + def unarchive(self, annotation: str | Annotation) -> Annotation: + """Unarchive an annotation. + + Args: + annotation: The Annotation or annotation ID to unarchive. + + Returns: + The unarchived Annotation. + """ + ... + + def update(self, annotation: str | Annotation, update: AnnotationUpdate | dict) -> Annotation: + """Update an Annotation. + + `tags`, `linked_channels`, and `metadata` are replaced, not merged. + + Args: + annotation: The Annotation or annotation ID to update. + update: Updates to apply to the Annotation. + + Returns: + The updated Annotation. + """ + ... + @property + def logs(self) -> AnnotationLogsAPI: + """Nested AnnotationLogsAPI for making synchronous requests.""" + ... + class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. @@ -3670,6 +3998,253 @@ class TestResultsAPI: """ ... +class UserDefinedFunctionVersionsAPI: + """Sync counterpart to `UserDefinedFunctionVersionsAPIAsync`. + + High-level API for a function's version history. + + Every save produces a new version. Reachable as + `client.user_defined_functions.versions`. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the UserDefinedFunctionVersionsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def batch_get( + self, *, versions: list[str] | list[UserDefinedFunctionVersion] + ) -> list[UserDefinedFunctionVersion]: + """Get many versions in one call. + + Args: + versions: The UserDefinedFunctionVersions or version IDs. + + Returns: + The UserDefinedFunctionVersions. + """ + ... + + def get(self, *, version: str | UserDefinedFunctionVersion) -> UserDefinedFunctionVersion: + """Get one version. + + Args: + version: The UserDefinedFunctionVersion or version ID. + + Returns: + The UserDefinedFunctionVersion. + """ + ... + + def list_( + self, + *, + user_defined_function: str | UserDefinedFunction | None = None, + name: str | None = None, + version: int | None = None, + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[UserDefinedFunctionVersion]: + """List a function's versions. + + Args: + user_defined_function: The UserDefinedFunction or function ID whose versions to list. + name: The function name, as an alternative to `user_defined_function`. + version: Filter to a single version number. + include_archived: If True, include archived versions in results. + filter_query: Explicit CEL query to filter versions. + order_by: Field and direction to order results by. + limit: Maximum number of versions to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of UserDefinedFunctionVersion objects in the service's order, which is + by name unless `order_by` says otherwise. Every version shares the function's + name, so pass `order_by="version desc"` for newest first. + """ + ... + +class UserDefinedFunctionsAPI: + """Sync counterpart to `UserDefinedFunctionsAPIAsync`. + + High-level API for interacting with user-defined functions. + + A user-defined function is a named expression that calculated channels and rules + can call. Every save produces a new version. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the UserDefinedFunctionsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive(self, user_defined_function: str | UserDefinedFunction) -> UserDefinedFunction: + """Archive a function. + + Args: + user_defined_function: The UserDefinedFunction or function ID to archive. + + Returns: + The archived UserDefinedFunction. + """ + ... + + def create(self, create: UserDefinedFunctionCreate | dict) -> UserDefinedFunction: + """Create a new function. + + Args: + create: The function definition. + + Returns: + The created UserDefinedFunction. + """ + ... + + def find(self, **kwargs) -> UserDefinedFunction | None: + """Find one function. Takes the same arguments as `list_`. + + Raises if more than one matches. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The UserDefinedFunction found or None. + """ + ... + + def get(self, *, user_defined_function_id: str) -> UserDefinedFunction: + """Get a UserDefinedFunction. + + Args: + user_defined_function_id: The ID of the function. + + Returns: + The UserDefinedFunction. + """ + ... + + def get_where_used( + self, + user_defined_function: str | UserDefinedFunction | None = None, + *, + version: str | UserDefinedFunctionVersion | None = None, + ) -> FunctionUsage: + """Get what uses a function. + + Check this before changing inputs or the output type. The server refuses those + changes once a function is in use. + + Each result set is capped at 1000. The service pages them separately but accepts + no page token, so the rest cannot be fetched; `FunctionUsage.truncated` says when + that happened. + + Args: + user_defined_function: The UserDefinedFunction or function ID. + version: A specific UserDefinedFunctionVersion or version ID, instead of the + function as a whole. + + Returns: + The functions, calculated channels, and rules that use it. + + Raises: + ValueError: If neither or both are provided. + """ + ... + + def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + user_defined_function_ids: list[str] | list[UserDefinedFunction] | None = None, + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[UserDefinedFunction]: + """List user-defined functions. + + Args: + name: Exact name of the function. + name_contains: Partial name of the function. + name_regex: Regular expression to filter functions by name. + user_defined_function_ids: Filter to these UserDefinedFunctions or function IDs. + include_archived: If True, include archived functions in results. + filter_query: Explicit CEL query to filter functions. + order_by: Field and direction to order results by. + limit: Maximum number of functions to return. If None, returns all matches. + page_size: Number of results to fetch per request. + + Returns: + A list of UserDefinedFunction objects that match the filter criteria. + """ + ... + + def unarchive(self, user_defined_function: str | UserDefinedFunction) -> UserDefinedFunction: + """Unarchive a function. + + Args: + user_defined_function: The UserDefinedFunction or function ID to unarchive. + + Returns: + The unarchived UserDefinedFunction. + """ + ... + + def update( + self, + user_defined_function: str | UserDefinedFunction, + update: UserDefinedFunctionUpdate | dict, + change_notes: str | None = None, + ) -> UserDefinedFunction: + """Update a function. + + Changes to the expression, inputs, description, or metadata create a new version. + + Args: + user_defined_function: The UserDefinedFunction or function ID to update. + update: Updates to apply to the function. + change_notes: A note to attach to the new version. The server treats an empty + note as a change, so the current version's note carries over when omitted. + + Returns: + The updated UserDefinedFunction. + """ + ... + + def validate_expression( + self, expression: str, function_inputs: list[FunctionInput] + ) -> UserDefinedFunctionValidation: + """Check an expression without saving it. + + Args: + expression: The expression to check. + function_inputs: The inputs the expression refers to. The server rejects an + empty list. + + Returns: + Whether the expression compiles, and its output type or error. + """ + ... + @property + def versions(self) -> UserDefinedFunctionVersionsAPI: + """Nested UserDefinedFunctionVersionsAPI for making synchronous requests.""" + ... + class UsersAPI: """Sync counterpart to `UsersAPIAsync`.