Skip to content

python(feat): annotations resource - #792

Open
Brandon-Shippy wants to merge 8 commits into
mainfrom
python/annotations-resource
Open

Brandon-Shippy wants to merge 8 commits into
mainfrom
python/annotations-resource

Conversation

@Brandon-Shippy

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

Copy link
Copy Markdown
Contributor

Description

Adds an annotations resource to sift_client, covering data reviews, phases, and the review history.

Annotation and all associated classes and enums are made.
Implements get, list_, find, create, update, assign_to_user, set_open, set_failed, set_accepted, archive/unarchive, add_comment

Validation

  • End to End testing with manual script written to test each new API feature.
  • Verified against a local stack, including the annotation rendering in Explore

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

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

Deployed from 06ee58e. 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 20:25
if state:
filter_parts.append(cel.equals("state", state.to_filter_str()))
if assigned_to:
filter_parts.append(cel.equals("assignee", assigned_to))

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.

assignee filters on the user's name (azimuth maps it to users.name) but the docstring says user id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

docstring for assigned_to now says it filters on the user's name

)
return self._apply_client_to_instance(log)

async def record_assignment(self, annotation: str | Annotation, user: str) -> AnnotationLog:

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.

Drop record_assignment and record_state. They write a "resolved" or "assigned" entry to the feed without changing the annotation, and the server already writes those entries itself whenever resolve/flag/reopen/assign run, so the only thing they add is history that disagrees with the annotation itself

Comments stay since that's intended

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.

+1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

dropped logs.record_assignment and logs.record_state

)
return self._apply_client_to_instance(log)

async def delete(self, annotation: str | Annotation, log: str | AnnotationLog) -> 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.

Drop logs.delete, nothing in Sift supports deleting logs, and we shouldn't expose it to users when it's own test says it doesn't work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i killed it

created = await self._low_level_client.create_annotation(create=create)
return self._apply_client_to_instance(created)

async def create_review(

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.

Let's fold create_review and create_phase into create. Under the hood both just build an AnnotationCreate and call create with annotation_type set, but with renamed fields along the way (assign_to vs assign_to_user_id, channels vs linked_channels, run vs run_id), so users end up having to know two names for everything

client.annotations.create(AnnotationCreate(name="Spike", start_time=start, end_time=end,
    assets=["Nostromo"], linked_channels=[AnnotationLinkedChannel(channel_id=c.id_) for c in chans],
    assign_to_user_id=bob))
client.annotations.create_review("Spike", start, end, channels=chans, assign_to=bob)
client.annotations.create_phase("Burn", start, end, channels=chans)

Would become:

client.annotations.create(AnnotationCreate(name="Spike", start_time=start, end_time=end,
    linked_channels=chans, assign_to_user_id=bob))
client.annotations.create({"name": "Burn", "start_time": start, "end_time": end,
    "linked_channels": chans, "annotation_type": AnnotationType.PHASE})

Let AnnotationCreate accept Channel and Asset objects and move the asset-from-channels lookup from _create_typed into create

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

folded create_review and create_phase into one create() that takes either AnnotationCreate or PhaseCreate

"""
return self.update({"assigned_to_user_id": user})

def resolve(self) -> Annotation:

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.

Convention is that instance methods forward to the resource and _update (see archive right above). assign/resolve/flag/reopen restate the logic instead and duplicate _set_state, so let's have them call self.client.annotations.resolve(annotation=self) etc., which also deletes Annotation._set_state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

instance now forwards to the resource, then _update. Annotation._set_state is deleted

"""
request = ArchiveAnnotationRequest(annotation_id=annotation_id)
await self._grpc_client.get_stub(AnnotationServiceStub).ArchiveAnnotation(request)
return await self.get_annotation(annotation_id)

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.

ArchiveAnnotationRequest and UnarchiveAnnotationRequest already return the updated annotation so the extra get_annotation here is redundant

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

removed the get_annotation call

Comment thread python/lib/sift_client/sift_types/annotation.py
run_id: str | None
assigned_to_user_id: str | None
created_by_rule_condition_version_id: str | None
legend_config: str | 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.

this is not user-useful, drop this field

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

its gone

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 meant legend config


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

"""
return self.update({"assigned_to_user_id": user})

def resolve(self) -> Annotation:

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.

The enum names do not actually correlated to what they are referred to in the application. Use the application nomenclature instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

renamed to match open / failed / accepted

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.

The enums read right now. test_record_state (test_annotations.py:248-253) still uses AnnotationLogState.FLAGGED and the removed logs.record_state, so it fails the first time anyone runs it; delete it. The set_* docstrings at resources/annotations.py:469-494 still say resolve, flag and reopen, so reword them to accepted, failed and open and regenerate the stubs.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

deleted test_record_state

set_* docstrings use the proper words: Accepted/Failed/Open

Comment thread python/lib/sift_client/sift_types/annotation.py Outdated
Comment thread python/lib/sift_client/sift_types/annotation.py Outdated
start_time: datetime
end_time: datetime
annotation_type: AnnotationType = AnnotationType.DATA_REVIEW
assets: 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.

asset and run should accept both Object and Id

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

assets takes str | Asset
run_id takes str | Run

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.

assets now takes Asset objects, but a string is still read as an asset name (annotation.py:336-353), while list_(assets=...) reads it as an ID (resources/annotations.py:285-287). Strings should be asset IDs here, like for any other Sift resource, since a name can change. Resolve the IDs to names in create() with the same assets.list_(asset_ids=...) call that _assets_for_channels already makes.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

create() resolves them to names using _asset_names

@model_validator(mode="after")
def _validate_state(self):
"""Phase annotations have no review state; the server rejects one."""
if self.annotation_type is AnnotationType.PHASE and self.state is not 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.

It may be nicer to have different models for the different types since they are very different in usage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They are now split into both AnnotationCreate and PhaseCreate but are folded under create()

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.

The split is in, but annotation_type is still settable on both models (annotation.py:373, 399), so AnnotationCreate(annotation_type=AnnotationType.PHASE, state=...) gets all the way to the server, which rejects a phase with a state. Add a pydantic check so it fails validation instead: pin the type per class (Literal[AnnotationType.PHASE] on PhaseCreate) or bring back a _validate_state model validator. Then move test_create_phase to PhaseCreate and fix test_phase_model_has_no_usable_state, which raises KeyError at head.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

a mismatched type fails validation now


text: str | None = None
user_id: str | None = None
user_email: 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.

not a fan of this pattern - we have a User resource now. Can we instead use user_id and a user prop?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

makes sense, done

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.

user_email is gone, but there's still no user prop: AnnotationCommentElement (annotation.py:448-483) is a plain BaseModel, so reading a mention means calling client.users.get by hand, and writing one rejects a User. Accept str | User for user_id, and resolve mentions through a property on AnnotationLog, which has the client.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

AnnotationLog.mentioned_users resolves both str and User

Comment thread python/lib/sift_client/sift_types/annotation.py Outdated
return f"ANNOTATION_STATE_{self.name}"


class AnnotationLinkedChannel(BaseModel):

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 don't think we should use a new type for this. Simply reference the existing types.

linked_channels: list[Channel | CalcualtedChannel | ....]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

AnnotationLinkedChannel deleted.
writes do list[Channel | CalculatedChannel]

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.

Create works, but AnnotationUpdate(linked_channels=...) is silently dropped: exclude=True (annotation.py:301-303) keeps it out of the mask, and update_annotation never adds it back, though the update docstring says it's replaced. Add it to the proto and mask in to_proto_with_mask, as ReportTemplateUpdate does (report_template.py:285-290). On read, line 224 passes calculated-channel version IDs as calculated_channel_id, so resolve them with one GetCalculatedChannelVersions call instead, and fix test_annotations.py:300, since Channel has no channel_id.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

now sets and adds linked_channels to the mask

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

list_versions filters on calculated_channel_version_id now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

test needed to use channel_id like you said

Comment thread python/lib/sift_client/resources/annotations.py Outdated
Comment thread python/lib/sift_client/resources/annotations.py Outdated
name_contains: str | None = None,
name_regex: str | re.Pattern | None = None,
# self ids
annotation_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.

should be Annotation | str

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

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.

Doesn't look fixed?


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

should be fixed, the docstring was still listed

pending: bool | None = None,
assets: list[Asset] | list[str] | None = None,
runs: list[Run] | list[str] | None = None,
rule_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.

support Rule and Report objects

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

rule_ids and report_ids became rules and replorts which both accept objects or ids

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.

The objects work now. The list_ docstring still lists rule_ids and report_ids at resources/annotations.py:237-238, and the stub repeats them, so hover docs show two arguments that don't exist. Drop both lines and regenerate the stubs.


Generated by Claude Code

self,
*,
name: str | None = None,
names: 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.

We should combine these into a single arg (not just here but everywhere).

e.g. name: str | list[str] | None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@Brandon-Shippy Brandon-Shippy Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

all have been collapsed

i noticed a pattern on resources wehere we have asset and assets. are these things we want merged as well. it is in the asset resource

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.

Yes, and let's make it library-wide: please file a ticket to move every list_ (and _build_name_cel_filters) onto the single name: str | list[str] shape and settle pairs like asset/assets the same way, and link it here. Also, a tuple or set in name falls through both checks at resources/annotations.py:256-257 and silently drops the filter, so I'd move that split into _build_name_cel_filters and treat any non-string iterable as a list.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

now any non-string iterable can be treated as a list

Comment thread python/lib/sift_client/resources/annotations.py Outdated
Comment thread python/lib/sift_client/resources/annotations.py Outdated
Comment thread python/lib/sift_client/resources/annotations.py
start_time: datetime
end_time: datetime
assets: list[str | Asset] | None = None
run_id: str | Run | 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.

should be run

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

a0c97fc

yeah that makes more sense here

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