From baad1c42d9f413a377832187b11b4b1fe6b96113 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Tue, 17 Feb 2026 14:32:38 -0800 Subject: [PATCH 01/10] add report unarchive test fix --- .../_tests/resources/test_reports.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/python/lib/sift_client/_tests/resources/test_reports.py b/python/lib/sift_client/_tests/resources/test_reports.py index 8472ed276..ee8ef70ae 100644 --- a/python/lib/sift_client/_tests/resources/test_reports.py +++ b/python/lib/sift_client/_tests/resources/test_reports.py @@ -146,17 +146,16 @@ def test_archive(self, nostromo_run, test_rule, sift_client): assert archived_report is not None assert archived_report.is_archived == True - def test_unarchive(self, sift_client): - reports_from_rules = sift_client.reports.list_( - name="report_from_rules", include_archived=True + def test_unarchive(self, nostromo_run, test_rule, sift_client): + # create a report, archive it, then unarchive it + report_from_rules = sift_client.reports.create_from_rules( + name="report_from_rules_unarchive", + run=nostromo_run, + rules=[test_rule], ) - report_from_rules = None - for report_from_rules in reports_from_rules: - if report_from_rules.is_archived: - report_from_rules = report_from_rules - break assert report_from_rules is not None - assert report_from_rules.is_archived == True - unarchived_report = sift_client.reports.unarchive(report=report_from_rules) + archived_report = sift_client.reports.archive(report=report_from_rules) + assert archived_report.is_archived is True + unarchived_report = sift_client.reports.unarchive(report=archived_report) assert unarchived_report is not None - assert unarchived_report.is_archived == False + assert unarchived_report.is_archived is False From 069ea798dad42d6b015122ab698cc516cf19218d Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Wed, 18 Feb 2026 18:01:12 -0800 Subject: [PATCH 02/10] add list_rule_versions and get_rule_version --- .../_internal/low_level_wrappers/rules.py | 71 +++++++++++++++++++ python/lib/sift_client/resources/reports.py | 36 +++++++++- python/lib/sift_client/resources/rules.py | 64 ++++++++++++++++- 3 files changed, 169 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index 08a279b85..2f44b5553 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -24,7 +24,11 @@ CreateRuleResponse, GetRuleRequest, GetRuleResponse, + GetRuleVersionRequest, + GetRuleVersionResponse, ListRulesRequest, + ListRuleVersionsRequest, + ListRuleVersionsResponse, RuleAssetConfiguration, RuleConditionExpression, UnarchiveRuleRequest, @@ -45,6 +49,7 @@ Rule, RuleCreate, RuleUpdate, + RuleVersion, ) from sift_client.sift_types.tag import Tag from sift_client.transport import GrpcClient, WithGrpcClient @@ -506,6 +511,57 @@ async def list_all_rules( max_results=max_results, ) + async def list_rule_versions( + self, + rule_id: str, + *, + filter_query: str | None = None, + order_by: str | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> tuple[list[RuleVersion], str]: + """List rule versions for a rule. + + Args: + rule_id: The rule ID to list versions for. + filter_query: Optional CEL filter (fields: rule_version_id, user_notes, change_message). + order_by: Unused, for _handle_pagination compatibility. + page_size: Maximum number of versions per page. + page_token: Token for the next page. + + Returns: + Tuple of (list of RuleVersions, next page token or empty string). + """ + _ = order_by + request_kwargs: dict[str, Any] = { + "rule_id": rule_id, + "page_size": page_size or DEFAULT_PAGE_SIZE, + "page_token": page_token or "", + } + if filter_query: + request_kwargs["filter"] = filter_query + request = ListRuleVersionsRequest(**request_kwargs) + response = await self._grpc_client.get_stub(RuleServiceStub).ListRuleVersions(request) + response = cast("ListRuleVersionsResponse", response) + versions = [RuleVersion._from_proto(p) for p in response.rule_versions] + return versions, response.next_page_token or "" + + async def list_all_rule_versions( + self, + rule_id: str, + *, + filter_query: str | None = None, + max_results: int | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + ) -> list[RuleVersion]: + """List all rule versions for a rule, with optional CEL filter.""" + return await self._handle_pagination( + self.list_rule_versions, + kwargs={"rule_id": rule_id, "filter_query": filter_query}, + page_size=page_size, + max_results=max_results, + ) + async def evaluate_rules( self, *, @@ -595,3 +651,18 @@ async def evaluate_rules( report = await ReportsLowLevelClient(self._grpc_client).get_report(report_id=report_id) return created_annotation_count, report, job_id return created_annotation_count, None, job_id + + async def get_rule_version(self, rule_version_id: str) -> Rule: + """Get a rule at a specific version by rule_version_id. + + Args: + rule_version_id: The rule version ID to get. + + Returns: + The Rule at that version. + """ + request = GetRuleVersionRequest(rule_version_id=rule_version_id) + response = await self._grpc_client.get_stub(RuleServiceStub).GetRuleVersion(request) + grpc_rule = cast("GetRuleVersionResponse", response).rule + return Rule._from_proto(grpc_rule) + diff --git a/python/lib/sift_client/resources/reports.py b/python/lib/sift_client/resources/reports.py index 86a19d5ae..0db9c77d7 100644 --- a/python/lib/sift_client/resources/reports.py +++ b/python/lib/sift_client/resources/reports.py @@ -6,7 +6,7 @@ from sift_client._internal.low_level_wrappers.rules import RulesLowLevelClient from sift_client.resources._base import ResourceBase from sift_client.sift_types.report import Report, ReportUpdate -from sift_client.sift_types.rule import Rule +from sift_client.sift_types.rule import Rule, RuleVersion from sift_client.sift_types.run import Run from sift_client.util import cel_utils as cel @@ -266,6 +266,40 @@ async def create_from_applicable_rules( return self._apply_client_to_instance(created_report) return None + async def create_from_rule_versions( + self, + *, + name: str, + run: Run | str | None = None, + organization_id: str | None = None, + rule_versions: list[RuleVersion] | list[str], + ) -> Report | None: + """Create a new report from rule versions. + + Args: + name: The name of the report. + run: The run or run ID to associate with the report. + organization_id: The organization ID. + rule_versions: List of RuleVersions or rule_version IDs to include in the report. + + Returns: + The created Report or None if no report was created. + """ + ( + created_annotation_count, + created_report, + job_id, + ) = await self._rules_low_level_client.evaluate_rules( + run_id=run._id_or_error if isinstance(run, Run) else run, + organization_id=organization_id, + rule_version_ids=[rule_version.rule_version_id if isinstance(rule_version, RuleVersion) else rule_version for rule_version in rule_versions] + or [], + report_name=name, + ) + if created_report: + return self._apply_client_to_instance(created_report) + return None + async def rerun( self, *, diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index b6734e610..4a31e92b6 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -7,7 +7,7 @@ from sift_client.errors import SiftWarning from sift_client.resources._base import ResourceBase from sift_client.sift_types.asset import Asset -from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate +from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate, RuleVersion from sift_client.util import cel_utils as cel if TYPE_CHECKING: @@ -328,3 +328,65 @@ async def batch_update_or_create_rules( # Fetch the rules. updated_rules = await self._low_level_client.batch_get_rules(rule_ids=final_rule_ids) return self._apply_client_to_instances(updated_rules) + + async def list_rule_versions( + self, + rule: Rule | str, + *, + version_notes_contains: str | None = None, + change_message_contains: str | None = None, + rule_version_ids: list[str] | None = None, + filter_query: str | None = None, + limit: int | None = None, + ) -> list[RuleVersion]: + """List versions of a rule with optional filtering. + + Args: + rule: The Rule instance or rule ID. + version_notes_contains: Filter by version notes (user_notes) containing this string. + change_message_contains: Filter by change message containing this string. + rule_version_ids: Limit to these rule version IDs. + filter_query: Raw CEL filter (fields: rule_version_id, user_notes, change_message). + limit: Maximum number of versions to return. If None, returns all matches. + + Returns: + A list of RuleVersion objects matching the filters, ordered by newest versions first. + """ + if isinstance(rule, Rule): + rule_id = rule.resource_id + else: + rule_id = rule + + filter_parts: list[str] = [] + if version_notes_contains: + filter_parts.append(cel.contains("user_notes", version_notes_contains)) + if change_message_contains: + filter_parts.append(cel.contains("change_message", change_message_contains)) + if rule_version_ids: + filter_parts.append(cel.in_("rule_version_id", rule_version_ids)) + if filter_query: + filter_parts.append(filter_query) + query_filter = cel.and_(*filter_parts) if filter_parts else None + + return await self._low_level_client.list_all_rule_versions( + rule_id=rule_id, + filter_query=query_filter, + max_results=limit, + page_size=limit, + ) + + async def get_rule_version(self, rule_version: RuleVersion | str) -> Rule: + """Get a rule at a specific version by rule version ID. + + Args: + rule_version: The RuleVersion instance or rule version ID. + + Returns: + The Rule at that version. + """ + if isinstance(rule_version, RuleVersion): + rule_version_id = rule_version.rule_version_id + else: + rule_version_id = rule_version + rule = await self._low_level_client.get_rule_version(rule_version_id=rule_version_id) + return self._apply_client_to_instance(rule) From b10571040b0345dbf3072a002fbc35aff87fff96 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Wed, 18 Feb 2026 18:17:23 -0800 Subject: [PATCH 03/10] add tests --- .../_tests/resources/test_reports.py | 29 +++++++ .../_tests/resources/test_rules.py | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/python/lib/sift_client/_tests/resources/test_reports.py b/python/lib/sift_client/_tests/resources/test_reports.py index ee8ef70ae..3eb3e6f05 100644 --- a/python/lib/sift_client/_tests/resources/test_reports.py +++ b/python/lib/sift_client/_tests/resources/test_reports.py @@ -54,6 +54,35 @@ def test_client_binding(sift_client): @pytest.mark.integration class TestReports: + def test_create_from_rule_versions(self, nostromo_run, test_rule, sift_client): + """Create a report from specific rule version IDs.""" + rule_versions = sift_client.rules.list_rule_versions(test_rule) + assert rule_versions, "test_rule should have at least one version" + report = sift_client.reports.create_from_rule_versions( + name="report_from_rule_versions", + run=nostromo_run, + organization_id=nostromo_run.organization_id, + rule_versions=[rule_versions[0].rule_version_id], + ) + assert report is not None + assert report.run_id == nostromo_run.id_ + assert report.name == "report_from_rule_versions" + + def test_create_from_rule_versions_with_rule_version_objects( + self, nostromo_run, test_rule, sift_client + ): + """Create a report passing RuleVersion instances.""" + rule_versions = sift_client.rules.list_rule_versions(test_rule) + assert rule_versions + report = sift_client.reports.create_from_rule_versions( + name="report_from_rule_versions_objs", + run=nostromo_run, + organization_id=nostromo_run.organization_id, + rule_versions=rule_versions[:1], + ) + assert report is not None + assert report.run_id == nostromo_run.id_ + def test_create_from_rules(self, nostromo_run, test_rule, sift_client): report_from_rules = sift_client.reports.create_from_rules( name="report_from_rules", diff --git a/python/lib/sift_client/_tests/resources/test_rules.py b/python/lib/sift_client/_tests/resources/test_rules.py index c0fc581ea..091999a3a 100644 --- a/python/lib/sift_client/_tests/resources/test_rules.py +++ b/python/lib/sift_client/_tests/resources/test_rules.py @@ -22,6 +22,7 @@ RuleAnnotationType, RuleCreate, RuleUpdate, + RuleVersion, ) pytestmark = pytest.mark.integration @@ -215,6 +216,80 @@ async def test_list_with_time_filters(self, rules_api_async): for rule in rules: assert rule.created_date >= one_year_ago + class TestListRuleVersions: + """Tests for the async list_rule_versions method.""" + + @pytest.mark.asyncio + async def test_list_rule_versions_by_rule(self, rules_api_async, test_rule): + """Test listing rule versions for a rule.""" + versions = await rules_api_async.list_rule_versions(test_rule) + assert isinstance(versions, list) + assert len(versions) >= 1 + for v in versions: + assert isinstance(v, RuleVersion) + assert v.rule_id == test_rule.id_ + assert v.rule_version_id + assert v.version + assert v.created_date + + @pytest.mark.asyncio + async def test_list_rule_versions_by_rule_id_str(self, rules_api_async, test_rule): + """Test listing rule versions by rule ID string.""" + versions = await rules_api_async.list_rule_versions(test_rule.id_) + assert isinstance(versions, list) + assert len(versions) >= 1 + for v in versions: + assert v.rule_id == test_rule.id_ + + @pytest.mark.asyncio + async def test_list_rule_versions_with_limit(self, rules_api_async, test_rule): + """Test listing rule versions with limit.""" + versions = await rules_api_async.list_rule_versions(test_rule, limit=1) + assert isinstance(versions, list) + assert len(versions) <= 1 + if versions: + assert isinstance(versions[0], RuleVersion) + + @pytest.mark.asyncio + async def test_list_rule_versions_with_rule_version_ids_filter( + self, rules_api_async, test_rule + ): + """Test listing rule versions filtered by rule_version_ids.""" + all_versions = await rules_api_async.list_rule_versions(test_rule) + assert all_versions + first_id = all_versions[0].rule_version_id + versions = await rules_api_async.list_rule_versions( + test_rule, rule_version_ids=[first_id] + ) + assert len(versions) == 1 + assert versions[0].rule_version_id == first_id + + class TestGetRuleVersion: + """Tests for the async get_rule_version method.""" + + @pytest.mark.asyncio + async def test_get_rule_version_by_id(self, rules_api_async, test_rule): + """Test getting a rule at a specific version by rule_version_id.""" + versions = await rules_api_async.list_rule_versions(test_rule) + assert versions + rule_at_version = await rules_api_async.get_rule_version(versions[0].rule_version_id) + assert rule_at_version is not None + assert rule_at_version.id_ == test_rule.id_ + assert rule_at_version.rule_version is not None + assert rule_at_version.rule_version.rule_version_id == versions[0].rule_version_id + + @pytest.mark.asyncio + async def test_get_rule_version_by_rule_version_instance( + self, rules_api_async, test_rule + ): + """Test getting a rule at a specific version by passing RuleVersion instance.""" + versions = await rules_api_async.list_rule_versions(test_rule) + assert versions + rule_at_version = await rules_api_async.get_rule_version(versions[0]) + assert rule_at_version is not None + assert rule_at_version.id_ == test_rule.id_ + assert rule_at_version.rule_version.rule_version_id == versions[0].rule_version_id + class TestFind: """Tests for the async find method.""" From 6305c1db08fbf07996d5cd4959befe6ef6c75cc9 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Wed, 18 Feb 2026 18:20:32 -0800 Subject: [PATCH 04/10] add BatchGetRuleVersions --- .../_internal/low_level_wrappers/rules.py | 18 +++++++++ .../_tests/resources/test_rules.py | 40 +++++++++++++++++++ python/lib/sift_client/resources/rules.py | 19 +++++++++ 3 files changed, 77 insertions(+) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index 2f44b5553..b82012ffe 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -15,6 +15,8 @@ ArchiveRuleRequest, BatchArchiveRulesRequest, BatchGetRulesRequest, + BatchGetRuleVersionsRequest, + BatchGetRuleVersionsResponse, BatchUnarchiveRulesRequest, BatchUpdateRulesRequest, BatchUpdateRulesResponse, @@ -666,3 +668,19 @@ async def get_rule_version(self, rule_version_id: str) -> Rule: grpc_rule = cast("GetRuleVersionResponse", response).rule return Rule._from_proto(grpc_rule) + async def batch_get_rule_versions(self, rule_version_ids: list[str]) -> list[Rule]: + """Get multiple rules at specific versions by rule_version_ids. + + Args: + rule_version_ids: The rule version IDs to get. + + Returns: + List of Rules at those versions (order may match request order). + """ + request = BatchGetRuleVersionsRequest(rule_version_ids=rule_version_ids) + response = await self._grpc_client.get_stub(RuleServiceStub).BatchGetRuleVersions( + request + ) + response = cast("BatchGetRuleVersionsResponse", response) + return [Rule._from_proto(r) for r in response.rules] + diff --git a/python/lib/sift_client/_tests/resources/test_rules.py b/python/lib/sift_client/_tests/resources/test_rules.py index 091999a3a..c94b19186 100644 --- a/python/lib/sift_client/_tests/resources/test_rules.py +++ b/python/lib/sift_client/_tests/resources/test_rules.py @@ -290,6 +290,46 @@ async def test_get_rule_version_by_rule_version_instance( assert rule_at_version.id_ == test_rule.id_ assert rule_at_version.rule_version.rule_version_id == versions[0].rule_version_id + class TestBatchGetRuleVersions: + """Tests for the async batch_get_rule_versions method.""" + + @pytest.mark.asyncio + async def test_batch_get_rule_versions_by_ids(self, rules_api_async, test_rule): + """Test batch getting rules by rule_version_id strings.""" + versions = await rules_api_async.list_rule_versions(test_rule) + assert versions + ids = [v.rule_version_id for v in versions[:2]] + rules = await rules_api_async.batch_get_rule_versions(ids) + assert len(rules) == len(ids) + returned_ids = {r.rule_version.rule_version_id for r in rules if r.rule_version} + assert returned_ids >= set(ids) + for r in rules: + assert r.id_ == test_rule.id_ + + @pytest.mark.asyncio + async def test_batch_get_rule_versions_by_rule_version_instances( + self, rules_api_async, test_rule + ): + """Test batch getting rules by passing RuleVersion instances.""" + versions = await rules_api_async.list_rule_versions(test_rule) + assert versions + rules = await rules_api_async.batch_get_rule_versions(versions[:2]) + assert len(rules) <= 2 + for r in rules: + assert r.id_ == test_rule.id_ + if len(versions) >= 2: + assert len(rules) == 2 + + @pytest.mark.asyncio + async def test_batch_get_rule_versions_single(self, rules_api_async, test_rule): + """Test batch_get_rule_versions with a single version ID.""" + versions = await rules_api_async.list_rule_versions(test_rule) + assert versions + rules = await rules_api_async.batch_get_rule_versions([versions[0].rule_version_id]) + assert len(rules) == 1 + assert rules[0].id_ == test_rule.id_ + assert rules[0].rule_version.rule_version_id == versions[0].rule_version_id + class TestFind: """Tests for the async find method.""" diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index 4a31e92b6..d70a9a020 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -390,3 +390,22 @@ async def get_rule_version(self, rule_version: RuleVersion | str) -> Rule: rule_version_id = rule_version rule = await self._low_level_client.get_rule_version(rule_version_id=rule_version_id) return self._apply_client_to_instance(rule) + + async def batch_get_rule_versions( + self, rule_versions: list[RuleVersion] | list[str] + ) -> list[Rule]: + """Get multiple rules at specific versions by rule version IDs. + + Args: + rule_versions: List of RuleVersion instances or rule version IDs. + + Returns: + List of Rules at those versions. + """ + rule_version_ids = [ + rv.rule_version_id if isinstance(rv, RuleVersion) else rv for rv in rule_versions + ] + rules = await self._low_level_client.batch_get_rule_versions( + rule_version_ids=rule_version_ids + ) + return self._apply_client_to_instances(rules) From d0797a7990047be1035c949b9d79ba4303c51204 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Wed, 18 Feb 2026 18:26:28 -0800 Subject: [PATCH 05/10] fmt and stub gen --- .../_internal/low_level_wrappers/rules.py | 5 +- .../_tests/resources/test_rules.py | 4 +- python/lib/sift_client/resources/reports.py | 7 +- .../resources/sync_stubs/__init__.pyi | 66 +++++++++++++++---- .../lib/sift_py/ingestion/_internal/ingest.py | 8 +-- 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index b82012ffe..4461b4ebe 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -678,9 +678,6 @@ async def batch_get_rule_versions(self, rule_version_ids: list[str]) -> list[Rul List of Rules at those versions (order may match request order). """ request = BatchGetRuleVersionsRequest(rule_version_ids=rule_version_ids) - response = await self._grpc_client.get_stub(RuleServiceStub).BatchGetRuleVersions( - request - ) + response = await self._grpc_client.get_stub(RuleServiceStub).BatchGetRuleVersions(request) response = cast("BatchGetRuleVersionsResponse", response) return [Rule._from_proto(r) for r in response.rules] - diff --git a/python/lib/sift_client/_tests/resources/test_rules.py b/python/lib/sift_client/_tests/resources/test_rules.py index c94b19186..d181d80d1 100644 --- a/python/lib/sift_client/_tests/resources/test_rules.py +++ b/python/lib/sift_client/_tests/resources/test_rules.py @@ -279,9 +279,7 @@ async def test_get_rule_version_by_id(self, rules_api_async, test_rule): assert rule_at_version.rule_version.rule_version_id == versions[0].rule_version_id @pytest.mark.asyncio - async def test_get_rule_version_by_rule_version_instance( - self, rules_api_async, test_rule - ): + async def test_get_rule_version_by_rule_version_instance(self, rules_api_async, test_rule): """Test getting a rule at a specific version by passing RuleVersion instance.""" versions = await rules_api_async.list_rule_versions(test_rule) assert versions diff --git a/python/lib/sift_client/resources/reports.py b/python/lib/sift_client/resources/reports.py index 0db9c77d7..df3b4f0d1 100644 --- a/python/lib/sift_client/resources/reports.py +++ b/python/lib/sift_client/resources/reports.py @@ -292,7 +292,12 @@ async def create_from_rule_versions( ) = await self._rules_low_level_client.evaluate_rules( run_id=run._id_or_error if isinstance(run, Run) else run, organization_id=organization_id, - rule_version_ids=[rule_version.rule_version_id if isinstance(rule_version, RuleVersion) else rule_version for rule_version in rule_versions] + rule_version_ids=[ + rule_version.rule_version_id + if isinstance(rule_version, RuleVersion) + else rule_version + for rule_version in rule_versions + ] or [], report_name=name, ) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 35399d4cd..3d2f41c46 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -27,7 +27,7 @@ if TYPE_CHECKING: RemoteFileEntityType, ) from sift_client.sift_types.job import Job, JobStatus, JobType - from sift_client.sift_types.report import Report, ReportUpdate + from sift_client.sift_types.report import PendingReport, Report, ReportUpdate from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate from sift_client.sift_types.run import Run, RunCreate, RunUpdate from sift_client.sift_types.tag import Tag, TagUpdate @@ -745,6 +745,25 @@ class JobsAPI: """ ... + def wait_until_complete( + self, *, job: Job | str, polling_interval_secs: int = 5, timeout_secs: int | None = None + ) -> Job: + """Wait until the job is complete or the timeout is reached. + + Polls the job status at the given interval until the job is FINISHED, + FAILED, or CANCELLED, returning the completed Job + + Args: + job: The Job or job_id to wait for. + polling_interval_secs: Seconds between status polls. Defaults to 5s. + timeout_secs: Maximum seconds to wait. If None, polls indefinitely. + Defaults to None (indefinite). + + Returns: + The Job in the completed state. + """ + ... + class PingAPI: """Sync counterpart to `PingAPIAsync`. @@ -787,11 +806,11 @@ class ReportsAPI: """Archive a report.""" ... - def cancel(self, *, report: str | Report) -> None: + def cancel(self, *, report: str | Report | PendingReport) -> None: """Cancel a report. Args: - report: The Report or report ID to cancel. + report: The Report, PendingReport, or report ID to cancel. """ ... @@ -803,7 +822,7 @@ class ReportsAPI: name: str | None = None, start_time: datetime | None = None, end_time: datetime | None = None, - ) -> Report | None: + ) -> PendingReport | None: """Create a new report from applicable rules based on a run. If you want to evaluate against assets, use the rules client instead since no report is created in that case. @@ -815,7 +834,7 @@ class ReportsAPI: end_time: Optional end time to evaluate rules against. Returns: - The created Report or None if no report was created. + The PendingReport or None if no report was created. """ ... @@ -826,7 +845,7 @@ class ReportsAPI: run: Run | str | None = None, organization_id: str | None = None, rules: list[Rule] | list[str], - ) -> Report | None: + ) -> PendingReport | None: """Create a new report from rules. Args: @@ -836,7 +855,7 @@ class ReportsAPI: rules: List of rules or rule IDs to include in the report. Returns: - The created Report or None if no report was created. + The PendingReport or None if no report was created. """ ... @@ -847,7 +866,7 @@ class ReportsAPI: run_id: str, organization_id: str | None = None, name: str | None = None, - ) -> Report | None: + ) -> PendingReport | None: """Create a new report from a report template. Args: @@ -857,7 +876,7 @@ class ReportsAPI: name: Optional name for the report. Returns: - The created Report or None if no report was created. + The PendingReport or None if no report was created. """ ... @@ -939,14 +958,14 @@ class ReportsAPI: """ ... - def rerun(self, *, report: str | Report) -> tuple[str, str]: + def rerun(self, *, report: str | Report | PendingReport) -> PendingReport: """Rerun a report. Args: - report: The Report or report ID to rerun. + report: The Report, PendingReport, or report ID to rerun. Returns: - A tuple of (job_id, new_report_id). + A PendingReport for the new report run. """ ... @@ -963,6 +982,29 @@ class ReportsAPI: """ ... + def wait_until_complete( + self, + *, + report: Report | PendingReport, + polling_interval_secs: int = 5, + timeout_secs: int | None = None, + ) -> Report: + """Wait until the report is complete or the timeout is reached. + + Polls the report job status at the given interval until the job is FINISHED, + FAILED, or CANCELLED, returning the completed Report. + + Args: + report: The Report or PendingReport to wait for. + polling_interval_secs: Seconds between status polls. Defaults to 5s. + timeout_secs: Maximum seconds to wait. If None, polls indefinitely. + Defaults to None (indefinite). + + Returns: + The Report in the completed state. + """ + ... + class RulesAPI: """Sync counterpart to `RulesAPIAsync`. diff --git a/python/lib/sift_py/ingestion/_internal/ingest.py b/python/lib/sift_py/ingestion/_internal/ingest.py index b086e1889..1da6268c4 100644 --- a/python/lib/sift_py/ingestion/_internal/ingest.py +++ b/python/lib/sift_py/ingestion/_internal/ingest.py @@ -520,11 +520,11 @@ def _update_flow_configs( # We can have multiple channels of the same name but different data-type. This will create a completely unique channel # identifier by creating a composite key of the fully qualified channel name with the channel's data-type. - sift_channel_identifier: Callable[[ChannelConfigPb], str] = ( - lambda x: f"{channel_fqn(x)}.{x.data_type}" + sift_channel_identifier: Callable[[ChannelConfigPb], str] = lambda x: ( + f"{channel_fqn(x)}.{x.data_type}" ) - config_channel_identifier: Callable[[ChannelConfig], str] = ( - lambda x: f"{channel_fqn(x)}.{x.data_type.value}" + config_channel_identifier: Callable[[ChannelConfig], str] = lambda x: ( + f"{channel_fqn(x)}.{x.data_type.value}" ) for config_flow in config_flows: From a8582b7e5761d8f7e540538d0364b08e6fccd7ac Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Wed, 18 Feb 2026 18:32:15 -0800 Subject: [PATCH 06/10] Fix stub and mypy --- python/lib/sift_client/resources/rules.py | 2 +- .../resources/sync_stubs/__init__.pyi | 136 +++++++++++------- 2 files changed, 82 insertions(+), 56 deletions(-) diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index d70a9a020..ef6f9ca7a 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -353,7 +353,7 @@ async def list_rule_versions( A list of RuleVersion objects matching the filters, ordered by newest versions first. """ if isinstance(rule, Rule): - rule_id = rule.resource_id + rule_id = rule._id_or_error else: rule_id = rule diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 3d2f41c46..bde585e9e 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -27,8 +27,8 @@ if TYPE_CHECKING: RemoteFileEntityType, ) from sift_client.sift_types.job import Job, JobStatus, JobType - from sift_client.sift_types.report import PendingReport, Report, ReportUpdate - from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate + from sift_client.sift_types.report import Report, ReportUpdate + from sift_client.sift_types.rule import Rule, RuleCreate, RuleUpdate, RuleVersion from sift_client.sift_types.run import Run, RunCreate, RunUpdate from sift_client.sift_types.tag import Tag, TagUpdate from sift_client.sift_types.test_report import ( @@ -745,25 +745,6 @@ class JobsAPI: """ ... - def wait_until_complete( - self, *, job: Job | str, polling_interval_secs: int = 5, timeout_secs: int | None = None - ) -> Job: - """Wait until the job is complete or the timeout is reached. - - Polls the job status at the given interval until the job is FINISHED, - FAILED, or CANCELLED, returning the completed Job - - Args: - job: The Job or job_id to wait for. - polling_interval_secs: Seconds between status polls. Defaults to 5s. - timeout_secs: Maximum seconds to wait. If None, polls indefinitely. - Defaults to None (indefinite). - - Returns: - The Job in the completed state. - """ - ... - class PingAPI: """Sync counterpart to `PingAPIAsync`. @@ -806,11 +787,11 @@ class ReportsAPI: """Archive a report.""" ... - def cancel(self, *, report: str | Report | PendingReport) -> None: + def cancel(self, *, report: str | Report) -> None: """Cancel a report. Args: - report: The Report, PendingReport, or report ID to cancel. + report: The Report or report ID to cancel. """ ... @@ -822,7 +803,7 @@ class ReportsAPI: name: str | None = None, start_time: datetime | None = None, end_time: datetime | None = None, - ) -> PendingReport | None: + ) -> Report | None: """Create a new report from applicable rules based on a run. If you want to evaluate against assets, use the rules client instead since no report is created in that case. @@ -834,7 +815,28 @@ class ReportsAPI: end_time: Optional end time to evaluate rules against. Returns: - The PendingReport or None if no report was created. + The created Report or None if no report was created. + """ + ... + + def create_from_rule_versions( + self, + *, + name: str, + run: Run | str | None = None, + organization_id: str | None = None, + rule_versions: list[RuleVersion] | list[str], + ) -> Report | None: + """Create a new report from rule versions. + + Args: + name: The name of the report. + run: The run or run ID to associate with the report. + organization_id: The organization ID. + rule_versions: List of RuleVersions or rule_version IDs to include in the report. + + Returns: + The created Report or None if no report was created. """ ... @@ -845,7 +847,7 @@ class ReportsAPI: run: Run | str | None = None, organization_id: str | None = None, rules: list[Rule] | list[str], - ) -> PendingReport | None: + ) -> Report | None: """Create a new report from rules. Args: @@ -855,7 +857,7 @@ class ReportsAPI: rules: List of rules or rule IDs to include in the report. Returns: - The PendingReport or None if no report was created. + The created Report or None if no report was created. """ ... @@ -866,7 +868,7 @@ class ReportsAPI: run_id: str, organization_id: str | None = None, name: str | None = None, - ) -> PendingReport | None: + ) -> Report | None: """Create a new report from a report template. Args: @@ -876,7 +878,7 @@ class ReportsAPI: name: Optional name for the report. Returns: - The PendingReport or None if no report was created. + The created Report or None if no report was created. """ ... @@ -958,14 +960,14 @@ class ReportsAPI: """ ... - def rerun(self, *, report: str | Report | PendingReport) -> PendingReport: + def rerun(self, *, report: str | Report) -> tuple[str, str]: """Rerun a report. Args: - report: The Report, PendingReport, or report ID to rerun. + report: The Report or report ID to rerun. Returns: - A PendingReport for the new report run. + A tuple of (job_id, new_report_id). """ ... @@ -982,29 +984,6 @@ class ReportsAPI: """ ... - def wait_until_complete( - self, - *, - report: Report | PendingReport, - polling_interval_secs: int = 5, - timeout_secs: int | None = None, - ) -> Report: - """Wait until the report is complete or the timeout is reached. - - Polls the report job status at the given interval until the job is FINISHED, - FAILED, or CANCELLED, returning the completed Report. - - Args: - report: The Report or PendingReport to wait for. - polling_interval_secs: Seconds between status polls. Defaults to 5s. - timeout_secs: Maximum seconds to wait. If None, polls indefinitely. - Defaults to None (indefinite). - - Returns: - The Report in the completed state. - """ - ... - class RulesAPI: """Sync counterpart to `RulesAPIAsync`. @@ -1037,6 +1016,17 @@ class RulesAPI: """ ... + def batch_get_rule_versions(self, rule_versions: list[RuleVersion] | list[str]) -> list[Rule]: + """Get multiple rules at specific versions by rule version IDs. + + Args: + rule_versions: List of RuleVersion instances or rule version IDs. + + Returns: + List of Rules at those versions. + """ + ... + def batch_update_or_create_rules( self, rules: Sequence[RuleCreate | RuleUpdate], @@ -1104,6 +1094,17 @@ class RulesAPI: """ ... + def get_rule_version(self, rule_version: RuleVersion | str) -> Rule: + """Get a rule at a specific version by rule version ID. + + Args: + rule_version: The RuleVersion instance or rule version ID. + + Returns: + The Rule at that version. + """ + ... + def list_( self, *, @@ -1157,6 +1158,31 @@ class RulesAPI: """ ... + def list_rule_versions( + self, + rule: Rule | str, + *, + version_notes_contains: str | None = None, + change_message_contains: str | None = None, + rule_version_ids: list[str] | None = None, + filter_query: str | None = None, + limit: int | None = None, + ) -> list[RuleVersion]: + """List versions of a rule with optional filtering. + + Args: + rule: The Rule instance or rule ID. + version_notes_contains: Filter by version notes (user_notes) containing this string. + change_message_contains: Filter by change message containing this string. + rule_version_ids: Limit to these rule version IDs. + filter_query: Raw CEL filter (fields: rule_version_id, user_notes, change_message). + limit: Maximum number of versions to return. If None, returns all matches. + + Returns: + A list of RuleVersion objects matching the filters, ordered by newest versions first. + """ + ... + def unarchive(self, rule: str | Rule) -> Rule: """Unarchive a rule. From 4cc47c800e1c6c969f8ed936ff79eba4e7f5d60b Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Wed, 18 Feb 2026 18:41:53 -0800 Subject: [PATCH 07/10] fix test --- .../_internal/low_level_wrappers/rules.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index 4461b4ebe..5e3794066 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -3,9 +3,18 @@ import logging from typing import TYPE_CHECKING, Any, Sequence, cast -from sift.common.type.v1.resource_identifier_pb2 import ResourceIdentifier, ResourceIdentifiers +from sift.common.type.v1.resource_identifier_pb2 import ( + NamedResources, + Names, + ResourceIdentifier, + ResourceIdentifiers, +) from sift.rule_evaluation.v1.rule_evaluation_pb2 import ( AssetsTimeRange, + EvaluateRulesAnnotationOptions, + EvaluateRulesFromCurrentRuleVersions, + EvaluateRulesFromReportTemplate, + EvaluateRulesFromRuleVersions, EvaluateRulesRequest, EvaluateRulesResponse, RunTimeRange, @@ -629,13 +638,22 @@ async def evaluate_rules( if all_applicable_rules: kwargs["all_applicable_rules"] = all_applicable_rules if rule_ids: - kwargs["rules"] = {"rules": ResourceIdentifiers(ids={"ids": rule_ids})} # type: ignore + kwargs["rules"] = EvaluateRulesFromCurrentRuleVersions( + rules=ResourceIdentifiers(ids={"ids": rule_ids}) # type: ignore[arg-type] + ) if rule_version_ids: - kwargs["rule_versions"] = rule_version_ids + kwargs["rule_versions"] = EvaluateRulesFromRuleVersions( + rule_version_ids=rule_version_ids + ) if report_template_id: - kwargs["report_template"] = report_template_id + kwargs["report_template"] = EvaluateRulesFromReportTemplate( + report_template=ResourceIdentifier(id=report_template_id) + ) if tags: - kwargs["tags"] = [tag.name if isinstance(tag, Tag) else tag for tag in tags] + tag_names = [tag.name if isinstance(tag, Tag) else tag for tag in tags] + kwargs["annotation_options"] = EvaluateRulesAnnotationOptions( + tags=NamedResources(names=Names(names=tag_names)) # type: ignore[arg-type] + ) if report_name: kwargs["report_name"] = report_name if organization_id: From eb5042eb205cceb2c278462ca97cff703c2cc4a0 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Thu, 19 Feb 2026 17:38:31 -0800 Subject: [PATCH 08/10] doc fix --- python/lib/sift_client/resources/rules.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index ef6f9ca7a..c88217305 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -343,8 +343,8 @@ async def list_rule_versions( Args: rule: The Rule instance or rule ID. - version_notes_contains: Filter by version notes (user_notes) containing this string. - change_message_contains: Filter by change message containing this string. + user_notes_contains: Filter by user notes (notes for a given version) containing this string. + change_message_contains: Filter by change messages containing this string. rule_version_ids: Limit to these rule version IDs. filter_query: Raw CEL filter (fields: rule_version_id, user_notes, change_message). limit: Maximum number of versions to return. If None, returns all matches. From 2690d8122afa3391338546c7ba12052c806e96c0 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Thu, 19 Feb 2026 17:41:20 -0800 Subject: [PATCH 09/10] fix nomenclature --- python/lib/sift_client/resources/rules.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index c88217305..08c5815e3 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -333,7 +333,7 @@ async def list_rule_versions( self, rule: Rule | str, *, - version_notes_contains: str | None = None, + user_notes_contains: str | None = None, change_message_contains: str | None = None, rule_version_ids: list[str] | None = None, filter_query: str | None = None, @@ -358,8 +358,8 @@ async def list_rule_versions( rule_id = rule filter_parts: list[str] = [] - if version_notes_contains: - filter_parts.append(cel.contains("user_notes", version_notes_contains)) + if user_notes_contains: + filter_parts.append(cel.contains("user_notes", user_notes_contains)) if change_message_contains: filter_parts.append(cel.contains("change_message", change_message_contains)) if rule_version_ids: From 549374e23064bdb94fa91551371c4aaa36719a66 Mon Sep 17 00:00:00 2001 From: Nathan Federknopp Date: Fri, 20 Feb 2026 11:10:37 -0800 Subject: [PATCH 10/10] add stub --- python/lib/sift_client/resources/sync_stubs/__init__.pyi | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index bde585e9e..0fe3a628f 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -1162,7 +1162,7 @@ class RulesAPI: self, rule: Rule | str, *, - version_notes_contains: str | None = None, + user_notes_contains: str | None = None, change_message_contains: str | None = None, rule_version_ids: list[str] | None = None, filter_query: str | None = None, @@ -1172,8 +1172,8 @@ class RulesAPI: Args: rule: The Rule instance or rule ID. - version_notes_contains: Filter by version notes (user_notes) containing this string. - change_message_contains: Filter by change message containing this string. + user_notes_contains: Filter by user notes (notes for a given version) containing this string. + change_message_contains: Filter by change messages containing this string. rule_version_ids: Limit to these rule version IDs. filter_query: Raw CEL filter (fields: rule_version_id, user_notes, change_message). limit: Maximum number of versions to return. If None, returns all matches.