From 4749fa71a45e7df38d291bf1018cadbb67cb1e5b Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 16 Sep 2026 23:45:24 -0700 Subject: [PATCH 1/8] udf resource --- python/CHANGELOG.md | 2 + .../user_defined_functions.py | 349 ++++++++++++++++++ python/lib/sift_client/_tests/conftest.py | 1 + .../resources/test_user_defined_functions.py | 302 +++++++++++++++ .../sift_types/test_user_defined_function.py | 186 ++++++++++ python/lib/sift_client/client.py | 7 + python/lib/sift_client/resources/__init__.py | 10 + .../resources/sync_stubs/__init__.py | 13 + .../resources/sync_stubs/__init__.pyi | 235 ++++++++++++ .../resources/user_defined_functions.py | 330 +++++++++++++++++ python/lib/sift_client/sift_types/__init__.py | 16 + .../sift_types/user_defined_function.py | 253 +++++++++++++ python/lib/sift_client/util/util.py | 4 + 13 files changed, 1708 insertions(+) create mode 100644 python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py create mode 100644 python/lib/sift_client/_tests/resources/test_user_defined_functions.py create mode 100644 python/lib/sift_client/_tests/sift_types/test_user_defined_function.py create mode 100644 python/lib/sift_client/resources/user_defined_functions.py create mode 100644 python/lib/sift_client/sift_types/user_defined_function.py diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 9d3f862fd5..c12e519e7e 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -5,6 +5,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +- Added `client.user_defined_functions` for reusable expressions. + ### What's New #### MCAP imports diff --git a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py new file mode 100644 index 0000000000..266e3e00b3 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from sift.user_defined_functions.v1.user_defined_functions_pb2 import ( + CreateUserDefinedFunctionResponse, + GetUserDefinedFunctionDependentsRequest, + GetUserDefinedFunctionDependentsResponse, + GetUserDefinedFunctionRequest, + GetUserDefinedFunctionResponse, + GetUserDefinedFunctionVersionRequest, + GetUserDefinedFunctionVersionResponse, + ListUserDefinedFunctionsRequest, + ListUserDefinedFunctionsResponse, + ListUserDefinedFunctionVersionsRequest, + ListUserDefinedFunctionVersionsResponse, + UpdateUserDefinedFunctionRequest, + UpdateUserDefinedFunctionResponse, + ValidateUserDefinedFunctionRequest, + ValidateUserDefinedFunctionResponse, +) +from sift.user_defined_functions.v1.user_defined_functions_pb2_grpc import ( + UserDefinedFunctionServiceStub, +) + +from sift_client._internal.low_level_wrappers.base import DEFAULT_PAGE_SIZE, LowLevelClientBase +from sift_client.sift_types.user_defined_function import ( + FunctionDataType, + FunctionDependents, + FunctionInput, + UserDefinedFunction, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, + UserDefinedFunctionValidation, + _function_input_to_proto, +) +from sift_client.transport import WithGrpcClient + +if TYPE_CHECKING: + from sift_client.transport.grpc_transport import GrpcClient + +# Configure logging +logger = logging.getLogger(__name__) + + +class UserDefinedFunctionsLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the UserDefinedFunctionsAPI. + + A thin wrapper around the autogenerated bindings for the UserDefinedFunctionService. + """ + + def __init__(self, grpc_client: GrpcClient): + """Initialize the UserDefinedFunctionsLowLevelClient. + + Args: + grpc_client: The gRPC client to use for making API calls. + """ + super().__init__(grpc_client) + + async def get_function(self, function_id: str) -> UserDefinedFunction: + """Get a function by ID. + + Args: + function_id: The function ID to get. + + Returns: + The UserDefinedFunction. + """ + request = GetUserDefinedFunctionRequest(user_defined_function_id=function_id) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).GetUserDefinedFunction(request) + return UserDefinedFunction._from_proto( + cast("GetUserDefinedFunctionResponse", response).user_defined_function + ) + + async def get_version(self, version_id: str) -> UserDefinedFunction: + """Get one version of a function. + + Args: + version_id: The version ID to get. + + Returns: + The UserDefinedFunction at that version. + """ + request = GetUserDefinedFunctionVersionRequest(user_defined_function_version_id=version_id) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).GetUserDefinedFunctionVersion(request) + return UserDefinedFunction._from_proto( + cast("GetUserDefinedFunctionVersionResponse", response).user_defined_function + ) + + async def list_functions( + 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[UserDefinedFunction], str]: + """List one page of functions. + + Args: + page_size: The maximum number of functions to return. + page_token: A page token for pagination. + query_filter: A CEL filter string. + order_by: How to order the retrieved functions. + + Returns: + A tuple of (functions, 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 = ListUserDefinedFunctionsRequest(**request_kwargs) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).ListUserDefinedFunctions(request) + response = cast("ListUserDefinedFunctionsResponse", response) + + functions = [UserDefinedFunction._from_proto(f) for f in response.user_defined_functions] + return functions, response.next_page_token + + async def list_all_functions( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + max_results: int | None = None, + ) -> list[UserDefinedFunction]: + """List every matching function. + + Args: + query_filter: A CEL filter string. + order_by: How to order the retrieved functions. + page_size: The number of results to fetch per request. + max_results: Maximum number of results to return. + + Returns: + A list of all matching functions. + """ + return await self._handle_pagination( + self.list_functions, + kwargs={"query_filter": query_filter}, + order_by=order_by, + max_results=max_results, + page_size=page_size, + ) + + async def list_versions( + self, + *, + function_id: str | None = None, + name: str | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + ) -> tuple[list[UserDefinedFunction], str]: + """List one page of versions for a function. + + Args: + function_id: The function whose versions to list. + name: The function name, as an alternative to the ID. + page_size: The maximum number of versions to return. + page_token: A page token for pagination. + query_filter: A CEL filter string. + order_by: How to order the retrieved versions. + + Returns: + A tuple of (versions, next_page_token). + """ + request_kwargs: dict[str, Any] = {} + if function_id is not None: + request_kwargs["user_defined_function_id"] = function_id + if name is not None: + request_kwargs["name"] = name + 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 = ListUserDefinedFunctionVersionsRequest(**request_kwargs) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).ListUserDefinedFunctionVersions(request) + response = cast("ListUserDefinedFunctionVersionsResponse", response) + + versions = [UserDefinedFunction._from_proto(f) for f in response.user_defined_functions] + return versions, response.next_page_token + + async def list_all_versions( + self, + *, + function_id: str | None = None, + name: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + max_results: int | None = None, + ) -> list[UserDefinedFunction]: + """List every matching version. + + Args: + function_id: The function whose versions to list. + name: The function name, as an alternative to the ID. + query_filter: A CEL filter string. + order_by: How to order the retrieved versions. + page_size: The number of results to fetch per request. + max_results: Maximum number of results to return. + + Returns: + A list of all matching versions. + """ + return await self._handle_pagination( + self.list_versions, + kwargs={"function_id": function_id, "name": name, "query_filter": query_filter}, + order_by=order_by, + max_results=max_results, + page_size=page_size, + ) + + async def create_function(self, *, create: UserDefinedFunctionCreate) -> UserDefinedFunction: + """Create a function. + + Args: + create: The function definition. + + Returns: + The created UserDefinedFunction. + """ + request_proto = create.to_proto() + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).CreateUserDefinedFunction(request_proto) + return UserDefinedFunction._from_proto( + cast("CreateUserDefinedFunctionResponse", response).user_defined_function + ) + + async def update_function(self, update: UserDefinedFunctionUpdate) -> UserDefinedFunction: + """Update select fields of a function. + + Args: + update: The updates to apply. Its `resource_id` must be set. + + Returns: + The updated UserDefinedFunction. + """ + grpc_function, update_mask = update.to_proto_with_mask() + request = UpdateUserDefinedFunctionRequest( + user_defined_function=grpc_function, update_mask=update_mask + ) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).UpdateUserDefinedFunction(request) + return UserDefinedFunction._from_proto( + cast("UpdateUserDefinedFunctionResponse", response).user_defined_function + ) + + async def validate_function( + self, *, expression: str, function_inputs: list[FunctionInput] | None = None + ) -> UserDefinedFunctionValidation: + """Check an expression without saving it. + + Args: + expression: The expression to check. + function_inputs: The inputs the expression refers to. + + Returns: + Whether the expression compiles, and its output type or error. + """ + request = ValidateUserDefinedFunctionRequest( + expression=expression, + function_inputs=[ + _function_input_to_proto(**i.model_dump()) for i in function_inputs or [] + ], + ) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).ValidateUserDefinedFunction(request) + response = cast("ValidateUserDefinedFunctionResponse", response) + + if response.WhichOneof("result") == "error": + return UserDefinedFunctionValidation(valid=False, error=response.error.error_message) + output_type = response.success.user_defined_function.function_output_type + return UserDefinedFunctionValidation( + valid=True, + output_type=FunctionDataType(output_type) if output_type else None, + ) + + async def get_dependents( + self, + *, + function_id: str | None = None, + name: str | None = None, + version_id: str | None = None, + ) -> FunctionDependents: + """Get what depends on a function. + + Exactly one identifier must be provided. + + Args: + function_id: The function ID. + name: The function name. + version_id: A specific version ID. + + Returns: + The IDs of dependent functions, calculated channels, and rules. + + Raises: + ValueError: If not exactly one identifier is provided. + """ + given = [v for v in (function_id, name, version_id) if v is not None] + if len(given) != 1: + raise ValueError("Exactly one of function_id, name, or version_id must be provided") + + request_kwargs: dict[str, Any] = {} + if function_id is not None: + request_kwargs["user_defined_function_id"] = function_id + elif name is not None: + request_kwargs["user_defined_function_name"] = name + else: + request_kwargs["user_defined_function_version_id"] = version_id + + request = GetUserDefinedFunctionDependentsRequest(**request_kwargs) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).GetUserDefinedFunctionDependents(request) + response = cast("GetUserDefinedFunctionDependentsResponse", response) + + return FunctionDependents( + function_ids=[f.user_defined_function_id for f in response.user_defined_functions], + calculated_channel_ids=[c.calculated_channel_id for c in response.calculated_channels], + rule_ids=[r.rule_id for r in response.rules], + ) diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index 23f2dd7a62..49d399fab5 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -92,6 +92,7 @@ def mock_client(): client.jobs = MagicMock() client.data_import = MagicMock() client.data_export = MagicMock() + client.user_defined_functions = MagicMock() client.async_ = MagicMock(spec=AsyncAPIs) client.async_.ingestion = MagicMock() return client diff --git a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py new file mode 100644 index 0000000000..b288630f77 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py @@ -0,0 +1,302 @@ +"""Pytest tests for the User Defined Functions API. + +These tests demonstrate and validate the usage of the UserDefinedFunctionsAPI including: +- Basic function operations (get, list, find) +- Creation, updates, and archiving +- Versions, validation, dependents, and sync +""" + +from datetime import datetime, timezone + +import pytest + +from sift_client import SiftClient +from sift_client.resources import ( + UserDefinedFunctionsAPI, + UserDefinedFunctionsAPIAsync, + UserDefinedFunctionVersionsAPI, +) +from sift_client.sift_types import UserDefinedFunction +from sift_client.sift_types.user_defined_function import ( + FunctionDataType, + FunctionInput, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, +) + +pytestmark = pytest.mark.integration + + +def test_client_binding(sift_client): + assert isinstance(sift_client.user_defined_functions, UserDefinedFunctionsAPI) + assert isinstance(sift_client.user_defined_functions.versions, UserDefinedFunctionVersionsAPI) + assert isinstance(sift_client.async_.user_defined_functions, UserDefinedFunctionsAPIAsync) + + +@pytest.fixture(scope="session") +def test_timestamp_str(): + """A per-session suffix so function names stay unique across runs.""" + return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + + +@pytest.fixture +def functions_api_async(sift_client: SiftClient): + """Get the async functions API instance.""" + return sift_client.async_.user_defined_functions + + +@pytest.fixture(scope="session") +def new_function(sift_client, test_timestamp_str): + """Create a function for the session and archive it on teardown.""" + created = sift_client.user_defined_functions.create( + UserDefinedFunctionCreate( + name=f"test_fn_{test_timestamp_str}", + description="Created by Sift Client pytest", + expression="$1 * 2", + function_inputs=[FunctionInput(identifier="$1")], + ) + ) + yield created + sift_client.user_defined_functions.archive(created) + + +class TestUserDefinedFunctions: + """Tests for the User Defined Functions API.""" + + def test_create(self, new_function, test_timestamp_str): + """Test that create returns a fully populated function.""" + assert isinstance(new_function, UserDefinedFunction) + assert new_function.id_ is not None + assert new_function.name == f"test_fn_{test_timestamp_str}" + assert new_function.expression == "$1 * 2" + assert [i.identifier for i in new_function.function_inputs] == ["$1"] + assert new_function.version == 1 + assert new_function.version_id + assert new_function.is_archived is False + + def test_get(self, sift_client, new_function): + """Test getting a function by ID.""" + fetched = sift_client.user_defined_functions.get(new_function._id_or_error) + + assert fetched.id_ == new_function.id_ + assert fetched.name == new_function.name + + def test_basic_list(self, sift_client, new_function): + """Test basic function listing functionality.""" + functions = sift_client.user_defined_functions.list_(limit=5) + + assert isinstance(functions, list) + for function in functions: + assert isinstance(function, UserDefinedFunction) + assert function.id_ is not None + + def test_list_with_name_filter(self, sift_client, new_function): + """Test function listing with name filtering.""" + by_name = sift_client.user_defined_functions.list_(name=new_function.name) + + assert [f.id_ for f in by_name] == [new_function.id_] + + def test_list_with_id_filter(self, sift_client, new_function): + """Test function listing filtered to specific IDs.""" + functions = sift_client.user_defined_functions.list_( + function_ids=[new_function._id_or_error] + ) + + assert [f.id_ for f in functions] == [new_function.id_] + + def test_find(self, sift_client, new_function): + """Test finding a single function.""" + found = sift_client.user_defined_functions.find(name=new_function.name) + + assert found is not None + assert found.id_ == new_function.id_ + + def test_find_nonexistent(self, sift_client): + """Test finding a non-existent function returns None.""" + found = sift_client.user_defined_functions.find( + name=f"nonexistent_fn_{datetime.now(timezone.utc).timestamp()}" + ) + assert found is None + + def test_update_creates_a_version(self, sift_client, new_function): + """Test that updating a function bumps its version.""" + updated = sift_client.user_defined_functions.update( + new_function, UserDefinedFunctionUpdate(description="tweaked") + ) + + assert updated.description == "tweaked" + assert updated.version > new_function.version + # The name was not in the mask, so it is unchanged. + assert updated.name == new_function.name + + def test_update_accepts_dict(self, sift_client, new_function): + """Test that update accepts a plain dict.""" + updated = sift_client.user_defined_functions.update( + new_function._id_or_error, {"description": "via dict"} + ) + + assert updated.description == "via dict" + + def test_archive_and_unarchive(self, sift_client, test_timestamp_str): + """Test archiving and unarchiving a function.""" + function = sift_client.user_defined_functions.create( + UserDefinedFunctionCreate( + name=f"test_fn_archive_{test_timestamp_str}", + expression="$1 + 1", + function_inputs=[FunctionInput(identifier="$1")], + ) + ) + + archived = sift_client.user_defined_functions.archive(function) + assert archived.is_archived is True + + # Archived functions are excluded from list_ by default. + assert sift_client.user_defined_functions.find(name=function.name) is None + assert ( + sift_client.user_defined_functions.find(name=function.name, include_archived=True).id_ + == function.id_ + ) + + unarchived = sift_client.user_defined_functions.unarchive(function) + assert unarchived.is_archived is False + + sift_client.user_defined_functions.archive(function) + + def test_instance_methods(self, sift_client, test_timestamp_str): + """Test the update and archive methods on the instance itself.""" + function = sift_client.user_defined_functions.create( + UserDefinedFunctionCreate( + name=f"test_fn_instance_{test_timestamp_str}", + expression="$1 - 1", + function_inputs=[FunctionInput(identifier="$1")], + ) + ) + + function.update({"description": "from-instance"}) + assert function.description == "from-instance" + + function.archive() + assert function.is_archived is True + + def test_validate_accepts_a_good_expression(self, sift_client): + """Test validating an expression that compiles.""" + result = sift_client.user_defined_functions.validate( + "$1 * 2", [FunctionInput(identifier="$1")] + ) + + assert result.valid is True + assert result.error is None + + def test_validate_rejects_a_bad_expression(self, sift_client): + """Test validating an expression that does not compile.""" + result = sift_client.user_defined_functions.validate( + "$1 ** ** 2", [FunctionInput(identifier="$1")] + ) + + assert result.valid is False + assert result.error + + def test_dependents_of_an_unused_function(self, sift_client, new_function): + """Test that a function nothing uses reports no dependents.""" + dependents = sift_client.user_defined_functions.dependents(new_function) + + assert dependents.any is False + + @pytest.mark.asyncio + async def test_async_list(self, functions_api_async, new_function): + """Test the async API returns the same functions.""" + functions = await functions_api_async.list_(function_ids=[new_function._id_or_error]) + + assert [f.id_ for f in functions] == [new_function.id_] + + +class TestUserDefinedFunctionVersions: + """Tests for the nested versions API.""" + + def test_list_versions(self, sift_client, new_function): + """Test listing a function's versions.""" + versions = sift_client.user_defined_functions.versions.list_(function=new_function) + + assert versions + assert all(v.id_ == new_function.id_ for v in versions) + + def test_get_version(self, sift_client, new_function): + """Test getting one version by ID.""" + version = sift_client.user_defined_functions.versions.get(new_function.version_id) + + assert version.version_id == new_function.version_id + + def test_versions_property(self, sift_client, new_function): + """Test the versions property on the instance.""" + assert new_function.versions + + +class TestUserDefinedFunctionSync: + """Tests for sync, which creates or updates by name.""" + + def test_sync_creates_then_updates(self, sift_client, test_timestamp_str): + """Test that sync creates a missing function and updates an existing one.""" + name = f"test_fn_sync_{test_timestamp_str}" + + created = sift_client.user_defined_functions.sync( + [ + UserDefinedFunctionCreate( + name=name, + expression="$1 * 2", + function_inputs=[FunctionInput(identifier="$1")], + ) + ] + ) + assert len(created) == 1 + assert created[0].name == name + first_version = created[0].version + + updated = sift_client.user_defined_functions.sync( + [ + UserDefinedFunctionCreate( + name=name, + expression="$1 * 2", + function_inputs=[FunctionInput(identifier="$1")], + description="second pass", + ) + ] + ) + assert updated[0].id_ == created[0].id_ + assert updated[0].version > first_version + assert updated[0].description == "second pass" + + sift_client.user_defined_functions.archive(updated[0]) + + def test_sync_is_ordered(self, sift_client, test_timestamp_str): + """Test that sync returns results in the order given.""" + names = [f"test_fn_sync{i}_{test_timestamp_str}" for i in range(2)] + + results = sift_client.user_defined_functions.sync( + [ + UserDefinedFunctionCreate( + name=n, expression="$1 + 1", function_inputs=[FunctionInput(identifier="$1")] + ) + for n in names + ] + ) + + assert [r.name for r in results] == names + for result in results: + sift_client.user_defined_functions.archive(result) + + +class TestFunctionDataTypes: + """Tests that non-numeric input types round trip.""" + + def test_string_input(self, sift_client, test_timestamp_str): + """Test a function with a string input.""" + function = sift_client.user_defined_functions.create( + UserDefinedFunctionCreate( + name=f"test_fn_string_{test_timestamp_str}", + expression="$1", + function_inputs=[FunctionInput(identifier="$1", data_type=FunctionDataType.STRING)], + ) + ) + + assert function.function_inputs[0].data_type is FunctionDataType.STRING + sift_client.user_defined_functions.archive(function) diff --git a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py new file mode 100644 index 0000000000..8d36561ff0 --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py @@ -0,0 +1,186 @@ +"""Tests for sift_types.UserDefinedFunction model.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from sift_client.sift_types import UserDefinedFunction +from sift_client.sift_types.user_defined_function import ( + FunctionDataType, + FunctionDependents, + FunctionInput, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, +) + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +class TestFunctionInput: + """Unit tests for FunctionInput.""" + + def test_defaults(self): + given = FunctionInput(identifier="$1") + + assert given.data_type is FunctionDataType.NUMERIC + assert given.constant is False + + def test_round_trip(self): + from sift.common.type.v1.user_defined_functions_pb2 import FunctionInput as Proto + + original = FunctionInput(identifier="$1", data_type=FunctionDataType.STRING, constant=True) + proto = Proto(identifier="$1", data_type=FunctionDataType.STRING.value, constant=True) + + assert FunctionInput._from_proto(proto) == original + + +class TestUserDefinedFunctionCreate: + """Unit tests for UserDefinedFunctionCreate - tests _to_proto_helpers.""" + + def test_minimal_create(self): + proto = UserDefinedFunctionCreate(name="double", expression="$1 * 2").to_proto() + + assert proto.name == "double" + assert proto.expression == "$1 * 2" + + def test_function_inputs_converter(self): + proto = UserDefinedFunctionCreate( + name="double", + expression="$1 * 2", + function_inputs=[ + FunctionInput(identifier="$1"), + FunctionInput(identifier="$2", data_type=FunctionDataType.STRING, constant=True), + ], + ).to_proto() + + assert len(proto.function_inputs) == 2 + assert proto.function_inputs[0].identifier == "$1" + assert proto.function_inputs[0].data_type == FunctionDataType.NUMERIC.value + assert proto.function_inputs[1].data_type == FunctionDataType.STRING.value + assert proto.function_inputs[1].constant is True + + def test_metadata_converter(self): + proto = UserDefinedFunctionCreate( + name="double", expression="$1 * 2", metadata={"owner": "ops", "n": 2.0} + ).to_proto() + + by_key = {m.key.name: m for m in proto.metadata} + assert by_key["owner"].string_value == "ops" + assert by_key["n"].number_value == 2.0 + + +class TestUserDefinedFunctionUpdate: + """Unit tests for UserDefinedFunctionUpdate - tests field masks.""" + + def test_update_mask_only_includes_set_fields(self): + update = UserDefinedFunctionUpdate(expression="$1 * 3", description="tripled") + update.resource_id = "fn-1" + + proto, mask = update.to_proto_with_mask() + + assert proto.user_defined_function_id == "fn-1" + assert proto.expression == "$1 * 3" + assert set(mask.paths) == {"expression", "description"} + + def test_archive_update(self): + update = UserDefinedFunctionUpdate(is_archived=True) + update.resource_id = "fn-1" + + proto, mask = update.to_proto_with_mask() + + assert proto.is_archived is True + assert mask.paths == ["is_archived"] + + def test_requires_resource_id(self): + with pytest.raises(ValueError, match="Resource ID must be set"): + UserDefinedFunctionUpdate(expression="$1").to_proto_with_mask() + + +class TestFunctionDependents: + """Unit tests for FunctionDependents.""" + + def test_any_is_false_when_empty(self): + assert FunctionDependents().any is False + + def test_any_is_true_with_one_rule(self): + assert FunctionDependents(rule_ids=["r-1"]).any is True + + +@pytest.fixture +def mock_function(mock_client): + """Create a mock UserDefinedFunction instance for testing.""" + function = UserDefinedFunction( + proto=MagicMock(), + id_="fn-1", + name="double", + description="doubles the input", + expression="$1 * 2", + version=1, + version_id="ver-1", + change_message="created", + user_notes="", + function_inputs=[FunctionInput(identifier="$1")], + dependency_version_ids=[], + metadata={}, + created_date=NOW, + modified_date=NOW, + created_by_user_id="user1", + modified_by_user_id="user1", + is_archived=False, + output_type=FunctionDataType.NUMERIC, + archived_date=None, + ) + function._apply_client_to_instance(mock_client) + return function + + +class TestUserDefinedFunction: + """Unit tests for UserDefinedFunction model - tests properties and methods.""" + + def test_versions_property_calls_client(self, mock_function, mock_client): + mock_client.user_defined_functions.versions.list_.return_value = [] + + _ = mock_function.versions + + mock_client.user_defined_functions.versions.list_.assert_called_once_with(function="fn-1") + + def test_update_calls_client_and_updates_self(self, mock_function, mock_client): + updated = MagicMock() + mock_client.user_defined_functions.update.return_value = updated + + with MagicMock() as mock_update: + mock_function._update = mock_update + + update = UserDefinedFunctionUpdate(expression="$1 * 3") + result = mock_function.update(update) + + mock_client.user_defined_functions.update.assert_called_once_with( + function=mock_function, update=update + ) + mock_update.assert_called_once_with(updated) + assert result is mock_function + + def test_archive_calls_client(self, mock_function, mock_client): + mock_client.user_defined_functions.archive.return_value = MagicMock() + + with MagicMock() as mock_update: + mock_function._update = mock_update + result = mock_function.archive() + + mock_client.user_defined_functions.archive.assert_called_once_with( + function=mock_function + ) + assert result is mock_function + + def test_unarchive_calls_client(self, mock_function, mock_client): + mock_client.user_defined_functions.unarchive.return_value = MagicMock() + + with MagicMock() as mock_update: + mock_function._update = mock_update + result = mock_function.unarchive() + + mock_client.user_defined_functions.unarchive.assert_called_once_with( + function=mock_function + ) + assert result is mock_function diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 5c1c49551c..55ef4a4472 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -40,6 +40,8 @@ TagsAPIAsync, TestResultsAPI, TestResultsAPIAsync, + UserDefinedFunctionsAPI, + UserDefinedFunctionsAPIAsync, UsersAPI, UsersAPIAsync, ) @@ -137,6 +139,9 @@ class SiftClient( test_results: TestResultsAPI """Instance of the Test Results API for making synchronous requests.""" + user_defined_functions: UserDefinedFunctionsAPI + """Instance of the User Defined Functions API for making synchronous requests.""" + users: UsersAPI """Instance of the Users API for making synchronous requests.""" @@ -248,6 +253,7 @@ def __init__( ) self.tags = TagsAPI(self) self.test_results = TestResultsAPI(self) + self.user_defined_functions = UserDefinedFunctionsAPI(self) self.users = UsersAPI(self) self.data_export = DataExportAPI(self) self.data_import = DataImportAPI(self) @@ -270,6 +276,7 @@ def __init__( ), tags=TagsAPIAsync(self), test_results=TestResultsAPIAsync(self), + user_defined_functions=UserDefinedFunctionsAPIAsync(self), users=UsersAPIAsync(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 efa97fba3d..4cde9e3e4c 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -180,6 +180,10 @@ async def main(): from sift_client.resources.runs import RunsAPIAsync from sift_client.resources.tags import TagsAPIAsync from sift_client.resources.test_results import TestResultsAPIAsync +from sift_client.resources.user_defined_functions import ( + UserDefinedFunctionsAPIAsync, + UserDefinedFunctionVersionsAPIAsync, +) from sift_client.resources.users import UsersAPIAsync from sift_client.resources.data_imports import DataImportAPIAsync from sift_client.resources.exports import DataExportAPIAsync @@ -205,6 +209,8 @@ async def main(): RunsAPI, TagsAPI, TestResultsAPI, + UserDefinedFunctionsAPI, + UserDefinedFunctionVersionsAPI, UsersAPI, FileAttachmentsAPI, DataExportAPI, @@ -265,6 +271,10 @@ async def main(): "TestResultsAPI", "TestResultsAPIAsync", "TracingConfig", + "UserDefinedFunctionVersionsAPI", + "UserDefinedFunctionVersionsAPIAsync", + "UserDefinedFunctionsAPI", + "UserDefinedFunctionsAPIAsync", "UsersAPI", "UsersAPIAsync", "DataExportAPI", diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index b9544706d2..cfd66a3508 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -26,6 +26,8 @@ RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, + UserDefinedFunctionsAPIAsync, + UserDefinedFunctionVersionsAPIAsync, UsersAPIAsync, ) @@ -82,6 +84,15 @@ ) TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") +# UserDefinedFunctionVersionsAPI must be generated before UserDefinedFunctionsAPI so it can be nested under it. +UserDefinedFunctionVersionsAPI = generate_sync_api( + UserDefinedFunctionVersionsAPIAsync, "UserDefinedFunctionVersionsAPI" +) +UserDefinedFunctionsAPI = generate_sync_api( + UserDefinedFunctionsAPIAsync, + "UserDefinedFunctionsAPI", + nested_resources={"versions": UserDefinedFunctionVersionsAPI}, +) UsersAPI = generate_sync_api(UsersAPIAsync, "UsersAPI") DataExportAPI = generate_sync_api(DataExportAPIAsync, "DataExportAPI") DataImportAPI = generate_sync_api(DataImportAPIAsync, "DataImportAPI") @@ -109,5 +120,7 @@ "RunsAPI", "TagsAPI", "TestResultsAPI", + "UserDefinedFunctionVersionsAPI", + "UserDefinedFunctionsAPI", "UsersAPI", ] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 9528097604..7dd1f55f5d 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -85,6 +85,14 @@ if TYPE_CHECKING: TestStepUpdate, ) from sift_client.sift_types.user import User + from sift_client.sift_types.user_defined_function import ( + FunctionDependents, + FunctionInput, + UserDefinedFunction, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, + UserDefinedFunctionValidation, + ) class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. @@ -3459,6 +3467,233 @@ 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 get(self, version_id: str) -> UserDefinedFunction: + """Get one version. + + Args: + version_id: The ID of the version. + + Returns: + The function at that version. + """ + ... + + def list_( + self, + *, + 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[UserDefinedFunction]: + """List a function's versions. + + Args: + function: The UserDefinedFunction or function ID whose versions to list. + name: The function name, as an alternative to `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 UserDefinedFunction objects, one per version. + """ + ... + +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, function: str | UserDefinedFunction) -> UserDefinedFunction: + """Archive a function. + + Args: + 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 dependents( + self, function: str | UserDefinedFunction, *, version_id: str | None = None + ) -> FunctionDependents: + """Get what depends on a function. + + Check this before changing inputs or the output type. The server refuses those + changes once a function has dependents. + + Args: + function: The UserDefinedFunction or function ID. + version_id: A specific version, instead of the function as a whole. + + Returns: + The IDs of dependent functions, calculated channels, and rules. + """ + ... + + 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, function_id: str) -> UserDefinedFunction: + """Get a UserDefinedFunction. + + Args: + function_id: The ID of the function. + + Returns: + The UserDefinedFunction. + """ + ... + + def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + function_ids: list[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[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. + function_ids: Filter to functions with any of these 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 sync(self, functions: list[UserDefinedFunctionCreate | dict]) -> list[UserDefinedFunction]: + """Create or update each function so Sift matches the definitions given. + + Functions match by name. A new name is created. An existing one is updated, + which produces a new version. Nothing is archived. + + Args: + functions: The function definitions to apply. + + Returns: + The created or updated functions, in the order given. + """ + ... + + def unarchive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + """Unarchive a function. + + Args: + function: The UserDefinedFunction or function ID to unarchive. + + Returns: + The unarchived UserDefinedFunction. + """ + ... + + def update( + self, function: str | UserDefinedFunction, update: UserDefinedFunctionUpdate | dict + ) -> UserDefinedFunction: + """Update a function. This creates a new version. + + Args: + function: The UserDefinedFunction or function ID to update. + update: Updates to apply to the function. + + Returns: + The updated UserDefinedFunction. + """ + ... + + def validate( + self, expression: str, function_inputs: list[FunctionInput] | None = None + ) -> UserDefinedFunctionValidation: + """Check an expression without saving it. + + Args: + expression: The expression to check. + function_inputs: The inputs the expression refers to. + + 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`. diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py new file mode 100644 index 0000000000..09a48af35e --- /dev/null +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.user_defined_functions import ( + UserDefinedFunctionsLowLevelClient, +) +from sift_client.resources._base import ResourceBase +from sift_client.sift_types.user_defined_function import ( + FunctionDependents, + FunctionInput, + UserDefinedFunction, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, + UserDefinedFunctionValidation, +) +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + + from sift_client.client import SiftClient + + +class UserDefinedFunctionVersionsAPIAsync(ResourceBase): + """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. + """ + super().__init__(sift_client) + self._low_level_client = UserDefinedFunctionsLowLevelClient( + grpc_client=self.client.grpc_client + ) + + async def get(self, version_id: str) -> UserDefinedFunction: + """Get one version. + + Args: + version_id: The ID of the version. + + Returns: + The function at that version. + """ + version = await self._low_level_client.get_version(version_id=version_id) + return self._apply_client_to_instance(version) + + async def list_( + self, + *, + function: str | UserDefinedFunction | None = None, + name: str | None = None, + # version specific + version: int | 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[UserDefinedFunction]: + """List a function's versions. + + Args: + function: The UserDefinedFunction or function ID whose versions to list. + name: The function name, as an alternative to `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 UserDefinedFunction objects, one per version. + """ + filter_parts = self._build_common_cel_filters( + include_archived=include_archived, filter_query=filter_query + ) + if version is not None: + filter_parts.append(cel.equals("version", version)) + query_filter = cel.and_(*filter_parts) + + versions = await self._low_level_client.list_all_versions( + function_id=( + function._id_or_error if isinstance(function, UserDefinedFunction) else function + ), + name=name, + 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(versions) + + +class UserDefinedFunctionsAPIAsync(ResourceBase): + """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. + """ + super().__init__(sift_client) + self._low_level_client = UserDefinedFunctionsLowLevelClient( + grpc_client=self.client.grpc_client + ) + self.versions = UserDefinedFunctionVersionsAPIAsync(sift_client) + + async def get(self, function_id: str) -> UserDefinedFunction: + """Get a UserDefinedFunction. + + Args: + function_id: The ID of the function. + + Returns: + The UserDefinedFunction. + """ + function = await self._low_level_client.get_function(function_id=function_id) + return self._apply_client_to_instance(function) + + async def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + # self ids + function_ids: list[str] | 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[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. + function_ids: Filter to functions with any of these 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. + """ + filter_parts = [ + *self._build_name_cel_filters( + name=name, name_contains=name_contains, name_regex=name_regex + ), + *self._build_common_cel_filters( + include_archived=include_archived, filter_query=filter_query + ), + ] + if function_ids: + filter_parts.append(cel.in_("user_defined_function_id", function_ids)) + query_filter = cel.and_(*filter_parts) + + functions = await self._low_level_client.list_all_functions( + 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(functions) + + async 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. + """ + functions = await self.list_(**kwargs) + if len(functions) > 1: + raise ValueError(f"Multiple ({len(functions)}) functions found for query") + elif len(functions) == 1: + return functions[0] + return None + + async def create(self, create: UserDefinedFunctionCreate | dict) -> UserDefinedFunction: + """Create a new function. + + Args: + create: The function definition. + + Returns: + The created UserDefinedFunction. + """ + if isinstance(create, dict): + create = UserDefinedFunctionCreate.model_validate(create) + created = await self._low_level_client.create_function(create=create) + return self._apply_client_to_instance(created) + + async def update( + self, + function: str | UserDefinedFunction, + update: UserDefinedFunctionUpdate | dict, + ) -> UserDefinedFunction: + """Update a function. This creates a new version. + + Args: + function: The UserDefinedFunction or function ID to update. + update: Updates to apply to the function. + + Returns: + The updated UserDefinedFunction. + """ + function_id = ( + function._id_or_error if isinstance(function, UserDefinedFunction) else function + ) + if isinstance(update, dict): + update = UserDefinedFunctionUpdate.model_validate(update) + update.resource_id = function_id + updated = await self._low_level_client.update_function(update) + return self._apply_client_to_instance(updated) + + async def sync( + self, functions: list[UserDefinedFunctionCreate | dict] + ) -> list[UserDefinedFunction]: + """Create or update each function so Sift matches the definitions given. + + Functions match by name. A new name is created. An existing one is updated, + which produces a new version. Nothing is archived. + + Args: + functions: The function definitions to apply. + + Returns: + The created or updated functions, in the order given. + """ + definitions = [ + UserDefinedFunctionCreate.model_validate(f) if isinstance(f, dict) else f + for f in functions + ] + wanted = {d.name for d in definitions} + existing = {f.name: f for f in await self.list_(include_archived=True) if f.name in wanted} + + results = [] + for definition in definitions: + current = existing.get(definition.name) + if current is None: + results.append(await self.create(definition)) + continue + changes = definition.model_dump(exclude_unset=True, exclude={"name"}) + results.append(await self.update(current, changes)) + return results + + async def validate( + self, expression: str, function_inputs: list[FunctionInput] | None = None + ) -> UserDefinedFunctionValidation: + """Check an expression without saving it. + + Args: + expression: The expression to check. + function_inputs: The inputs the expression refers to. + + Returns: + Whether the expression compiles, and its output type or error. + """ + return await self._low_level_client.validate_function( + expression=expression, function_inputs=function_inputs + ) + + async def dependents( + self, function: str | UserDefinedFunction, *, version_id: str | None = None + ) -> FunctionDependents: + """Get what depends on a function. + + Check this before changing inputs or the output type. The server refuses those + changes once a function has dependents. + + Args: + function: The UserDefinedFunction or function ID. + version_id: A specific version, instead of the function as a whole. + + Returns: + The IDs of dependent functions, calculated channels, and rules. + """ + if version_id is not None: + return await self._low_level_client.get_dependents(version_id=version_id) + function_id = ( + function._id_or_error if isinstance(function, UserDefinedFunction) else function + ) + return await self._low_level_client.get_dependents(function_id=function_id) + + async def archive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + """Archive a function. + + Args: + function: The UserDefinedFunction or function ID to archive. + + Returns: + The archived UserDefinedFunction. + """ + return await self.update(function, UserDefinedFunctionUpdate(is_archived=True)) + + async def unarchive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + """Unarchive a function. + + Args: + function: The UserDefinedFunction or function ID to unarchive. + + Returns: + The unarchived UserDefinedFunction. + """ + return await self.update(function, UserDefinedFunctionUpdate(is_archived=False)) diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index 45589c9f0e..4bdf442150 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -217,6 +217,15 @@ TestStepType, ) from sift_client.sift_types.user import User, UserOrganization +from sift_client.sift_types.user_defined_function import ( + FunctionDataType, + FunctionDependents, + FunctionInput, + UserDefinedFunction, + UserDefinedFunctionCreate, + UserDefinedFunctionUpdate, + UserDefinedFunctionValidation, +) if "pytest" in sys.modules: # These are not test classes, so we need to set __test__ to False to avoid pytest warnings. @@ -253,6 +262,9 @@ "DataImportStatusDetails", "Flow", "FlowConfig", + "FunctionDataType", + "FunctionDependents", + "FunctionInput", "IngestionConfig", "IngestionConfigCreate", "Job", @@ -310,5 +322,9 @@ "TestStepCreate", "TestStepType", "User", + "UserDefinedFunction", + "UserDefinedFunctionCreate", + "UserDefinedFunctionUpdate", + "UserDefinedFunctionValidation", "UserOrganization", ] diff --git a/python/lib/sift_client/sift_types/user_defined_function.py b/python/lib/sift_client/sift_types/user_defined_function.py new file mode 100644 index 0000000000..afbf055f89 --- /dev/null +++ b/python/lib/sift_client/sift_types/user_defined_function.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, ClassVar + +from pydantic import BaseModel +from sift.common.type.v1.user_defined_functions_pb2 import ( + FunctionDataType as FunctionDataTypeProto, +) +from sift.common.type.v1.user_defined_functions_pb2 import ( + FunctionInput as FunctionInputProto, +) +from sift.common.type.v1.user_defined_functions_pb2 import ( + UserDefinedFunction as UserDefinedFunctionProto, +) +from sift.user_defined_functions.v1.user_defined_functions_pb2 import ( + CreateUserDefinedFunctionRequest as CreateUserDefinedFunctionRequestProto, +) + +from sift_client.sift_types._base import ( + BaseType, + MappingHelper, + ModelCreate, + ModelCreateUpdateBase, + ModelUpdate, +) +from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class FunctionDataType(Enum): + """Enum for the data type of a function input or output.""" + + UNSPECIFIED = FunctionDataTypeProto.FUNCTION_DATA_TYPE_UNSPECIFIED # 0 + NUMERIC = FunctionDataTypeProto.FUNCTION_DATA_TYPE_NUMERIC # 1 + STRING = FunctionDataTypeProto.FUNCTION_DATA_TYPE_STRING # 2 + BOOL = FunctionDataTypeProto.FUNCTION_DATA_TYPE_BOOL # 3 + + +class FunctionInput(BaseModel): + """An input the function's expression refers to. + + Attributes: + identifier: How the expression refers to this input, such as `$1`. + data_type: The type the input accepts. + constant: Whether the input is a literal rather than a channel. + """ + + identifier: str + data_type: FunctionDataType = FunctionDataType.NUMERIC + constant: bool = False + + @classmethod + def _from_proto(cls, proto: FunctionInputProto) -> FunctionInput: + return cls( + identifier=proto.identifier, + data_type=FunctionDataType(proto.data_type), + constant=proto.constant, + ) + + +def _function_input_to_proto(**kwargs) -> FunctionInputProto: + """Convert a function input dict (from model_dump) into its proto form.""" + data_type = kwargs.get("data_type", FunctionDataType.NUMERIC) + return FunctionInputProto( + identifier=kwargs["identifier"], + data_type=data_type.value if isinstance(data_type, FunctionDataType) else data_type, + constant=kwargs.get("constant", False), + ) + + +class UserDefinedFunction(BaseType[UserDefinedFunctionProto, "UserDefinedFunction"]): + """A reusable expression that calculated channels and rules can call. + + Each save produces a new version. `id_` identifies the function; `version_id` + identifies this particular version. + """ + + # Required fields + name: str + description: str + expression: str + version: int + version_id: str + change_message: str + user_notes: str + function_inputs: list[FunctionInput] + dependency_version_ids: list[str] + metadata: dict[str, str | float | bool] + created_date: datetime + modified_date: datetime + created_by_user_id: str + modified_by_user_id: str + is_archived: bool + + # Optional fields + output_type: FunctionDataType | None + archived_date: datetime | None + + @classmethod + def _from_proto( + cls, proto: UserDefinedFunctionProto, sift_client: SiftClient | None = None + ) -> UserDefinedFunction: + return cls( + proto=proto, + id_=proto.user_defined_function_id, + name=proto.name, + description=proto.description, + expression=proto.expression, + version=proto.version, + version_id=proto.user_defined_function_version_id, + change_message=proto.change_message, + user_notes=proto.user_notes, + function_inputs=[FunctionInput._from_proto(i) for i in proto.function_inputs], + dependency_version_ids=[ + d.user_defined_function_version_id for d in proto.function_dependencies + ], + metadata=metadata_proto_to_dict(proto.metadata), # type: ignore + 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, + output_type=( + FunctionDataType(proto.function_output_type) if proto.function_output_type else None + ), + archived_date=( + proto.archived_date.ToDatetime(tzinfo=timezone.utc) + if proto.HasField("archived_date") + else None + ), + _client=sift_client, + ) + + @property + def versions(self) -> list[UserDefinedFunction]: + """Return every version of this function, newest first.""" + return self.client.user_defined_functions.versions.list_(function=self._id_or_error) + + def update(self, update: UserDefinedFunctionUpdate | dict) -> UserDefinedFunction: + """Update the function. This creates a new version. + + Args: + update: The update to apply. See UserDefinedFunctionUpdate for updatable fields. + + Returns: + The updated function. + """ + updated = self.client.user_defined_functions.update(function=self, update=update) + self._update(updated) + return self + + def archive(self) -> UserDefinedFunction: + """Archive the function.""" + updated = self.client.user_defined_functions.archive(function=self) + self._update(updated) + return self + + def unarchive(self) -> UserDefinedFunction: + """Unarchive the function.""" + updated = self.client.user_defined_functions.unarchive(function=self) + self._update(updated) + return self + + +class UserDefinedFunctionBase(ModelCreateUpdateBase): + """Base class for UserDefinedFunction create and update models.""" + + description: str | None = None + function_inputs: list[FunctionInput] | None = None + metadata: dict[str, str | float | bool] | None = None + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "metadata": MappingHelper( + proto_attr_path="metadata", + update_field="metadata", + converter=metadata_dict_to_proto, + ), + "function_inputs": MappingHelper( + proto_attr_path="function_inputs", + update_field="function_inputs", + converter=_function_input_to_proto, # type: ignore[arg-type] + ), + } + + +class UserDefinedFunctionCreate( + UserDefinedFunctionBase, ModelCreate[CreateUserDefinedFunctionRequestProto] +): + """Create model for UserDefinedFunction.""" + + name: str + expression: str + user_notes: str | None = None + + def _get_proto_class(self) -> type[CreateUserDefinedFunctionRequestProto]: + return CreateUserDefinedFunctionRequestProto + + +class UserDefinedFunctionUpdate(UserDefinedFunctionBase, ModelUpdate[UserDefinedFunctionProto]): + """Update model for UserDefinedFunction. + + Once a function has dependents the server refuses to change `name`, + `function_inputs`, or the output type. Check `dependents` first. + """ + + name: str | None = None + expression: str | None = None + is_archived: bool | None = None + + def _get_proto_class(self) -> type[UserDefinedFunctionProto]: + return UserDefinedFunctionProto + + def _add_resource_id_to_proto(self, proto_msg: UserDefinedFunctionProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.user_defined_function_id = self._resource_id + + +class UserDefinedFunctionValidation(BaseModel): + """The result of checking an expression before saving it. + + Attributes: + valid: Whether the expression compiles. + error: Why it failed, when it is not valid. + output_type: The type the expression returns, when it is valid. + """ + + valid: bool + error: str | None = None + output_type: FunctionDataType | None = None + + +class FunctionDependents(BaseModel): + """What would break if a function changed. + + Attributes: + function_ids: IDs of other functions that call it. + calculated_channel_ids: IDs of calculated channels that use it. + rule_ids: IDs of rules that use it. + """ + + function_ids: list[str] = [] + calculated_channel_ids: list[str] = [] + rule_ids: list[str] = [] + + @property + def any(self) -> bool: + """Whether anything depends on the function.""" + return bool(self.function_ids or self.calculated_channel_ids or self.rule_ids) diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 80da283528..8155d4672c 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -18,6 +18,7 @@ RunsAPIAsync, TagsAPIAsync, TestResultsAPIAsync, + UserDefinedFunctionsAPIAsync, UsersAPIAsync, ) from sift_client.resources.access_control import AccessControlAPIAsync @@ -65,6 +66,9 @@ class AsyncAPIs(NamedTuple): test_results: TestResultsAPIAsync """Instance of the Test Results API for making asynchronous requests.""" + user_defined_functions: UserDefinedFunctionsAPIAsync + """Instance of the User Defined Functions API for making asynchronous requests.""" + users: UsersAPIAsync """Instance of the Users API for making asynchronous requests.""" From 2546dd6bb8d3ea0be987eab449bb6127e08aaca9 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 23 Sep 2026 22:35:48 -0400 Subject: [PATCH 2/8] python: resolve pr comments --- .../user_defined_functions.py | 46 +++-- .../resources/test_user_defined_functions.py | 10 +- .../sift_types/test_user_defined_function.py | 61 +++--- .../resources/sync_stubs/__init__.pyi | 106 ++++++----- .../resources/user_defined_functions.py | 145 +++++++------- python/lib/sift_client/sift_types/__init__.py | 6 +- .../sift_types/user_defined_function.py | 178 ++++++++++++------ 7 files changed, 331 insertions(+), 221 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py index 266e3e00b3..b9c539d476 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py @@ -25,14 +25,17 @@ ) from sift_client._internal.low_level_wrappers.base import DEFAULT_PAGE_SIZE, LowLevelClientBase +from sift_client.sift_types.calculated_channel import CalculatedChannel +from sift_client.sift_types.rule import Rule from sift_client.sift_types.user_defined_function import ( FunctionDataType, - FunctionDependents, FunctionInput, + FunctionUsage, UserDefinedFunction, UserDefinedFunctionCreate, UserDefinedFunctionUpdate, UserDefinedFunctionValidation, + UserDefinedFunctionVersion, _function_input_to_proto, ) from sift_client.transport import WithGrpcClient @@ -75,20 +78,20 @@ async def get_function(self, function_id: str) -> UserDefinedFunction: cast("GetUserDefinedFunctionResponse", response).user_defined_function ) - async def get_version(self, version_id: str) -> UserDefinedFunction: + async def get_version(self, version_id: str) -> UserDefinedFunctionVersion: """Get one version of a function. Args: version_id: The version ID to get. Returns: - The UserDefinedFunction at that version. + The UserDefinedFunctionVersion. """ request = GetUserDefinedFunctionVersionRequest(user_defined_function_version_id=version_id) response = await self._grpc_client.get_stub( UserDefinedFunctionServiceStub ).GetUserDefinedFunctionVersion(request) - return UserDefinedFunction._from_proto( + return UserDefinedFunctionVersion._from_proto( cast("GetUserDefinedFunctionVersionResponse", response).user_defined_function ) @@ -166,7 +169,7 @@ async def list_versions( page_token: str | None = None, query_filter: str | None = None, order_by: str | None = None, - ) -> tuple[list[UserDefinedFunction], str]: + ) -> tuple[list[UserDefinedFunctionVersion], str]: """List one page of versions for a function. Args: @@ -200,7 +203,9 @@ async def list_versions( ).ListUserDefinedFunctionVersions(request) response = cast("ListUserDefinedFunctionVersionsResponse", response) - versions = [UserDefinedFunction._from_proto(f) for f in response.user_defined_functions] + versions = [ + UserDefinedFunctionVersion._from_proto(f) for f in response.user_defined_functions + ] return versions, response.next_page_token async def list_all_versions( @@ -212,7 +217,7 @@ async def list_all_versions( order_by: str | None = None, page_size: int | None = DEFAULT_PAGE_SIZE, max_results: int | None = None, - ) -> list[UserDefinedFunction]: + ) -> list[UserDefinedFunctionVersion]: """List every matching version. Args: @@ -251,16 +256,21 @@ async def create_function(self, *, create: UserDefinedFunctionCreate) -> UserDef cast("CreateUserDefinedFunctionResponse", response).user_defined_function ) - async def update_function(self, update: UserDefinedFunctionUpdate) -> UserDefinedFunction: + async def update_function( + self, update: UserDefinedFunctionUpdate, change_notes: str | None = None + ) -> UserDefinedFunction: """Update select fields of a function. Args: update: The updates to apply. Its `resource_id` must be set. + change_notes: The note to attach to the new version. Returns: The updated UserDefinedFunction. """ grpc_function, update_mask = update.to_proto_with_mask() + if change_notes is not None: + grpc_function.user_notes = change_notes request = UpdateUserDefinedFunctionRequest( user_defined_function=grpc_function, update_mask=update_mask ) @@ -272,7 +282,7 @@ async def update_function(self, update: UserDefinedFunctionUpdate) -> UserDefine ) async def validate_function( - self, *, expression: str, function_inputs: list[FunctionInput] | None = None + self, *, expression: str, function_inputs: list[FunctionInput] ) -> UserDefinedFunctionValidation: """Check an expression without saving it. @@ -295,10 +305,10 @@ async def validate_function( response = cast("ValidateUserDefinedFunctionResponse", response) if response.WhichOneof("result") == "error": - return UserDefinedFunctionValidation(valid=False, error=response.error.error_message) + return UserDefinedFunctionValidation(is_valid=False, error=response.error.error_message) output_type = response.success.user_defined_function.function_output_type return UserDefinedFunctionValidation( - valid=True, + is_valid=True, output_type=FunctionDataType(output_type) if output_type else None, ) @@ -308,7 +318,7 @@ async def get_dependents( function_id: str | None = None, name: str | None = None, version_id: str | None = None, - ) -> FunctionDependents: + ) -> FunctionUsage: """Get what depends on a function. Exactly one identifier must be provided. @@ -319,7 +329,7 @@ async def get_dependents( version_id: A specific version ID. Returns: - The IDs of dependent functions, calculated channels, and rules. + The functions, calculated channels, and rules that use it. Raises: ValueError: If not exactly one identifier is provided. @@ -342,8 +352,10 @@ async def get_dependents( ).GetUserDefinedFunctionDependents(request) response = cast("GetUserDefinedFunctionDependentsResponse", response) - return FunctionDependents( - function_ids=[f.user_defined_function_id for f in response.user_defined_functions], - calculated_channel_ids=[c.calculated_channel_id for c in response.calculated_channels], - rule_ids=[r.rule_id for r in response.rules], + return FunctionUsage( + functions=[UserDefinedFunction._from_proto(f) for f in response.user_defined_functions], + calculated_channels=[ + CalculatedChannel._from_proto(c) for c in response.calculated_channels + ], + rules=[Rule._from_proto(r) for r in response.rules], ) diff --git a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py index b288630f77..20aa5b2dba 100644 --- a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py +++ b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py @@ -180,7 +180,7 @@ def test_instance_methods(self, sift_client, test_timestamp_str): def test_validate_accepts_a_good_expression(self, sift_client): """Test validating an expression that compiles.""" - result = sift_client.user_defined_functions.validate( + result = sift_client.user_defined_functions.validate_expression( "$1 * 2", [FunctionInput(identifier="$1")] ) @@ -189,7 +189,7 @@ def test_validate_accepts_a_good_expression(self, sift_client): def test_validate_rejects_a_bad_expression(self, sift_client): """Test validating an expression that does not compile.""" - result = sift_client.user_defined_functions.validate( + result = sift_client.user_defined_functions.validate_expression( "$1 ** ** 2", [FunctionInput(identifier="$1")] ) @@ -198,7 +198,7 @@ def test_validate_rejects_a_bad_expression(self, sift_client): def test_dependents_of_an_unused_function(self, sift_client, new_function): """Test that a function nothing uses reports no dependents.""" - dependents = sift_client.user_defined_functions.dependents(new_function) + dependents = sift_client.user_defined_functions.get_where_used(new_function) assert dependents.any is False @@ -215,7 +215,9 @@ class TestUserDefinedFunctionVersions: def test_list_versions(self, sift_client, new_function): """Test listing a function's versions.""" - versions = sift_client.user_defined_functions.versions.list_(function=new_function) + versions = sift_client.user_defined_functions.versions.list_( + user_defined_function=new_function + ) assert versions assert all(v.id_ == new_function.id_ for v in versions) diff --git a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py index 8d36561ff0..1b93f0fa0a 100644 --- a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py +++ b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py @@ -8,10 +8,11 @@ from sift_client.sift_types import UserDefinedFunction from sift_client.sift_types.user_defined_function import ( FunctionDataType, - FunctionDependents, FunctionInput, + FunctionUsage, UserDefinedFunctionCreate, UserDefinedFunctionUpdate, + UserDefinedFunctionVersion, ) NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) @@ -24,12 +25,12 @@ def test_defaults(self): given = FunctionInput(identifier="$1") assert given.data_type is FunctionDataType.NUMERIC - assert given.constant is False + assert given.scalar is False def test_round_trip(self): from sift.common.type.v1.user_defined_functions_pb2 import FunctionInput as Proto - original = FunctionInput(identifier="$1", data_type=FunctionDataType.STRING, constant=True) + original = FunctionInput(identifier="$1", data_type=FunctionDataType.STRING, scalar=True) proto = Proto(identifier="$1", data_type=FunctionDataType.STRING.value, constant=True) assert FunctionInput._from_proto(proto) == original @@ -39,7 +40,9 @@ class TestUserDefinedFunctionCreate: """Unit tests for UserDefinedFunctionCreate - tests _to_proto_helpers.""" def test_minimal_create(self): - proto = UserDefinedFunctionCreate(name="double", expression="$1 * 2").to_proto() + proto = UserDefinedFunctionCreate( + name="double", expression="$1 * 2", function_inputs=[FunctionInput(identifier="$1")] + ).to_proto() assert proto.name == "double" assert proto.expression == "$1 * 2" @@ -50,7 +53,7 @@ def test_function_inputs_converter(self): expression="$1 * 2", function_inputs=[ FunctionInput(identifier="$1"), - FunctionInput(identifier="$2", data_type=FunctionDataType.STRING, constant=True), + FunctionInput(identifier="$2", data_type=FunctionDataType.STRING, scalar=True), ], ).to_proto() @@ -62,7 +65,10 @@ def test_function_inputs_converter(self): def test_metadata_converter(self): proto = UserDefinedFunctionCreate( - name="double", expression="$1 * 2", metadata={"owner": "ops", "n": 2.0} + name="double", + expression="$1 * 2", + function_inputs=[FunctionInput(identifier="$1")], + metadata={"owner": "ops", "n": 2.0}, ).to_proto() by_key = {m.key.name: m for m in proto.metadata} @@ -97,38 +103,47 @@ def test_requires_resource_id(self): UserDefinedFunctionUpdate(expression="$1").to_proto_with_mask() -class TestFunctionDependents: - """Unit tests for FunctionDependents.""" +class TestFunctionUsage: + """Unit tests for FunctionUsage.""" def test_any_is_false_when_empty(self): - assert FunctionDependents().any is False + assert FunctionUsage().any is False def test_any_is_true_with_one_rule(self): - assert FunctionDependents(rule_ids=["r-1"]).any is True + usage = FunctionUsage.model_construct(rules=[object()]) + + assert usage.any is True @pytest.fixture def mock_function(mock_client): """Create a mock UserDefinedFunction instance for testing.""" - function = UserDefinedFunction( + latest = UserDefinedFunctionVersion( proto=MagicMock(), - id_="fn-1", - name="double", - description="doubles the input", - expression="$1 * 2", + id_="ver-1", + user_defined_function_id="fn-1", version=1, - version_id="ver-1", + expression="$1 * 2", change_message="created", - user_notes="", + change_notes="", function_inputs=[FunctionInput(identifier="$1")], dependency_version_ids=[], + created_date=NOW, + created_by_user_id="user1", + output_type=FunctionDataType.NUMERIC, + ) + function = UserDefinedFunction( + proto=MagicMock(), + id_="fn-1", + name="double", + description="doubles the input", metadata={}, created_date=NOW, modified_date=NOW, created_by_user_id="user1", modified_by_user_id="user1", is_archived=False, - output_type=FunctionDataType.NUMERIC, + latest_version=latest, archived_date=None, ) function._apply_client_to_instance(mock_client) @@ -143,7 +158,9 @@ def test_versions_property_calls_client(self, mock_function, mock_client): _ = mock_function.versions - mock_client.user_defined_functions.versions.list_.assert_called_once_with(function="fn-1") + mock_client.user_defined_functions.versions.list_.assert_called_once_with( + user_defined_function=mock_function + ) def test_update_calls_client_and_updates_self(self, mock_function, mock_client): updated = MagicMock() @@ -156,7 +173,7 @@ def test_update_calls_client_and_updates_self(self, mock_function, mock_client): result = mock_function.update(update) mock_client.user_defined_functions.update.assert_called_once_with( - function=mock_function, update=update + user_defined_function=mock_function, update=update ) mock_update.assert_called_once_with(updated) assert result is mock_function @@ -169,7 +186,7 @@ def test_archive_calls_client(self, mock_function, mock_client): result = mock_function.archive() mock_client.user_defined_functions.archive.assert_called_once_with( - function=mock_function + user_defined_function=mock_function ) assert result is mock_function @@ -181,6 +198,6 @@ def test_unarchive_calls_client(self, mock_function, mock_client): result = mock_function.unarchive() mock_client.user_defined_functions.unarchive.assert_called_once_with( - function=mock_function + user_defined_function=mock_function ) assert result is mock_function diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 7dd1f55f5d..06eb84bfbe 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -86,12 +86,13 @@ if TYPE_CHECKING: ) from sift_client.sift_types.user import User from sift_client.sift_types.user_defined_function import ( - FunctionDependents, FunctionInput, + FunctionUsage, UserDefinedFunction, UserDefinedFunctionCreate, UserDefinedFunctionUpdate, UserDefinedFunctionValidation, + UserDefinedFunctionVersion, ) class AssetsAPI: @@ -3485,21 +3486,21 @@ class UserDefinedFunctionVersionsAPI: ... def _run(self, coro): ... - def get(self, version_id: str) -> UserDefinedFunction: + def get(self, *, version: str | UserDefinedFunctionVersion) -> UserDefinedFunctionVersion: """Get one version. Args: - version_id: The ID of the version. + version: The UserDefinedFunctionVersion or version ID. Returns: - The function at that version. + The UserDefinedFunctionVersion. """ ... def list_( self, *, - function: str | UserDefinedFunction | None = None, + user_defined_function: str | UserDefinedFunction | None = None, name: str | None = None, version: int | None = None, include_archived: bool = False, @@ -3507,12 +3508,12 @@ class UserDefinedFunctionVersionsAPI: order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[UserDefinedFunction]: + ) -> list[UserDefinedFunctionVersion]: """List a function's versions. Args: - function: The UserDefinedFunction or function ID whose versions to list. - name: The function name, as an alternative to `function`. + 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. @@ -3521,7 +3522,7 @@ class UserDefinedFunctionVersionsAPI: page_size: Number of results to fetch per request. Returns: - A list of UserDefinedFunction objects, one per version. + A list of UserDefinedFunctionVersion objects, newest first. """ ... @@ -3543,11 +3544,11 @@ class UserDefinedFunctionsAPI: ... def _run(self, coro): ... - def archive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + def archive(self, user_defined_function: str | UserDefinedFunction) -> UserDefinedFunction: """Archive a function. Args: - function: The UserDefinedFunction or function ID to archive. + user_defined_function: The UserDefinedFunction or function ID to archive. Returns: The archived UserDefinedFunction. @@ -3565,23 +3566,6 @@ class UserDefinedFunctionsAPI: """ ... - def dependents( - self, function: str | UserDefinedFunction, *, version_id: str | None = None - ) -> FunctionDependents: - """Get what depends on a function. - - Check this before changing inputs or the output type. The server refuses those - changes once a function has dependents. - - Args: - function: The UserDefinedFunction or function ID. - version_id: A specific version, instead of the function as a whole. - - Returns: - The IDs of dependent functions, calculated channels, and rules. - """ - ... - def find(self, **kwargs) -> UserDefinedFunction | None: """Find one function. Takes the same arguments as `list_`. @@ -3595,17 +3579,41 @@ class UserDefinedFunctionsAPI: """ ... - def get(self, function_id: str) -> UserDefinedFunction: + def get(self, *, user_defined_function_id: str) -> UserDefinedFunction: """Get a UserDefinedFunction. Args: - function_id: The ID of the function. + 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. + + 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, *, @@ -3637,25 +3645,11 @@ class UserDefinedFunctionsAPI: """ ... - def sync(self, functions: list[UserDefinedFunctionCreate | dict]) -> list[UserDefinedFunction]: - """Create or update each function so Sift matches the definitions given. - - Functions match by name. A new name is created. An existing one is updated, - which produces a new version. Nothing is archived. - - Args: - functions: The function definitions to apply. - - Returns: - The created or updated functions, in the order given. - """ - ... - - def unarchive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + def unarchive(self, user_defined_function: str | UserDefinedFunction) -> UserDefinedFunction: """Unarchive a function. Args: - function: The UserDefinedFunction or function ID to unarchive. + user_defined_function: The UserDefinedFunction or function ID to unarchive. Returns: The unarchived UserDefinedFunction. @@ -3663,27 +3657,35 @@ class UserDefinedFunctionsAPI: ... def update( - self, function: str | UserDefinedFunction, update: UserDefinedFunctionUpdate | dict + self, + user_defined_function: str | UserDefinedFunction, + update: UserDefinedFunctionUpdate | dict, + change_notes: str | None = None, ) -> UserDefinedFunction: - """Update a function. This creates a new version. + """Update a function. + + Changes to the expression, inputs, description, or metadata create a new version. Args: - function: The UserDefinedFunction or function ID to update. + 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( - self, expression: str, function_inputs: list[FunctionInput] | None = None + 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. + 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. diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py index 09a48af35e..20bad6e226 100644 --- a/python/lib/sift_client/resources/user_defined_functions.py +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -7,12 +7,13 @@ ) from sift_client.resources._base import ResourceBase from sift_client.sift_types.user_defined_function import ( - FunctionDependents, FunctionInput, + FunctionUsage, UserDefinedFunction, UserDefinedFunctionCreate, UserDefinedFunctionUpdate, UserDefinedFunctionValidation, + UserDefinedFunctionVersion, ) from sift_client.util import cel_utils as cel @@ -40,22 +41,25 @@ def __init__(self, sift_client: SiftClient): grpc_client=self.client.grpc_client ) - async def get(self, version_id: str) -> UserDefinedFunction: + async def get(self, *, version: str | UserDefinedFunctionVersion) -> UserDefinedFunctionVersion: """Get one version. Args: - version_id: The ID of the version. + version: The UserDefinedFunctionVersion or version ID. Returns: - The function at that version. + The UserDefinedFunctionVersion. """ + version_id = ( + version._id_or_error if isinstance(version, UserDefinedFunctionVersion) else version + ) version = await self._low_level_client.get_version(version_id=version_id) return self._apply_client_to_instance(version) async def list_( self, *, - function: str | UserDefinedFunction | None = None, + user_defined_function: str | UserDefinedFunction | None = None, name: str | None = None, # version specific version: int | None = None, @@ -65,12 +69,12 @@ async def list_( order_by: str | None = None, limit: int | None = None, page_size: int | None = None, - ) -> list[UserDefinedFunction]: + ) -> list[UserDefinedFunctionVersion]: """List a function's versions. Args: - function: The UserDefinedFunction or function ID whose versions to list. - name: The function name, as an alternative to `function`. + 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. @@ -79,7 +83,7 @@ async def list_( page_size: Number of results to fetch per request. Returns: - A list of UserDefinedFunction objects, one per version. + A list of UserDefinedFunctionVersion objects, newest first. """ filter_parts = self._build_common_cel_filters( include_archived=include_archived, filter_query=filter_query @@ -90,7 +94,9 @@ async def list_( versions = await self._low_level_client.list_all_versions( function_id=( - function._id_or_error if isinstance(function, UserDefinedFunction) else function + user_defined_function._id_or_error + if isinstance(user_defined_function, UserDefinedFunction) + else user_defined_function ), name=name, query_filter=query_filter or None, @@ -120,16 +126,16 @@ def __init__(self, sift_client: SiftClient): ) self.versions = UserDefinedFunctionVersionsAPIAsync(sift_client) - async def get(self, function_id: str) -> UserDefinedFunction: + async def get(self, *, user_defined_function_id: str) -> UserDefinedFunction: """Get a UserDefinedFunction. Args: - function_id: The ID of the function. + user_defined_function_id: The ID of the function. Returns: The UserDefinedFunction. """ - function = await self._low_level_client.get_function(function_id=function_id) + function = await self._low_level_client.get_function(function_id=user_defined_function_id) return self._apply_client_to_instance(function) async def list_( @@ -217,66 +223,45 @@ async def create(self, create: UserDefinedFunctionCreate | dict) -> UserDefinedF async def update( self, - function: str | UserDefinedFunction, + user_defined_function: str | UserDefinedFunction, update: UserDefinedFunctionUpdate | dict, + change_notes: str | None = None, ) -> UserDefinedFunction: - """Update a function. This creates a new version. + """Update a function. + + Changes to the expression, inputs, description, or metadata create a new version. Args: - function: The UserDefinedFunction or function ID to update. + 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. """ - function_id = ( - function._id_or_error if isinstance(function, UserDefinedFunction) else function + current = ( + user_defined_function + if isinstance(user_defined_function, UserDefinedFunction) + else await self.get(user_defined_function_id=user_defined_function) ) if isinstance(update, dict): update = UserDefinedFunctionUpdate.model_validate(update) - update.resource_id = function_id - updated = await self._low_level_client.update_function(update) + update.resource_id = current._id_or_error + updated = await self._low_level_client.update_function( + update, change_notes=change_notes or current.latest_version.change_notes + ) return self._apply_client_to_instance(updated) - async def sync( - self, functions: list[UserDefinedFunctionCreate | dict] - ) -> list[UserDefinedFunction]: - """Create or update each function so Sift matches the definitions given. - - Functions match by name. A new name is created. An existing one is updated, - which produces a new version. Nothing is archived. - - Args: - functions: The function definitions to apply. - - Returns: - The created or updated functions, in the order given. - """ - definitions = [ - UserDefinedFunctionCreate.model_validate(f) if isinstance(f, dict) else f - for f in functions - ] - wanted = {d.name for d in definitions} - existing = {f.name: f for f in await self.list_(include_archived=True) if f.name in wanted} - - results = [] - for definition in definitions: - current = existing.get(definition.name) - if current is None: - results.append(await self.create(definition)) - continue - changes = definition.model_dump(exclude_unset=True, exclude={"name"}) - results.append(await self.update(current, changes)) - return results - - async def validate( - self, expression: str, function_inputs: list[FunctionInput] | None = None + async 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. + 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. @@ -285,46 +270,66 @@ async def validate( expression=expression, function_inputs=function_inputs ) - async def dependents( - self, function: str | UserDefinedFunction, *, version_id: str | None = None - ) -> FunctionDependents: - """Get what depends on a function. + async 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 has dependents. + changes once a function is in use. Args: - function: The UserDefinedFunction or function ID. - version_id: A specific version, instead of the function as a whole. + user_defined_function: The UserDefinedFunction or function ID. + version: A specific UserDefinedFunctionVersion or version ID, instead of the + function as a whole. Returns: - The IDs of dependent functions, calculated channels, and rules. + The functions, calculated channels, and rules that use it. + + Raises: + ValueError: If neither or both are provided. """ - if version_id is not None: + if (user_defined_function is None) == (version is None): + raise ValueError("Exactly one of user_defined_function or version must be provided") + if version is not None: + version_id = ( + version._id_or_error if isinstance(version, UserDefinedFunctionVersion) else version + ) return await self._low_level_client.get_dependents(version_id=version_id) function_id = ( - function._id_or_error if isinstance(function, UserDefinedFunction) else function + user_defined_function._id_or_error + if isinstance(user_defined_function, UserDefinedFunction) + else user_defined_function ) return await self._low_level_client.get_dependents(function_id=function_id) - async def archive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + async def archive( + self, user_defined_function: str | UserDefinedFunction + ) -> UserDefinedFunction: """Archive a function. Args: - function: The UserDefinedFunction or function ID to archive. + user_defined_function: The UserDefinedFunction or function ID to archive. Returns: The archived UserDefinedFunction. """ - return await self.update(function, UserDefinedFunctionUpdate(is_archived=True)) + return await self.update(user_defined_function, UserDefinedFunctionUpdate(is_archived=True)) - async def unarchive(self, function: str | UserDefinedFunction) -> UserDefinedFunction: + async def unarchive( + self, user_defined_function: str | UserDefinedFunction + ) -> UserDefinedFunction: """Unarchive a function. Args: - function: The UserDefinedFunction or function ID to unarchive. + user_defined_function: The UserDefinedFunction or function ID to unarchive. Returns: The unarchived UserDefinedFunction. """ - return await self.update(function, UserDefinedFunctionUpdate(is_archived=False)) + return await self.update( + user_defined_function, UserDefinedFunctionUpdate(is_archived=False) + ) diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index 4bdf442150..1ba85bc60f 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -219,12 +219,13 @@ from sift_client.sift_types.user import User, UserOrganization from sift_client.sift_types.user_defined_function import ( FunctionDataType, - FunctionDependents, FunctionInput, + FunctionUsage, UserDefinedFunction, UserDefinedFunctionCreate, UserDefinedFunctionUpdate, UserDefinedFunctionValidation, + UserDefinedFunctionVersion, ) if "pytest" in sys.modules: @@ -263,8 +264,8 @@ "Flow", "FlowConfig", "FunctionDataType", - "FunctionDependents", "FunctionInput", + "FunctionUsage", "IngestionConfig", "IngestionConfigCreate", "Job", @@ -326,5 +327,6 @@ "UserDefinedFunctionCreate", "UserDefinedFunctionUpdate", "UserDefinedFunctionValidation", + "UserDefinedFunctionVersion", "UserOrganization", ] diff --git a/python/lib/sift_client/sift_types/user_defined_function.py b/python/lib/sift_client/sift_types/user_defined_function.py index afbf055f89..8d7c4f424e 100644 --- a/python/lib/sift_client/sift_types/user_defined_function.py +++ b/python/lib/sift_client/sift_types/user_defined_function.py @@ -25,10 +25,13 @@ ModelCreateUpdateBase, ModelUpdate, ) +from sift_client.sift_types.calculated_channel import CalculatedChannel # noqa: TC001 +from sift_client.sift_types.rule import Rule # noqa: TC001 from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: from sift_client.client import SiftClient + from sift_client.sift_types.user import User class FunctionDataType(Enum): @@ -46,19 +49,19 @@ class FunctionInput(BaseModel): Attributes: identifier: How the expression refers to this input, such as `$1`. data_type: The type the input accepts. - constant: Whether the input is a literal rather than a channel. + scalar: Whether the input is a literal value rather than a channel. """ identifier: str data_type: FunctionDataType = FunctionDataType.NUMERIC - constant: bool = False + scalar: bool = False @classmethod def _from_proto(cls, proto: FunctionInputProto) -> FunctionInput: return cls( identifier=proto.identifier, data_type=FunctionDataType(proto.data_type), - constant=proto.constant, + scalar=proto.constant, ) @@ -68,36 +71,86 @@ def _function_input_to_proto(**kwargs) -> FunctionInputProto: return FunctionInputProto( identifier=kwargs["identifier"], data_type=data_type.value if isinstance(data_type, FunctionDataType) else data_type, - constant=kwargs.get("constant", False), + constant=kwargs.get("scalar", False), ) +class UserDefinedFunctionVersion(BaseType[UserDefinedFunctionProto, "UserDefinedFunctionVersion"]): + """One saved revision of a function. `id_` is the version ID.""" + + user_defined_function_id: str + version: int + expression: str + change_message: str + change_notes: str + function_inputs: list[FunctionInput] + dependency_version_ids: list[str] + created_date: datetime + created_by_user_id: str + output_type: FunctionDataType | None + + @classmethod + def _from_proto( + cls, proto: UserDefinedFunctionProto, sift_client: SiftClient | None = None + ) -> UserDefinedFunctionVersion: + return cls( + proto=proto, + id_=proto.user_defined_function_version_id, + user_defined_function_id=proto.user_defined_function_id, + version=proto.version, + expression=proto.expression, + change_message=proto.change_message, + change_notes=proto.user_notes, + function_inputs=[FunctionInput._from_proto(i) for i in proto.function_inputs], + dependency_version_ids=[ + d.user_defined_function_version_id for d in proto.function_dependencies + ], + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + created_by_user_id=proto.created_by_user_id, + output_type=( + FunctionDataType(proto.function_output_type) if proto.function_output_type else None + ), + _client=sift_client, + ) + + @property + def function(self) -> UserDefinedFunction: + """Fetch the function this version belongs to.""" + return self.client.user_defined_functions.get( + user_defined_function_id=self.user_defined_function_id + ) + + @property + def created_by(self) -> User: + """Fetch the User that created this version.""" + return self.client.users.get(user_id=self.created_by_user_id) + + @property + def dependencies(self) -> list[UserDefinedFunctionVersion]: + """Fetch the function versions this one calls.""" + return [ + self.client.user_defined_functions.versions.get(version=v) + for v in self.dependency_version_ids + ] + + class UserDefinedFunction(BaseType[UserDefinedFunctionProto, "UserDefinedFunction"]): """A reusable expression that calculated channels and rules can call. - Each save produces a new version. `id_` identifies the function; `version_id` - identifies this particular version. + Version agnostic. The expression, inputs, and output type live on + `latest_version`; each save produces a new one. """ - # Required fields name: str description: str - expression: str - version: int - version_id: str - change_message: str - user_notes: str - function_inputs: list[FunctionInput] - dependency_version_ids: list[str] metadata: dict[str, str | float | bool] created_date: datetime modified_date: datetime created_by_user_id: str modified_by_user_id: str is_archived: bool + latest_version: UserDefinedFunctionVersion - # Optional fields - output_type: FunctionDataType | None archived_date: datetime | None @classmethod @@ -109,24 +162,13 @@ def _from_proto( id_=proto.user_defined_function_id, name=proto.name, description=proto.description, - expression=proto.expression, - version=proto.version, - version_id=proto.user_defined_function_version_id, - change_message=proto.change_message, - user_notes=proto.user_notes, - function_inputs=[FunctionInput._from_proto(i) for i in proto.function_inputs], - dependency_version_ids=[ - d.user_defined_function_version_id for d in proto.function_dependencies - ], metadata=metadata_proto_to_dict(proto.metadata), # type: ignore 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, - output_type=( - FunctionDataType(proto.function_output_type) if proto.function_output_type else None - ), + latest_version=UserDefinedFunctionVersion._from_proto(proto, sift_client), archived_date=( proto.archived_date.ToDatetime(tzinfo=timezone.utc) if proto.HasField("archived_date") @@ -136,9 +178,19 @@ def _from_proto( ) @property - def versions(self) -> list[UserDefinedFunction]: - """Return every version of this function, newest first.""" - return self.client.user_defined_functions.versions.list_(function=self._id_or_error) + def versions(self) -> list[UserDefinedFunctionVersion]: + """Fetch every version of this function, newest first.""" + return self.client.user_defined_functions.versions.list_(user_defined_function=self) + + @property + def created_by(self) -> User: + """Fetch the User that created this function.""" + 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 function.""" + return self.client.users.get(user_id=self.modified_by_user_id) def update(self, update: UserDefinedFunctionUpdate | dict) -> UserDefinedFunction: """Update the function. This creates a new version. @@ -149,19 +201,21 @@ def update(self, update: UserDefinedFunctionUpdate | dict) -> UserDefinedFunctio Returns: The updated function. """ - updated = self.client.user_defined_functions.update(function=self, update=update) + updated = self.client.user_defined_functions.update( + user_defined_function=self, update=update + ) self._update(updated) return self def archive(self) -> UserDefinedFunction: """Archive the function.""" - updated = self.client.user_defined_functions.archive(function=self) + updated = self.client.user_defined_functions.archive(user_defined_function=self) self._update(updated) return self def unarchive(self) -> UserDefinedFunction: """Unarchive the function.""" - updated = self.client.user_defined_functions.unarchive(function=self) + updated = self.client.user_defined_functions.unarchive(user_defined_function=self) self._update(updated) return self @@ -170,7 +224,6 @@ class UserDefinedFunctionBase(ModelCreateUpdateBase): """Base class for UserDefinedFunction create and update models.""" description: str | None = None - function_inputs: list[FunctionInput] | None = None metadata: dict[str, str | float | bool] | None = None _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { @@ -179,11 +232,6 @@ class UserDefinedFunctionBase(ModelCreateUpdateBase): update_field="metadata", converter=metadata_dict_to_proto, ), - "function_inputs": MappingHelper( - proto_attr_path="function_inputs", - update_field="function_inputs", - converter=_function_input_to_proto, # type: ignore[arg-type] - ), } @@ -194,7 +242,19 @@ class UserDefinedFunctionCreate( name: str expression: str - user_notes: str | None = None + """The CEL expression, referring to inputs positionally as `$1`, `$2`.""" + function_inputs: list[FunctionInput] + change_notes: str | None = None + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + **UserDefinedFunctionBase._to_proto_helpers, + "function_inputs": MappingHelper( + proto_attr_path="function_inputs", + update_field="function_inputs", + converter=_function_input_to_proto, # type: ignore[arg-type] + ), + "change_notes": MappingHelper(proto_attr_path="user_notes", update_field="user_notes"), + } def _get_proto_class(self) -> type[CreateUserDefinedFunctionRequestProto]: return CreateUserDefinedFunctionRequestProto @@ -204,13 +264,23 @@ class UserDefinedFunctionUpdate(UserDefinedFunctionBase, ModelUpdate[UserDefined """Update model for UserDefinedFunction. Once a function has dependents the server refuses to change `name`, - `function_inputs`, or the output type. Check `dependents` first. + `function_inputs`, or the output type. Check `get_where_used` first. """ name: str | None = None expression: str | None = None + function_inputs: list[FunctionInput] | None = None is_archived: bool | None = None + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + **UserDefinedFunctionBase._to_proto_helpers, + "function_inputs": MappingHelper( + proto_attr_path="function_inputs", + update_field="function_inputs", + converter=_function_input_to_proto, # type: ignore[arg-type] + ), + } + def _get_proto_class(self) -> type[UserDefinedFunctionProto]: return UserDefinedFunctionProto @@ -224,30 +294,30 @@ class UserDefinedFunctionValidation(BaseModel): """The result of checking an expression before saving it. Attributes: - valid: Whether the expression compiles. + is_valid: Whether the expression compiles. error: Why it failed, when it is not valid. output_type: The type the expression returns, when it is valid. """ - valid: bool + is_valid: bool error: str | None = None output_type: FunctionDataType | None = None -class FunctionDependents(BaseModel): - """What would break if a function changed. +class FunctionUsage(BaseModel): + """Where a function is used. Attributes: - function_ids: IDs of other functions that call it. - calculated_channel_ids: IDs of calculated channels that use it. - rule_ids: IDs of rules that use it. + functions: Other functions that call it. + calculated_channels: Calculated channels that use it. + rules: Rules that use it. """ - function_ids: list[str] = [] - calculated_channel_ids: list[str] = [] - rule_ids: list[str] = [] + functions: list[UserDefinedFunction] = [] + calculated_channels: list[CalculatedChannel] = [] + rules: list[Rule] = [] @property def any(self) -> bool: - """Whether anything depends on the function.""" - return bool(self.function_ids or self.calculated_channel_ids or self.rule_ids) + """Whether anything uses the function.""" + return bool(self.functions or self.calculated_channels or self.rules) From 8d856bb47ec271a23371b57a08c5525ff0978b4f Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 17:37:35 -0400 Subject: [PATCH 3/8] python: pr comments --- .../user_defined_functions.py | 22 +++++++++++++++++++ .../resources/test_user_defined_functions.py | 2 +- .../sift_types/test_user_defined_function.py | 4 ++-- .../resources/sync_stubs/__init__.pyi | 16 +++++++++++++- .../resources/user_defined_functions.py | 20 +++++++++++++++-- .../sift_types/user_defined_function.py | 11 +++++----- 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py index b9c539d476..fc28a84b46 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py @@ -11,6 +11,8 @@ GetUserDefinedFunctionResponse, GetUserDefinedFunctionVersionRequest, GetUserDefinedFunctionVersionResponse, + GetUserDefinedFunctionVersionsRequest, + GetUserDefinedFunctionVersionsResponse, ListUserDefinedFunctionsRequest, ListUserDefinedFunctionsResponse, ListUserDefinedFunctionVersionsRequest, @@ -95,6 +97,26 @@ async def get_version(self, version_id: str) -> UserDefinedFunctionVersion: cast("GetUserDefinedFunctionVersionResponse", response).user_defined_function ) + async def get_versions(self, version_ids: list[str]) -> list[UserDefinedFunctionVersion]: + """Get many versions in one call. + + Args: + version_ids: The version IDs to get. + + Returns: + The UserDefinedFunctionVersions. + """ + if not version_ids: + return [] + request = GetUserDefinedFunctionVersionsRequest( + user_defined_function_version_ids=version_ids + ) + response = await self._grpc_client.get_stub( + UserDefinedFunctionServiceStub + ).GetUserDefinedFunctionVersions(request) + response = cast("GetUserDefinedFunctionVersionsResponse", response) + return [UserDefinedFunctionVersion._from_proto(f) for f in response.user_defined_functions] + async def list_functions( self, *, diff --git a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py index 20aa5b2dba..02d412fb40 100644 --- a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py +++ b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py @@ -200,7 +200,7 @@ def test_dependents_of_an_unused_function(self, sift_client, new_function): """Test that a function nothing uses reports no dependents.""" dependents = sift_client.user_defined_functions.get_where_used(new_function) - assert dependents.any is False + assert dependents.is_used is False @pytest.mark.asyncio async def test_async_list(self, functions_api_async, new_function): diff --git a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py index 1b93f0fa0a..ac238eb08f 100644 --- a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py +++ b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py @@ -107,12 +107,12 @@ class TestFunctionUsage: """Unit tests for FunctionUsage.""" def test_any_is_false_when_empty(self): - assert FunctionUsage().any is False + assert FunctionUsage().is_used is False def test_any_is_true_with_one_rule(self): usage = FunctionUsage.model_construct(rules=[object()]) - assert usage.any is True + assert usage.is_used is True @pytest.fixture diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 06eb84bfbe..42bfae679b 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -3486,6 +3486,19 @@ 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. + + Returns: + The UserDefinedFunctionVersions. + """ + ... + def get(self, *, version: str | UserDefinedFunctionVersion) -> UserDefinedFunctionVersion: """Get one version. @@ -3517,7 +3530,8 @@ class UserDefinedFunctionVersionsAPI: 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. + order_by: Field and direction to order results by. Newest first by default; + the service otherwise orders by name, which every version shares. limit: Maximum number of versions to return. If None, returns all matches. page_size: Number of results to fetch per request. diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py index 20bad6e226..35385e2878 100644 --- a/python/lib/sift_client/resources/user_defined_functions.py +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -56,6 +56,21 @@ async def get(self, *, version: str | UserDefinedFunctionVersion) -> UserDefined version = await self._low_level_client.get_version(version_id=version_id) return self._apply_client_to_instance(version) + async 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. + """ + ids = [v._id_or_error if isinstance(v, UserDefinedFunctionVersion) else v for v in versions] + found = await self._low_level_client.get_versions(version_ids=ids) + return self._apply_client_to_instances(found) + async def list_( self, *, @@ -78,7 +93,8 @@ async def list_( 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. + order_by: Field and direction to order results by. Newest first by default; + the service otherwise orders by name, which every version shares. limit: Maximum number of versions to return. If None, returns all matches. page_size: Number of results to fetch per request. @@ -100,7 +116,7 @@ async def list_( ), name=name, query_filter=query_filter or None, - order_by=order_by, + order_by=order_by or "version desc", max_results=limit, **({"page_size": page_size} if page_size is not None else {}), ) diff --git a/python/lib/sift_client/sift_types/user_defined_function.py b/python/lib/sift_client/sift_types/user_defined_function.py index 8d7c4f424e..2203eef337 100644 --- a/python/lib/sift_client/sift_types/user_defined_function.py +++ b/python/lib/sift_client/sift_types/user_defined_function.py @@ -128,10 +128,11 @@ def created_by(self) -> User: @property def dependencies(self) -> list[UserDefinedFunctionVersion]: """Fetch the function versions this one calls.""" - return [ - self.client.user_defined_functions.versions.get(version=v) - for v in self.dependency_version_ids - ] + if not self.dependency_version_ids: + return [] + return self.client.user_defined_functions.versions.batch_get( + versions=self.dependency_version_ids + ) class UserDefinedFunction(BaseType[UserDefinedFunctionProto, "UserDefinedFunction"]): @@ -318,6 +319,6 @@ class FunctionUsage(BaseModel): rules: list[Rule] = [] @property - def any(self) -> bool: + def is_used(self) -> bool: """Whether anything uses the function.""" return bool(self.functions or self.calculated_channels or self.rules) From 07dceb107d7219c60202e2b98d0ba27f64f74fb2 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 18:08:44 -0400 Subject: [PATCH 4/8] python: docstrings move getudfversion to low level client --- .../_tests/resources/test_user_defined_functions.py | 6 ++---- .../sift_client/resources/sync_stubs/__init__.pyi | 4 ++-- .../sift_client/resources/user_defined_functions.py | 12 ++++++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py index 02d412fb40..e1c6f900d9 100644 --- a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py +++ b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py @@ -98,9 +98,7 @@ def test_list_with_name_filter(self, sift_client, new_function): def test_list_with_id_filter(self, sift_client, new_function): """Test function listing filtered to specific IDs.""" - functions = sift_client.user_defined_functions.list_( - function_ids=[new_function._id_or_error] - ) + functions = sift_client.user_defined_functions.list_(user_defined_functions=[new_function]) assert [f.id_ for f in functions] == [new_function.id_] @@ -205,7 +203,7 @@ def test_dependents_of_an_unused_function(self, sift_client, new_function): @pytest.mark.asyncio async def test_async_list(self, functions_api_async, new_function): """Test the async API returns the same functions.""" - functions = await functions_api_async.list_(function_ids=[new_function._id_or_error]) + functions = await functions_api_async.list_(user_defined_functions=[new_function]) assert [f.id_ for f in functions] == [new_function.id_] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 42bfae679b..c53a9d3524 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -3634,7 +3634,7 @@ class UserDefinedFunctionsAPI: name: str | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, - function_ids: list[str] | None = None, + user_defined_functions: list[str] | list[UserDefinedFunction] | None = None, include_archived: bool = False, filter_query: str | None = None, order_by: str | None = None, @@ -3647,7 +3647,7 @@ class UserDefinedFunctionsAPI: name: Exact name of the function. name_contains: Partial name of the function. name_regex: Regular expression to filter functions by name. - function_ids: Filter to functions with any of these IDs. + user_defined_functions: 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. diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py index 35385e2878..383a3248e9 100644 --- a/python/lib/sift_client/resources/user_defined_functions.py +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -161,7 +161,7 @@ async def list_( name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids - function_ids: list[str] | None = None, + user_defined_functions: list[str] | list[UserDefinedFunction] | None = None, # common filters include_archived: bool = False, filter_query: str | None = None, @@ -175,7 +175,7 @@ async def list_( name: Exact name of the function. name_contains: Partial name of the function. name_regex: Regular expression to filter functions by name. - function_ids: Filter to functions with any of these IDs. + user_defined_functions: 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. @@ -193,8 +193,12 @@ async def list_( include_archived=include_archived, filter_query=filter_query ), ] - if function_ids: - filter_parts.append(cel.in_("user_defined_function_id", function_ids)) + if user_defined_functions: + ids = [ + f._id_or_error if isinstance(f, UserDefinedFunction) else f + for f in user_defined_functions + ] + filter_parts.append(cel.in_("user_defined_function_id", ids)) query_filter = cel.and_(*filter_parts) functions = await self._low_level_client.list_all_functions( From fffb22693861c632e933a6c08071a896a5d1168f Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 18:27:49 -0400 Subject: [PATCH 5/8] python: fixed docstring --- .../_tests/sift_types/test_user_defined_function.py | 2 +- python/lib/sift_client/resources/sync_stubs/__init__.pyi | 7 ++++--- .../lib/sift_client/resources/user_defined_functions.py | 9 +++++---- .../lib/sift_client/sift_types/user_defined_function.py | 4 +++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py index ac238eb08f..9701b02cb6 100644 --- a/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py +++ b/python/lib/sift_client/_tests/sift_types/test_user_defined_function.py @@ -159,7 +159,7 @@ def test_versions_property_calls_client(self, mock_function, mock_client): _ = mock_function.versions mock_client.user_defined_functions.versions.list_.assert_called_once_with( - user_defined_function=mock_function + user_defined_function=mock_function, order_by="version desc" ) def test_update_calls_client_and_updates_self(self, mock_function, mock_client): diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index c53a9d3524..0911864929 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -3530,13 +3530,14 @@ class UserDefinedFunctionVersionsAPI: 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. Newest first by default; - the service otherwise orders by name, which every version shares. + 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, newest first. + 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. """ ... diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py index 383a3248e9..b65b5dd20d 100644 --- a/python/lib/sift_client/resources/user_defined_functions.py +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -93,13 +93,14 @@ async def list_( 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. Newest first by default; - the service otherwise orders by name, which every version shares. + 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, newest first. + 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. """ filter_parts = self._build_common_cel_filters( include_archived=include_archived, filter_query=filter_query @@ -116,7 +117,7 @@ async def list_( ), name=name, query_filter=query_filter or None, - order_by=order_by or "version desc", + order_by=order_by, max_results=limit, **({"page_size": page_size} if page_size is not None else {}), ) diff --git a/python/lib/sift_client/sift_types/user_defined_function.py b/python/lib/sift_client/sift_types/user_defined_function.py index 2203eef337..26dccdb8cd 100644 --- a/python/lib/sift_client/sift_types/user_defined_function.py +++ b/python/lib/sift_client/sift_types/user_defined_function.py @@ -181,7 +181,9 @@ def _from_proto( @property def versions(self) -> list[UserDefinedFunctionVersion]: """Fetch every version of this function, newest first.""" - return self.client.user_defined_functions.versions.list_(user_defined_function=self) + return self.client.user_defined_functions.versions.list_( + user_defined_function=self, order_by="version desc" + ) @property def created_by(self) -> User: From 973007223810239ec72a94f4c710f5228b65e636 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 19:10:34 -0400 Subject: [PATCH 6/8] python: renames and fixes --- .../resources/test_user_defined_functions.py | 6 +++-- .../resources/sync_stubs/__init__.pyi | 4 ++-- .../resources/user_defined_functions.py | 23 ++++++++++++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py index e1c6f900d9..30a5a51786 100644 --- a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py +++ b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py @@ -98,7 +98,9 @@ def test_list_with_name_filter(self, sift_client, new_function): def test_list_with_id_filter(self, sift_client, new_function): """Test function listing filtered to specific IDs.""" - functions = sift_client.user_defined_functions.list_(user_defined_functions=[new_function]) + functions = sift_client.user_defined_functions.list_( + user_defined_function_ids=[new_function] + ) assert [f.id_ for f in functions] == [new_function.id_] @@ -203,7 +205,7 @@ def test_dependents_of_an_unused_function(self, sift_client, new_function): @pytest.mark.asyncio async def test_async_list(self, functions_api_async, new_function): """Test the async API returns the same functions.""" - functions = await functions_api_async.list_(user_defined_functions=[new_function]) + functions = await functions_api_async.list_(user_defined_function_ids=[new_function]) assert [f.id_ for f in functions] == [new_function.id_] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 0911864929..191eff3a34 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -3635,7 +3635,7 @@ class UserDefinedFunctionsAPI: name: str | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, - user_defined_functions: list[str] | list[UserDefinedFunction] | 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, @@ -3648,7 +3648,7 @@ class UserDefinedFunctionsAPI: name: Exact name of the function. name_contains: Partial name of the function. name_regex: Regular expression to filter functions by name. - user_defined_functions: Filter to these UserDefinedFunctions or function IDs. + 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. diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py index b65b5dd20d..99d2b2594e 100644 --- a/python/lib/sift_client/resources/user_defined_functions.py +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -162,7 +162,7 @@ async def list_( name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids - user_defined_functions: list[str] | list[UserDefinedFunction] | None = None, + user_defined_function_ids: list[str] | list[UserDefinedFunction] | None = None, # common filters include_archived: bool = False, filter_query: str | None = None, @@ -176,7 +176,7 @@ async def list_( name: Exact name of the function. name_contains: Partial name of the function. name_regex: Regular expression to filter functions by name. - user_defined_functions: Filter to these UserDefinedFunctions or function IDs. + 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. @@ -194,10 +194,10 @@ async def list_( include_archived=include_archived, filter_query=filter_query ), ] - if user_defined_functions: + if user_defined_function_ids: ids = [ f._id_or_error if isinstance(f, UserDefinedFunction) else f - for f in user_defined_functions + for f in user_defined_function_ids ] filter_parts.append(cel.in_("user_defined_function_id", ids)) query_filter = cel.and_(*filter_parts) @@ -319,13 +319,24 @@ async def get_where_used( version_id = ( version._id_or_error if isinstance(version, UserDefinedFunctionVersion) else version ) - return await self._low_level_client.get_dependents(version_id=version_id) + return self._apply_client_to_usage( + await self._low_level_client.get_dependents(version_id=version_id) + ) function_id = ( user_defined_function._id_or_error if isinstance(user_defined_function, UserDefinedFunction) else user_defined_function ) - return await self._low_level_client.get_dependents(function_id=function_id) + return self._apply_client_to_usage( + await self._low_level_client.get_dependents(function_id=function_id) + ) + + def _apply_client_to_usage(self, usage: FunctionUsage) -> FunctionUsage: + """Hand the client to every object in the usage, so their properties resolve.""" + self._apply_client_to_instances(usage.functions) + self._apply_client_to_instances(usage.calculated_channels) + self._apply_client_to_instances(usage.rules) + return usage async def archive( self, user_defined_function: str | UserDefinedFunction From dab369310c1ea99ab4d2a27fa42e1734d79cb4c9 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 20:16:28 -0400 Subject: [PATCH 7/8] python: no longer skips apply client to instance --- .../resources/test_user_defined_functions.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py index 30a5a51786..5e9a89c061 100644 --- a/python/lib/sift_client/_tests/resources/test_user_defined_functions.py +++ b/python/lib/sift_client/_tests/resources/test_user_defined_functions.py @@ -202,6 +202,34 @@ def test_dependents_of_an_unused_function(self, sift_client, new_function): assert dependents.is_used is False + def test_dependents_come_back_usable(self, sift_client, new_function, test_timestamp_str): + """Test that a returned object carries the client, so its properties resolve.""" + from sift_client.sift_types.calculated_channel import ( + CalculatedChannelCreate, + ChannelReference, + ) + + channel = sift_client.channels.list_(limit=1)[0] + calc = sift_client.calculated_channels.create( + CalculatedChannelCreate( + name=f"test_fn_user_{test_timestamp_str}", + expression=f"{new_function.name}($1)", + expression_channel_references=[ + ChannelReference(channel_reference="$1", channel_identifier=channel.name) + ], + all_assets=True, + ) + ) + + usage = sift_client.user_defined_functions.get_where_used(new_function) + + assert [c.id_ for c in usage.calculated_channels] == [calc.id_] + # CalculatedChannel exposes no client-backed property, so check the client + # directly. Without it, every property on a returned object raises. + assert usage.calculated_channels[0]._client is not None + + sift_client.calculated_channels.archive(calc) + @pytest.mark.asyncio async def test_async_list(self, functions_api_async, new_function): """Test the async API returns the same functions.""" From 29f956eec9772e1739b312629b1bcfa4d89437f2 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Fri, 25 Sep 2026 21:07:01 -0400 Subject: [PATCH 8/8] python: set page size to max --- .../user_defined_functions.py | 21 +++++++++++++++++++ .../resources/sync_stubs/__init__.pyi | 4 ++++ .../resources/user_defined_functions.py | 4 ++++ .../sift_types/user_defined_function.py | 2 ++ 4 files changed, 31 insertions(+) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py index fc28a84b46..09f8f512ee 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/user_defined_functions.py @@ -368,16 +368,37 @@ async def get_dependents( else: request_kwargs["user_defined_function_version_id"] = version_id + request_kwargs["page_size"] = DEFAULT_PAGE_SIZE request = GetUserDefinedFunctionDependentsRequest(**request_kwargs) response = await self._grpc_client.get_stub( UserDefinedFunctionServiceStub ).GetUserDefinedFunctionDependents(request) response = cast("GetUserDefinedFunctionDependentsResponse", response) + # The response pages each set separately but the request takes no page token, + # so a leftover token cannot be followed. Say so rather than return short. + truncated = [ + name + for name, token in ( + ("functions", response.next_page_token_user_defined_function), + ("calculated_channels", response.next_page_token_calculated_channel), + ("rules", response.next_page_token_rule), + ) + if token + ] + if truncated: + logger.warning( + "GetUserDefinedFunctionDependents returned more than %d %s; " + "the request takes no page token, so the rest cannot be fetched.", + DEFAULT_PAGE_SIZE, + " and ".join(truncated), + ) + return FunctionUsage( functions=[UserDefinedFunction._from_proto(f) for f in response.user_defined_functions], calculated_channels=[ CalculatedChannel._from_proto(c) for c in response.calculated_channels ], rules=[Rule._from_proto(r) for r in response.rules], + truncated=bool(truncated), ) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 191eff3a34..fb6914771c 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -3616,6 +3616,10 @@ class UserDefinedFunctionsAPI: 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 diff --git a/python/lib/sift_client/resources/user_defined_functions.py b/python/lib/sift_client/resources/user_defined_functions.py index 99d2b2594e..d3ace3c951 100644 --- a/python/lib/sift_client/resources/user_defined_functions.py +++ b/python/lib/sift_client/resources/user_defined_functions.py @@ -302,6 +302,10 @@ async def get_where_used( 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 diff --git a/python/lib/sift_client/sift_types/user_defined_function.py b/python/lib/sift_client/sift_types/user_defined_function.py index 26dccdb8cd..82be346caf 100644 --- a/python/lib/sift_client/sift_types/user_defined_function.py +++ b/python/lib/sift_client/sift_types/user_defined_function.py @@ -314,11 +314,13 @@ class FunctionUsage(BaseModel): functions: Other functions that call it. calculated_channels: Calculated channels that use it. rules: Rules that use it. + truncated: Whether the service had more to return than it sent. """ functions: list[UserDefinedFunction] = [] calculated_channels: list[CalculatedChannel] = [] rules: list[Rule] = [] + truncated: bool = False @property def is_used(self) -> bool: