Skip to content

python(feat): udf resource - #793

Open
Brandon-Shippy wants to merge 1 commit into
mainfrom
python/udf-resource
Open

Brandon-Shippy wants to merge 1 commit into
mainfrom
python/udf-resource

Conversation

@Brandon-Shippy

@Brandon-Shippy Brandon-Shippy commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a user-defined functions resource to sift_client. A function is a reusable, versioned expression that calculated channels and rules call by name.

  • UserDefinedFunctionsAPIAsync with get, list_, find, create, update, archive, unarchive. Wired up as client.user_defined_functions

  • UserDefinedFunctionVersionsAPIAsync nested as client.user_defined_functions.versions

  • UserDefinedFunctionsLowLevelClient wraps UserDefinedFunctionService; the high-level client has zero proto imports

  • Pydantic models in sift_types/user_defined_function.py

  • validate(...) checks an expression without saving it

  • dependents(...) returns the rules and calculated channels that would break on a change

  • sync(...) creates or updates a list of definitions by name, so a definitions file is rerunnable

  • Sync wrappers and type stubs registered

    Validation

    • End to End testing with manual script written to test each new API feature.
    • Verified against a local stack, including a calculated channel and a rule that call a function by name

@github-actions

Copy link
Copy Markdown
Contributor

Python docs preview: https://sift-stack.github.io/sift/python/pr-793/

Deployed from 212370e. The link may take up to a minute to become live as GitHub Pages propagates.

@Brandon-Shippy
Brandon-Shippy marked this pull request as ready for review September 17, 2026 22:13
updated = await self._low_level_client.update_function(update)
return self._apply_client_to_instance(updated)

async def sync(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd drop sync, or if you want to keep it, rename it to something like create_or_update and look up by names instead of listing the whole org, skip archived matches, carry user_notes through, and document that the server blocks input, output type, and name changes on in-use functions, so a rerun can't promise Sift will match the file

I think we should drop it, since anyone who needs it can write find then create or update in a few lines and see exactly where a partial failure stopped

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we should remove this. It is not idiomatic with the rest of the types.

"""Base class for UserDefinedFunction create and update models."""

description: str | None = None
function_inputs: list[FunctionInput] | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The server rejects an empty function_inputs on create and validate, so this should be required on UserDefinedFunctionCreate and validate()

)

async def dependents(
self, function: str | UserDefinedFunction, *, version_id: str | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if version_id is passed, function gets ignored. Is this intentional?

).GetUserDefinedFunctionDependents(request)
response = cast("GetUserDefinedFunctionDependentsResponse", response)

return FunctionDependents(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a reason to reduce these to IDs? The response returns full messages and _from_proto exists for all three types

function: str | UserDefinedFunction,
update: UserDefinedFunctionUpdate | dict,
) -> UserDefinedFunction:
"""Update a function. This creates a new version.

@wei-qlu wei-qlu Sep 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things here. Archives return the current version, so something like Changes to the expression, inputs, description, or metadata create a new version. would be more accurate. And let's add a user_notes kwarg like calculated channels have, threaded into the wrapper and set on the proto after to_proto_with_mask(). The server reads it from the body, and right now there's no way to leave a note on a version

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One catch for user_notes, the server treats an empty note as a change, so when none is given, send the current version's note rather than empty, otherwise every update clears it and bumps the version. Rules update already fetches the object when passed an ID, so the same pattern works here

async def list_(
self,
*,
function: str | UserDefinedFunction | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing a lot of other filters we should support here.

name_contains: str | None = None,
name_regex: str | re.Pattern | None = None,
# self ids
function_ids: list[str] | None = None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

object and Id support.

A lot of missing filters here?

results.append(await self.update(current, changes))
return results

async def validate(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_expression - more descriptive naming

expression=expression, function_inputs=function_inputs
)

async def dependents(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_where_used is more clear IMO

)

async def dependents(
self, function: str | UserDefinedFunction, *, version_id: str | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

version: UserDefinedFunctionVersion | str

Also since version is fully defined, it should make passing the function itself optional

)

async def dependents(
self, function: str | UserDefinedFunction, *, version_id: str | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit on naming: user_defined_function instead of function


identifier: str
data_type: FunctionDataType = FunctionDataType.NUMERIC
constant: bool = False

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This naming is confusing. It isn't a constant since that would imply it doesn't change, rather this is a value provided by the caller. scalar or something may make more sense.

)

@property
def versions(self) -> list[UserDefinedFunction]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a latest_version

name: str
description: str
expression: str
version: int

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty confusing and repeats some of the same sins of say Channels.

Instead of a single object, we should have two:

  • User Defined Function
  • User Defined Function Version

The User Defined Function should contain a reference to the latest version, but be version agnostic

"""Create model for UserDefinedFunction."""

name: str
expression: str

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be documented that this is a cel expression


name: str
expression: str
user_notes: str | None = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this "change_notes" or something more clear what it is doing

output_type: The type the expression returns, when it is valid.
"""

valid: bool

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_valid



class FunctionDependents(BaseModel):
"""What would break if a function changed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that what this represents? Why would things necessarily break?

"""

function_ids: list[str] = []
calculated_channel_ids: list[str] = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add properties to return the objects

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants