From 402331a7799da6d9b8c933992d85356ca2b92689 Mon Sep 17 00:00:00 2001 From: Ian Later Date: Wed, 10 Sep 2025 18:23:43 -0700 Subject: [PATCH 1/2] python(feat): Add reports and tags types and clients + clean up rules as a result. --- .../_internal/low_level_wrappers/__init__.py | 2 + .../_internal/low_level_wrappers/reports.py | 268 ++++++++++++++++ .../_internal/low_level_wrappers/tags.py | 117 +++++++ .../sift_client/_tests/integrated/reports.py | 83 +++++ python/lib/sift_client/client.py | 15 +- python/lib/sift_client/resources/__init__.py | 8 + python/lib/sift_client/resources/reports.py | 236 ++++++++++++++ python/lib/sift_client/resources/rules.py | 17 +- python/lib/sift_client/resources/runs.py | 47 ++- .../resources/sync_stubs/__init__.py | 6 +- .../resources/sync_stubs/__init__.pyi | 293 ++++++++++++++++-- python/lib/sift_client/resources/tags.py | 128 ++++++++ python/lib/sift_client/sift_types/__init__.py | 5 + python/lib/sift_client/sift_types/report.py | 124 ++++++++ python/lib/sift_client/sift_types/tag.py | 56 ++++ python/lib/sift_client/util/cel_utils.py | 4 +- python/lib/sift_client/util/util.py | 8 + 17 files changed, 1385 insertions(+), 32 deletions(-) create mode 100644 python/lib/sift_client/_internal/low_level_wrappers/reports.py create mode 100644 python/lib/sift_client/_internal/low_level_wrappers/tags.py create mode 100644 python/lib/sift_client/_tests/integrated/reports.py create mode 100644 python/lib/sift_client/resources/reports.py create mode 100644 python/lib/sift_client/resources/tags.py create mode 100644 python/lib/sift_client/sift_types/report.py create mode 100644 python/lib/sift_client/sift_types/tag.py diff --git a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py index 6bdef7f17..2105116b0 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py @@ -5,6 +5,7 @@ from sift_client._internal.low_level_wrappers.channels import ChannelsLowLevelClient from sift_client._internal.low_level_wrappers.ingestion import IngestionLowLevelClient from sift_client._internal.low_level_wrappers.ping import PingLowLevelClient +from sift_client._internal.low_level_wrappers.reports import ReportsLowLevelClient from sift_client._internal.low_level_wrappers.rules import RulesLowLevelClient from sift_client._internal.low_level_wrappers.runs import RunsLowLevelClient @@ -14,6 +15,7 @@ "ChannelsLowLevelClient", "IngestionLowLevelClient", "PingLowLevelClient", + "ReportsLowLevelClient", "RulesLowLevelClient", "RunsLowLevelClient", ] diff --git a/python/lib/sift_client/_internal/low_level_wrappers/reports.py b/python/lib/sift_client/_internal/low_level_wrappers/reports.py new file mode 100644 index 000000000..63ed10207 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/reports.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from sift.reports.v1.reports_pb2 import ( + CancelReportRequest, + CreateReportFromReportTemplateRequest, + CreateReportFromRulesRequest, + CreateReportRequest, + CreateReportRequestClientKeys, + CreateReportRequestRuleIds, + CreateReportResponse, + GetReportRequest, + GetReportResponse, + ListReportsRequest, + ListReportsResponse, + RerunReportRequest, + RerunReportResponse, +) +from sift.reports.v1.reports_pb2_grpc import ReportServiceStub + +from sift_client._internal.low_level_wrappers.base import LowLevelClientBase +from sift_client.sift_types.report import Report +from sift_client.transport import WithGrpcClient + +if TYPE_CHECKING: + from sift_client.transport.grpc_transport import GrpcClient + +# Configure logging +logger = logging.getLogger(__name__) + + +class ReportsLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the ReportsAPI. + + This class provides a thin wrapper around the autogenerated bindings for the ReportsAPI. + """ + + def __init__(self, grpc_client: GrpcClient): + """Initialize the ReportsLowLevelClient. + + Args: + grpc_client: The gRPC client to use for making API calls. + """ + super().__init__(grpc_client) + + async def get_report(self, report_id: str) -> Report: + """Get a report by report_id. + + Args: + report_id: The report ID to get. + + Returns: + The Report. + + Raises: + ValueError: If report_id is not provided. + """ + if not report_id: + raise ValueError("report_id must be provided") + + request = GetReportRequest(report_id=report_id) + response = await self._grpc_client.get_stub(ReportServiceStub).GetReport(request) + grpc_report = cast("GetReportResponse", response).report + return Report._from_proto(grpc_report) + + async def list_reports( + self, + *, + page_size: int | None = None, + page_token: str | None = None, + query_filter: str | None = None, + organization_id: str | None = None, + order_by: str | None = None, + ) -> tuple[list[Report], str]: + """List reports with optional filtering and pagination. + + Args: + page_size: The maximum number of reports to return. + page_token: A page token for pagination. + query_filter: A CEL filter string. + organization_id: The organization ID to filter by. + order_by: How to order the retrieved reports. + + Returns: + A tuple of (reports, 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 organization_id is not None: + request_kwargs["organization_id"] = organization_id + if order_by is not None: + request_kwargs["order_by"] = order_by + + request = ListReportsRequest(**request_kwargs) + response = await self._grpc_client.get_stub(ReportServiceStub).ListReports(request) + response = cast("ListReportsResponse", response) + + reports = [Report._from_proto(report) for report in response.reports] + return reports, response.next_page_token + + async def list_all_reports( + self, + *, + query_filter: str | None = None, + organization_id: str | None = None, + order_by: str | None = None, + max_results: int | None = None, + ) -> list[Report]: + """List all reports with optional filtering. + + Args: + query_filter: A CEL filter string. + organization_id: The organization ID to filter by. + order_by: How to order the retrieved reports. + max_results: Maximum number of results to return. + + Returns: + A list of all matching reports. + """ + return await self._handle_pagination( + self.list_reports, + kwargs={ + "query_filter": query_filter, + "organization_id": organization_id, + }, + order_by=order_by, + max_results=max_results, + ) + + async def create_report_from_template( + self, + *, + report_template_id: str, + run_id: str, + organization_id: str, + name: str | None = None, + ) -> Report: + """Create a new report from a report template. + + Args: + report_template_id: The ID of the report template to use. + run_id: The run ID to associate with the report. + organization_id: The organization ID. + name: Optional name for the report. + + Returns: + The created Report. + """ + template_request = CreateReportFromReportTemplateRequest( + report_template_id=report_template_id + ) + + request_kwargs: dict[str, Any] = { + "report_from_report_template_request": template_request, + "organization_id": organization_id, + "run_id": run_id, + } + + if name is not None: + request_kwargs["name"] = name + + request = CreateReportRequest(**request_kwargs) + response = await self._grpc_client.get_stub(ReportServiceStub).CreateReport(request) + grpc_report = cast("CreateReportResponse", response).report + return Report._from_proto(grpc_report) + + async def create_report_from_rules( + self, + *, + name: str, + description: str | None = None, + tag_names: list[str] | None = None, + rule_ids: list[str] | None = None, + rule_client_keys: list[str] | None = None, + run_id: str, + organization_id: str, + ) -> Report: + """Create a new report from rules. + + Args: + name: The name of the report. + description: Optional description of the report. + tag_names: List of tag names to associate with the report. + rule_ids: List of rule IDs to include in the report. + rule_client_keys: List of rule client keys to include in the report. + run_id: The run ID to associate with the report. + organization_id: The organization ID. + + Returns: + The created Report. + + Raises: + ValueError: If neither rule_ids nor rule_client_keys are provided. + """ + if not rule_ids and not rule_client_keys: + raise ValueError("Either rule_ids or rule_client_keys must be provided") + + rules_request_kwargs: dict[str, Any] = { + "name": name, + } + + if description is not None: + rules_request_kwargs["description"] = description + + if tag_names is not None: + rules_request_kwargs["tag_names"] = tag_names + + if rule_ids is not None: + rules_request_kwargs["rule_ids"] = CreateReportRequestRuleIds(rule_ids=rule_ids) + elif rule_client_keys is not None: + rules_request_kwargs["rule_client_keys"] = CreateReportRequestClientKeys( + rule_client_keys=rule_client_keys + ) + + rules_request = CreateReportFromRulesRequest(**rules_request_kwargs) + + request_kwargs: dict[str, Any] = { + "report_from_rules_request": rules_request, + "organization_id": organization_id, + "run_id": run_id, + } + + request = CreateReportRequest(**request_kwargs) + response = await self._grpc_client.get_stub(ReportServiceStub).CreateReport(request) + grpc_report = cast("CreateReportResponse", response).report + return Report._from_proto(grpc_report) + + async def rerun_report(self, report_id: str) -> tuple[str, str]: + """Rerun a report. + + Args: + report_id: The ID of the report to rerun. + + Returns: + A tuple of (job_id, new_report_id). + + Raises: + ValueError: If report_id is not provided. + """ + if not report_id: + raise ValueError("report_id must be provided") + + request = RerunReportRequest(report_id=report_id) + response = await self._grpc_client.get_stub(ReportServiceStub).RerunReport(request) + response = cast("RerunReportResponse", response) + return response.job_id, response.report_id + + async def cancel_report(self, report_id: str) -> None: + """Cancel a report. + + Args: + report_id: The ID of the report to cancel. + + Raises: + ValueError: If report_id is not provided. + """ + if not report_id: + raise ValueError("report_id must be provided") + + request = CancelReportRequest(report_id=report_id) + await self._grpc_client.get_stub(ReportServiceStub).CancelReport(request) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/tags.py b/python/lib/sift_client/_internal/low_level_wrappers/tags.py new file mode 100644 index 000000000..bb67df27e --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/tags.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from sift.tags.v2.tags_pb2 import ( + CreateTagRequest, + CreateTagResponse, + ListTagsRequest, + ListTagsResponse, +) +from sift.tags.v2.tags_pb2_grpc import TagServiceStub + +from sift_client._internal.low_level_wrappers.base import LowLevelClientBase +from sift_client.sift_types.tag import Tag, TagUpdate +from sift_client.transport import WithGrpcClient + +if TYPE_CHECKING: + from sift_client.transport.grpc_transport import GrpcClient + +# Configure logging +logger = logging.getLogger(__name__) + + +class TagsLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the TagsAPI. + + This class provides a thin wrapper around the autogenerated bindings for the TagsAPI. + """ + + def __init__(self, grpc_client: GrpcClient): + """Initialize the TagsLowLevelClient. + + Args: + grpc_client: The gRPC client to use for making API calls. + """ + super().__init__(grpc_client) + + async def create_tag(self, name: str) -> Tag: + """Create a new tag. + + Args: + name: The name of the tag. + + Returns: + The created Tag. + + Raises: + ValueError: If name is not provided. + """ + if not name: + raise ValueError("name must be provided") + + request = CreateTagRequest(name=name) + response = await self._grpc_client.get_stub(TagServiceStub).CreateTag(request) + grpc_tag = cast("CreateTagResponse", response).tag + return Tag._from_proto(grpc_tag) + + async def list_tags( + self, + *, + page_size: int | None = None, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + ) -> tuple[list[Tag], str]: + """List tags with optional filtering and pagination. + + Args: + page_size: The maximum number of tags to return. + page_token: A page token for pagination. + query_filter: A CEL filter string. + order_by: How to order the retrieved tags. + + Returns: + A tuple of (tags, 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 = ListTagsRequest(**request_kwargs) + response = await self._grpc_client.get_stub(TagServiceStub).ListTags(request) + response = cast("ListTagsResponse", response) + + tags = [Tag._from_proto(tag) for tag in response.tags] + return tags, response.next_page_token + + async def list_all_tags( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + max_results: int | None = None, + ) -> list[Tag]: + """List all tags with optional filtering. + + Args: + query_filter: A CEL filter string. + order_by: How to order the retrieved tags. + max_results: Maximum number of results to return. + + Returns: + A list of all matching tags. + """ + return await self._handle_pagination( + self.list_tags, + kwargs={"query_filter": query_filter}, + order_by=order_by, + max_results=max_results, + ) \ No newline at end of file diff --git a/python/lib/sift_client/_tests/integrated/reports.py b/python/lib/sift_client/_tests/integrated/reports.py new file mode 100644 index 000000000..d4be677ee --- /dev/null +++ b/python/lib/sift_client/_tests/integrated/reports.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""This test demonstrates the usage of the Runs API. + +It creates a new run, updates it, and associates assets with it. +It also lists runs, filters them, and deletes the run. + +It uses the SiftClient to interact with the API. +""" + +import asyncio +import os +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +from sift_client import SiftClient +from sift_client.sift_types import report + + +async def main(): + """Main function demonstrating the Runs API usage.""" + # Initialize the client + # You can set these environment variables or pass them directly + grpc_url = os.getenv("SIFT_GRPC_URI", "localhost:50051") + rest_url = os.getenv("SIFT_REST_URI", "localhost:8080") + api_key = os.getenv("SIFT_API_KEY", "") + client = SiftClient( + api_key=api_key, + grpc_url=grpc_url, + rest_url=rest_url, + ) + + runs = client.runs.list_( + created_date_start=datetime(2025, 9, 10, 9, 50, tzinfo=ZoneInfo("America/Los_Angeles")), + created_date_end=datetime(2025, 9, 10, 12, 50, tzinfo=ZoneInfo("America/Los_Angeles")), + limit=100, + ) + + asset_ids = [] + asset_tags_names = [] + rules = [] + reports = [] + for run in runs: + print("run.name: ", run.name) + print(" client_key: ", run.client_key) + if run.client_key: + # rules = client.rules.list_( + # client_key=run.client_key, + # limit=100, + # ) + raise Exception("client_key is not None! Let's add these rules") + run_assets = run.assets + print(" assets: ", [asset.name for asset in run_assets]) + asset_ids.extend([asset.id_ for asset in run_assets]) + asset_tags_names.extend([tag for asset in run_assets for tag in asset.tags]) + per_run_reports = client.reports.list_( + run_id=run.id_, + ) + print(" reports: ", [report.name for report in per_run_reports]) + reports.extend(per_run_reports) + + asset_ids = list(set(asset_ids)) + asset_tags_names = list(set(asset_tags_names)) + asset_tags = client.tags.list_( + names=asset_tags_names, + ) + print(" asset_tags: ", [(tag.name, tag.id_) for tag in asset_tags]) + print("Number of runs: ", len(runs)) + print("Number of assets: ", len(asset_ids)) + + rules = client.rules.list_( + asset_ids=asset_ids, + asset_tags_ids=[tag.id_ for tag in asset_tags], + ) + print("reports: ", [report.name for report in reports]) + if len(rules) < 10: + print("rules: ", [rule.name for rule in rules]) + else: + print("number of rules: ", len(rules)) + + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 427e4def5..d122a896a 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -11,10 +11,14 @@ IngestionAPIAsync, PingAPI, PingAPIAsync, + ReportsAPI, + ReportsAPIAsync, RulesAPI, RulesAPIAsync, RunsAPI, RunsAPIAsync, + TagsAPI, + TagsAPIAsync, ) from sift_client.transport import ( GrpcClient, @@ -80,12 +84,18 @@ class SiftClient( ingestion: IngestionAPIAsync """Instance of the Ingestion API for making synchronous requests.""" + reports: ReportsAPI + """Instance of the Reports API for making synchronous requests.""" + rules: RulesAPI """Instance of the Rules API for making synchronous requests.""" runs: RunsAPI """Instance of the Runs API for making synchronous requests.""" + tags: TagsAPI + """Instance of the Tags API for making synchronous requests.""" + async_: AsyncAPIs """Accessor for the asynchronous APIs. All asynchronous APIs are available as attributes on this accessor.""" @@ -130,7 +140,8 @@ def __init__( self.ingestion = IngestionAPIAsync(self) self.rules = RulesAPI(self) self.runs = RunsAPI(self) - + self.reports = ReportsAPI(self) + self.tags = TagsAPI(self) # Accessor for the asynchronous APIs self.async_ = AsyncAPIs( ping=PingAPIAsync(self), @@ -140,6 +151,8 @@ def __init__( ingestion=IngestionAPIAsync(self), rules=RulesAPIAsync(self), runs=RunsAPIAsync(self), + reports=ReportsAPIAsync(self), + tags=TagsAPIAsync(self), ) @property diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 5997acb02..5f22ecf6c 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -3,15 +3,19 @@ from sift_client.resources.channels import ChannelsAPIAsync from sift_client.resources.ingestion import IngestionAPIAsync from sift_client.resources.ping import PingAPIAsync +from sift_client.resources.reports import ReportsAPIAsync from sift_client.resources.rules import RulesAPIAsync from sift_client.resources.runs import RunsAPIAsync +from sift_client.resources.tags import TagsAPIAsync from sift_client.resources.sync_stubs import ( AssetsAPI, CalculatedChannelsAPI, ChannelsAPI, PingAPI, + ReportsAPI, RulesAPI, RunsAPI, + TagsAPI, ) __all__ = [ @@ -24,8 +28,12 @@ "IngestionAPIAsync", "PingAPI", "PingAPIAsync", + "ReportsAPI", + "ReportsAPIAsync", "RulesAPI", "RulesAPIAsync", "RunsAPI", "RunsAPIAsync", + "TagsAPI", + "TagsAPIAsync", ] diff --git a/python/lib/sift_client/resources/reports.py b/python/lib/sift_client/resources/reports.py new file mode 100644 index 000000000..521e0d4ad --- /dev/null +++ b/python/lib/sift_client/resources/reports.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.reports import ReportsLowLevelClient +from sift_client.resources._base import ResourceBase +from sift_client.sift_types.report import Report +from sift_client.util.cel_utils import contains, equals, equals_null, match, not_ + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class ReportsAPIAsync(ResourceBase): + """High-level API for interacting with reports. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ReportsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = ReportsLowLevelClient(grpc_client=self.client.grpc_client) + + async def get( + self, + *, + report_id: str, + ) -> Report: + """Get a Report. + + Args: + report_id: The ID of the report. + + Returns: + The Report. + """ + report = await self._low_level_client.get_report(report_id=report_id) + return self._apply_client_to_instance(report) + + async def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + description: str | None = None, + description_contains: str | None = None, + run_id: str | None = None, + organization_id: str | None = None, + created_by_user_id: str | None = None, + modified_by_user_id: str | None = None, + report_template_id: str | None = None, + tag_name: str | None = None, + order_by: str | None = None, + limit: int | None = None, + ) -> list[Report]: + """List reports with optional filtering. + + Args: + name: Exact name of the report. + name_contains: Partial name of the report. + name_regex: Regular expression string to filter reports by name. + description: Exact description of the report. + description_contains: Partial description of the report. + run_id: Run ID to filter by. + organization_id: Organization ID to filter by. + created_by_user_id: User ID who created the report. + modified_by_user_id: User ID who modified the report. + report_template_id: Report template ID to filter by. + tag_name: Tag name to filter by. + order_by: How to order the retrieved reports. + limit: How many reports to retrieve. If None, retrieves all matches. + + Returns: + A list of Reports that matches the filter. + """ + # Build CEL filter + filter_parts = [] + + if name: + filter_parts.append(equals("name", name)) + elif name_contains: + filter_parts.append(contains("name", name_contains)) + elif name_regex: + if isinstance(name_regex, re.Pattern): + name_regex = name_regex.pattern + filter_parts.append(match("name", name_regex)) # type: ignore + + if description: + filter_parts.append(equals("description", description)) + elif description_contains: + filter_parts.append(contains("description", description_contains)) + + if run_id: + filter_parts.append(equals("run_id", run_id)) + + if organization_id: + filter_parts.append(equals("organization_id", organization_id)) + + if created_by_user_id: + filter_parts.append(equals("created_by_user_id", created_by_user_id)) + + if modified_by_user_id: + filter_parts.append(equals("modified_by_user_id", modified_by_user_id)) + + if report_template_id: + filter_parts.append(equals("report_template_id", report_template_id)) + + if tag_name: + filter_parts.append(contains("tags", tag_name)) + + query_filter = " && ".join(filter_parts) if filter_parts else None + + reports = await self._low_level_client.list_all_reports( + query_filter=query_filter, + organization_id=organization_id, + order_by=order_by, + max_results=limit, + ) + return self._apply_client_to_instances(reports) + + async def find(self, **kwargs) -> Report | None: + """Find a single report matching the given query. Takes the same arguments as `list`. If more than one report is found, + raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list`. + + Returns: + The Report found or None. + """ + reports = await self.list_(**kwargs) + if len(reports) > 1: + raise ValueError("Multiple reports found for query") + elif len(reports) == 1: + return reports[0] + return None + + async def create_from_template( + self, + report_template_id: str, + run_id: str, + organization_id: str, + name: str | None = None, + ) -> Report: + """Create a new report from a report template. + + Args: + report_template_id: The ID of the report template to use. + run_id: The run ID to associate with the report. + organization_id: The organization ID. + name: Optional name for the report. + + Returns: + The created Report. + """ + created_report = await self._low_level_client.create_report_from_template( + report_template_id=report_template_id, + run_id=run_id, + organization_id=organization_id, + name=name, + ) + return self._apply_client_to_instance(created_report) + + async def create_from_rules( + self, + name: str, + run_id: str, + organization_id: str, + description: str | None = None, + tag_names: list[str] | None = None, + rule_ids: list[str] | None = None, + rule_client_keys: list[str] | None = None, + ) -> Report: + """Create a new report from rules. + + Args: + name: The name of the report. + run_id: The run ID to associate with the report. + organization_id: The organization ID. + description: Optional description of the report. + tag_names: List of tag names to associate with the report. + rule_ids: List of rule IDs to include in the report. + rule_client_keys: List of rule client keys to include in the report. + + Returns: + The created Report. + """ + created_report = await self._low_level_client.create_report_from_rules( + name=name, + description=description, + tag_names=tag_names, + rule_ids=rule_ids, + rule_client_keys=rule_client_keys, + run_id=run_id, + organization_id=organization_id, + ) + return self._apply_client_to_instance(created_report) + + + async def rerun( + self, + *, + report: str | Report, + ) -> tuple[str, str]: + """Rerun a report. + + Args: + report: The Report or report ID to rerun. + + Returns: + A tuple of (job_id, new_report_id). + """ + report_id = report.id_ if isinstance(report, Report) else report + if not isinstance(report_id, str): + raise TypeError(f"report_id must be a string not {type(report_id)}") + return await self._low_level_client.rerun_report(report_id=report_id) + + async def cancel( + self, + *, + report: str | Report, + ) -> None: + """Cancel a report. + + Args: + report: The Report or report ID to cancel. + """ + report_id = report.id_ if isinstance(report, Report) else report + if not isinstance(report_id, str): + raise TypeError(f"report_id must be a string not {type(report_id)}") + await self._low_level_client.cancel_report(report_id=report_id) \ No newline at end of file diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index a101a3ae5..ddd28d48d 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -57,6 +57,9 @@ async def list_( name: str | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + asset_ids: list[str] | None = None, + asset_tags_ids: list[str] | None = None, + client_key: str | None = None, order_by: str | None = None, limit: int | None = None, include_deleted: bool = False, @@ -67,6 +70,9 @@ async def list_( name: Exact name of the rule. name_contains: Partial name of the rule. name_regex: Regular expression string to filter rules by name. + asset_ids: List of asset IDs to filter rules by. + asset_tags_ids: List of asset tags IDs to filter rules by. + client_key: The client key of the rules. order_by: How to order the retrieved rules. limit: How many rules to retrieve. If None, retrieves all matches. include_deleted: Include deleted rules. @@ -84,9 +90,16 @@ async def list_( filters.append(cel.contains("name", name_contains)) if name_regex: filters.append(cel.match("name", name_regex)) + if asset_ids: + filters.append(cel.in_("asset_id", asset_ids)) + if asset_tags_ids: + filters.append(cel.in_("tag_id", asset_tags_ids)) + if client_key: + filters.append(cel.equals("client_key", client_key)) + # We mostly want to OR these filters except for the deleted_date filter + filter_str = " || ".join(filters) if filters else "" if not include_deleted: - filters.append(cel.equals_null("deleted_date")) - filter_str = " && ".join(filters) if filters else "" + filter_str = f"({filter_str}) && {cel.equals_null('deleted_date')}" rules = await self._low_level_client.list_all_rules( filter_query=filter_str, order_by=order_by, diff --git a/python/lib/sift_client/resources/runs.py b/python/lib/sift_client/resources/runs.py index abb324f89..9ec8a50be 100644 --- a/python/lib/sift_client/resources/runs.py +++ b/python/lib/sift_client/resources/runs.py @@ -6,7 +6,7 @@ from sift_client._internal.low_level_wrappers.runs import RunsLowLevelClient from sift_client.resources._base import ResourceBase from sift_client.sift_types.run import Run, RunUpdate -from sift_client.util.cel_utils import contains, equals, equals_null, match, not_ +from sift_client.util.cel_utils import contains, equals, equals_null, greater_than, less_than, match, not_ if TYPE_CHECKING: from datetime import datetime @@ -63,7 +63,16 @@ async def list_( asset_name: str | None = None, created_by_user_id: str | None = None, is_stopped: bool | None = None, + created_date_start: datetime | None = None, + created_date_end: datetime | None = None, + modified_date_start: datetime | None = None, + modified_date_end: datetime | None = None, + start_time_start: datetime | None = None, + start_time_end: datetime | None = None, + stop_time_start: datetime | None = None, + stop_time_end: datetime | None = None, include_archived: bool = False, + organization_id: str | None = None, order_by: str | None = None, limit: int | None = None, ) -> list[Run]: @@ -81,7 +90,16 @@ async def list_( asset_name: Asset name to filter by. created_by_user_id: User ID who created the run. is_stopped: Whether the run is stopped. + created_date_start: Start date for created_date filter. + created_date_end: End date for created_date filter. + modified_date_start: Start date for modified_date filter. + modified_date_end: End date for modified_date filter. + start_time_start: Start date for start_time filter. + start_time_end: End date for start_time filter. + stop_time_start: Start date for stop_time filter. + stop_time_end: End date for stop_time filter. include_archived: Whether to include archived runs. + organization_id: Organization ID to filter by. order_by: How to order the retrieved runs. limit: How many runs to retrieve. If None, retrieves all matches. @@ -126,6 +144,33 @@ async def list_( if not include_archived: filter_parts.append(equals("archived_date", None)) + if created_date_start: + filter_parts.append(greater_than("created_date", created_date_start)) + + if created_date_end: + filter_parts.append(less_than("created_date", created_date_end)) + + if modified_date_start: + filter_parts.append(greater_than("modified_date", modified_date_start)) + + if modified_date_end: + filter_parts.append(less_than("modified_date", modified_date_end)) + + if start_time_start: + filter_parts.append(greater_than("start_time", start_time_start)) + + if start_time_end: + filter_parts.append(less_than("start_time", start_time_end)) + + if stop_time_start: + filter_parts.append(greater_than("stop_time", stop_time_start)) + + if stop_time_end: + filter_parts.append(less_than("stop_time", stop_time_end)) + + if organization_id: + filter_parts.append(equals("organization_id", organization_id)) + query_filter = " && ".join(filter_parts) if filter_parts else None runs = await self._low_level_client.list_all_runs( diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 7246389a4..dca41435b 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -8,8 +8,10 @@ CalculatedChannelsAPIAsync, ChannelsAPIAsync, PingAPIAsync, + ReportsAPIAsync, RulesAPIAsync, RunsAPIAsync, + TagsAPIAsync, ) PingAPI = generate_sync_api(PingAPIAsync, "PingAPI") @@ -18,5 +20,7 @@ ChannelsAPI = generate_sync_api(ChannelsAPIAsync, "ChannelsAPI") RulesAPI = generate_sync_api(RulesAPIAsync, "RulesAPI") RunsAPI = generate_sync_api(RunsAPIAsync, "RunsAPI") +ReportsAPI = generate_sync_api(ReportsAPIAsync, "ReportsAPI") +TagsAPI = generate_sync_api(TagsAPIAsync, "TagsAPI") -__all__ = ["AssetsAPI", "CalculatedChannelsAPI", "PingAPI", "RunsAPI"] +__all__ = ["AssetsAPI", "CalculatedChannelsAPI", "PingAPI", "ReportsAPI", "RunsAPI", "TagsAPI"] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 0c52d3b15..a35c9d82b 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -13,20 +13,21 @@ from sift_client.client import SiftClient from sift_client.sift_types.asset import Asset, AssetUpdate from sift_client.sift_types.calculated_channel import CalculatedChannel, CalculatedChannelUpdate from sift_client.sift_types.channel import Channel, ChannelReference +from sift_client.sift_types.report import Report from sift_client.sift_types.rule import Rule, RuleAction, RuleUpdate from sift_client.sift_types.run import Run, RunUpdate +from sift_client.sift_types.tag import Tag, TagUpdate class AssetsAPI: """Sync counterpart to `AssetsAPIAsync`. High-level API for interacting with assets. - This class provides a Pythonic, notebook-friendly interface for interacting with the AssetsAPI. - It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. - - All methods in this class use the Asset class from the low-level wrapper, which is a user-friendly - representation of an asset using standard Python data structures and types. + This class provides a Pythonic, notebook-friendly interface for interacting with the AssetsAPI. + It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. + All methods in this class use the Asset class from the low-level wrapper, which is a user-friendly + representation of an asset using standard Python data structures and types. """ def __init__(self, sift_client: SiftClient): @@ -139,12 +140,11 @@ class CalculatedChannelsAPI: High-level API for interacting with calculated channels. - This class provides a Pythonic, notebook-friendly interface for interacting with the CalculatedChannelsAPI. - It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. - - All methods in this class use the CalculatedChannel class from the low-level wrapper, which is a user-friendly - representation of a calculated channel using standard Python data structures and types. + This class provides a Pythonic, notebook-friendly interface for interacting with the CalculatedChannelsAPI. + It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. + All methods in this class use the CalculatedChannel class from the low-level wrapper, which is a user-friendly + representation of a calculated channel using standard Python data structures and types. """ def __init__(self, sift_client: SiftClient): @@ -351,12 +351,11 @@ class ChannelsAPI: High-level API for interacting with channels. - This class provides a Pythonic, notebook-friendly interface for interacting with the ChannelsAPI. - It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. - - All methods in this class use the Channel class from the low-level wrapper, which is a user-friendly - representation of a channel using standard Python data structures and types. + This class provides a Pythonic, notebook-friendly interface for interacting with the ChannelsAPI. + It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. + All methods in this class use the Channel class from the low-level wrapper, which is a user-friendly + representation of a channel using standard Python data structures and types. """ def __init__(self, sift_client: SiftClient): @@ -491,17 +490,155 @@ class PingAPI: """ ... +class ReportsAPI: + """Sync counterpart to `ReportsAPIAsync`. + + High-level API for interacting with reports. + + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the ReportsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def cancel(self, *, report: str | Report) -> None: + """Cancel a report. + + Args: + report: The Report or report ID to cancel. + """ + ... + + def create_from_rules( + self, + name: str, + run_id: str, + organization_id: str, + description: str | None = None, + tag_names: list[str] | None = None, + rule_ids: list[str] | None = None, + rule_client_keys: list[str] | None = None, + ) -> Report: + """Create a new report from rules. + + Args: + name: The name of the report. + run_id: The run ID to associate with the report. + organization_id: The organization ID. + description: Optional description of the report. + tag_names: List of tag names to associate with the report. + rule_ids: List of rule IDs to include in the report. + rule_client_keys: List of rule client keys to include in the report. + + Returns: + The created Report. + """ + ... + + def create_from_template( + self, report_template_id: str, run_id: str, organization_id: str, name: str | None = None + ) -> Report: + """Create a new report from a report template. + + Args: + report_template_id: The ID of the report template to use. + run_id: The run ID to associate with the report. + organization_id: The organization ID. + name: Optional name for the report. + + Returns: + The created Report. + """ + ... + + def find(self, **kwargs) -> Report | None: + """Find a single report matching the given query. Takes the same arguments as `list`. If more than one report is found, + raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list`. + + Returns: + The Report found or None. + """ + ... + + def get(self, *, report_id: str) -> Report: + """Get a Report. + + Args: + report_id: The ID of the report. + + Returns: + The Report. + """ + ... + + def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + description: str | None = None, + description_contains: str | None = None, + run_id: str | None = None, + organization_id: str | None = None, + created_by_user_id: str | None = None, + modified_by_user_id: str | None = None, + report_template_id: str | None = None, + tag_name: str | None = None, + order_by: str | None = None, + limit: int | None = None, + ) -> list[Report]: + """List reports with optional filtering. + + Args: + name: Exact name of the report. + name_contains: Partial name of the report. + name_regex: Regular expression string to filter reports by name. + description: Exact description of the report. + description_contains: Partial description of the report. + run_id: Run ID to filter by. + organization_id: Organization ID to filter by. + created_by_user_id: User ID who created the report. + modified_by_user_id: User ID who modified the report. + report_template_id: Report template ID to filter by. + tag_name: Tag name to filter by. + order_by: How to order the retrieved reports. + limit: How many reports to retrieve. If None, retrieves all matches. + + Returns: + A list of Reports that matches the filter. + """ + ... + + def rerun(self, *, report: str | Report) -> tuple[str, str]: + """Rerun a report. + + Args: + report: The Report or report ID to rerun. + + Returns: + A tuple of (job_id, new_report_id). + """ + ... + class RulesAPI: """Sync counterpart to `RulesAPIAsync`. High-level API for interacting with rules. - This class provides a Pythonic, notebook-friendly interface for interacting with the RulesAPI. - It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. - - All methods in this class use the Rule class from the low-level wrapper, which is a user-friendly - representation of a rule using standard Python data structures and types. + This class provides a Pythonic, notebook-friendly interface for interacting with the RulesAPI. + It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. + All methods in this class use the Rule class from the low-level wrapper, which is a user-friendly + representation of a rule using standard Python data structures and types. """ def __init__(self, sift_client: SiftClient): @@ -602,6 +739,9 @@ class RulesAPI: name: str | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + asset_ids: list[str] | None = None, + asset_tags: list[str] | None = None, + client_key: str | None = None, order_by: str | None = None, limit: int | None = None, include_deleted: bool = False, @@ -612,6 +752,9 @@ class RulesAPI: name: Exact name of the rule. name_contains: Partial name of the rule. name_regex: Regular expression string to filter rules by name. + asset_ids: List of asset IDs to filter rules by. + asset_tags: List of asset tags to filter rules by. + client_key: The client key of the rules. order_by: How to order the retrieved rules. limit: How many rules to retrieve. If None, retrieves all matches. include_deleted: Include deleted rules. @@ -656,12 +799,11 @@ class RunsAPI: High-level API for interacting with runs. - This class provides a Pythonic, notebook-friendly interface for interacting with the RunsAPI. - It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. - - All methods in this class use the Run class from the low-level wrapper, which is a user-friendly - representation of a run using standard Python data structures and types. + This class provides a Pythonic, notebook-friendly interface for interacting with the RunsAPI. + It handles automatic handling of gRPC services, seamless type conversion, and clear error handling. + All methods in this class use the Run class from the low-level wrapper, which is a user-friendly + representation of a run using standard Python data structures and types. """ def __init__(self, sift_client: SiftClient): @@ -757,7 +899,16 @@ class RunsAPI: asset_name: str | None = None, created_by_user_id: str | None = None, is_stopped: bool | None = None, + created_date_start: datetime | None = None, + created_date_end: datetime | None = None, + modified_date_start: datetime | None = None, + modified_date_end: datetime | None = None, + start_time_start: datetime | None = None, + start_time_end: datetime | None = None, + stop_time_start: datetime | None = None, + stop_time_end: datetime | None = None, include_archived: bool = False, + organization_id: str | None = None, order_by: str | None = None, limit: int | None = None, ) -> list[Run]: @@ -775,7 +926,16 @@ class RunsAPI: asset_name: Asset name to filter by. created_by_user_id: User ID who created the run. is_stopped: Whether the run is stopped. + created_date_start: Start date for created_date filter. + created_date_end: End date for created_date filter. + modified_date_start: Start date for modified_date filter. + modified_date_end: End date for modified_date filter. + start_time_start: Start date for start_time filter. + start_time_end: End date for start_time filter. + stop_time_start: Start date for stop_time filter. + stop_time_end: End date for stop_time filter. include_archived: Whether to include archived runs. + organization_id: Organization ID to filter by. order_by: How to order the retrieved runs. limit: How many runs to retrieve. If None, retrieves all matches. @@ -811,3 +971,86 @@ class RunsAPI: The updated Run. """ ... + +class TagsAPI: + """Sync counterpart to `TagsAPIAsync`. + + High-level API for interacting with tags. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the TagsAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def create(self, name: str) -> Tag: + """Create a new tag. + + Args: + name: The name of the tag. + + Returns: + The created Tag. + """ + ... + + def find(self, **kwargs) -> Tag | None: + """Find a single tag matching the given query. Takes the same arguments as `list`. If more than one tag is found, + raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list`. + + Returns: + The Tag found or None. + """ + ... + + def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + names: list[str] | None = None, + tag_ids: list[str] | None = None, + created_by_user_id: str | None = None, + order_by: str | None = None, + limit: int | None = None, + ) -> list[Tag]: + """List tags with optional filtering. + + Args: + name: Exact name of the tag. + name_contains: Partial name of the tag. + name_regex: Regular expression string to filter tags by name. + names: List of tag names to filter by. + tag_ids: List of tag IDs to filter by. + created_by_user_id: User ID who created the tag. + order_by: How to order the retrieved tags. + limit: How many tags to retrieve. If None, retrieves all matches. + + Returns: + A list of Tags that matches the filter. + """ + ... + + def update(self, tag: str | Tag, update: TagUpdate | dict) -> Tag: + """Update a Tag. + + Args: + tag: The Tag or tag ID to update. + update: Updates to apply to the Tag. + + Returns: + The updated Tag. + + Note: + The tags API doesn't have an update method in the proto, + so this would need to be implemented if the API supports it. + """ + ... diff --git a/python/lib/sift_client/resources/tags.py b/python/lib/sift_client/resources/tags.py new file mode 100644 index 000000000..e549e2b4c --- /dev/null +++ b/python/lib/sift_client/resources/tags.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.tags import TagsLowLevelClient +from sift_client.resources._base import ResourceBase +from sift_client.sift_types.tag import Tag, TagUpdate +from sift_client.util.cel_utils import contains, equals, in_, match + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class TagsAPIAsync(ResourceBase): + """High-level API for interacting with tags.""" + + def __init__(self, sift_client: SiftClient): + """Initialize the TagsAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = TagsLowLevelClient(grpc_client=self.client.grpc_client) + + async def list_( + self, + *, + name: str | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + names: list[str] | None = None, + tag_ids: list[str] | None = None, + created_by_user_id: str | None = None, + order_by: str | None = None, + limit: int | None = None, + ) -> list[Tag]: + """List tags with optional filtering. + + Args: + name: Exact name of the tag. + name_contains: Partial name of the tag. + name_regex: Regular expression string to filter tags by name. + names: List of tag names to filter by. + tag_ids: List of tag IDs to filter by. + created_by_user_id: User ID who created the tag. + order_by: How to order the retrieved tags. + limit: How many tags to retrieve. If None, retrieves all matches. + + Returns: + A list of Tags that matches the filter. + """ + # Build CEL filter + filter_parts = [] + + if name: + filter_parts.append(equals("name", name)) + elif name_contains: + filter_parts.append(contains("name", name_contains)) + elif name_regex: + if isinstance(name_regex, re.Pattern): + name_regex = name_regex.pattern + filter_parts.append(match("name", name_regex)) # type: ignore + + if names: + filter_parts.append(in_("name", names)) + if tag_ids: + filter_parts.append(in_("tag_id", tag_ids)) + + if created_by_user_id: + filter_parts.append(equals("created_by_user_id", created_by_user_id)) + + query_filter = " && ".join(filter_parts) if filter_parts else None + + tags = await self._low_level_client.list_all_tags( + query_filter=query_filter, + order_by=order_by, + max_results=limit, + ) + return self._apply_client_to_instances(tags) + + async def find(self, **kwargs) -> Tag | None: + """Find a single tag matching the given query. Takes the same arguments as `list`. If more than one tag is found, + raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list`. + + Returns: + The Tag found or None. + """ + tags = await self.list_(**kwargs) + if len(tags) > 1: + raise ValueError("Multiple tags found for query") + elif len(tags) == 1: + return tags[0] + return None + + async def create(self, name: str) -> Tag: + """Create a new tag. + + Args: + name: The name of the tag. + + Returns: + The created Tag. + """ + created_tag = await self._low_level_client.create_tag(name=name) + return self._apply_client_to_instance(created_tag) + + async def update(self, tag: str | Tag, update: TagUpdate | dict) -> Tag: + """Update a Tag. + + Args: + tag: The Tag or tag ID to update. + update: Updates to apply to the Tag. + + Returns: + The updated Tag. + + Note: + The tags API doesn't have an update method in the proto, + so this would need to be implemented if the API supports it. + """ + # Note: The tags API doesn't have an update method in the proto, + # so this would need to be implemented if the API supports it + raise NotImplementedError("Tag updates are not supported by the current API") \ No newline at end of file diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index 6a389fa51..a9a85c9d4 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -19,6 +19,8 @@ RuleVersion, ) from sift_client.sift_types.run import Run, RunUpdate +from sift_client.sift_types.report import Report +from sift_client.sift_types.tag import Tag, TagUpdate __all__ = [ "Asset", @@ -30,6 +32,7 @@ "ChannelDataType", "ChannelReference", "IngestionConfig", + "Report", "Rule", "RuleAction", "RuleActionType", @@ -38,4 +41,6 @@ "RuleVersion", "Run", "RunUpdate", + "Tag", + "TagUpdate", ] diff --git a/python/lib/sift_client/sift_types/report.py b/python/lib/sift_client/sift_types/report.py new file mode 100644 index 000000000..11f6f3f4f --- /dev/null +++ b/python/lib/sift_client/sift_types/report.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from pydantic import ConfigDict +from sift.reports.v1.reports_pb2 import Report as ReportProto +from sift.reports.v1.reports_pb2 import ReportRuleSummary as ReportRuleSummaryProto +from sift.reports.v1.reports_pb2 import ReportTag as ReportTagProto + +from sift_client.sift_types._base import BaseType + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class ReportRuleSummary(BaseType[ReportRuleSummaryProto, "ReportRuleSummary"]): + """ReportRuleSummary model representing a rule summary within a report.""" + rule_id: str + rule_client_key: str | None = None + rule_version_id: str + rule_version_number: int + report_rule_version_id: str + num_open: int + num_failed: int + num_passed: int + status: int + created_date: datetime + modified_date: datetime + asset_id: str + deleted_date: datetime | None = None + + @classmethod + def _from_proto(cls, proto: ReportRuleSummaryProto, sift_client: SiftClient | None = None) -> ReportRuleSummary: + return cls( + id_=proto.report_rule_version_id, + rule_id=proto.rule_id, + rule_client_key=proto.rule_client_key, + rule_version_id=proto.rule_version_id, + rule_version_number=proto.rule_version_number, + report_rule_version_id=proto.report_rule_version_id, + num_open=proto.num_open, + num_failed=proto.num_failed, + num_passed=proto.num_passed, + status=proto.status, + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), + asset_id=proto.asset_id, + deleted_date=proto.deleted_date.ToDatetime(tzinfo=timezone.utc) + if proto.HasField("deleted_date") + else None, + _client=sift_client, + ) + + def _to_proto(self) -> ReportRuleSummaryProto: + """Convert to protobuf message.""" + return ReportRuleSummaryProto( + rule_id=self.rule_id, + rule_client_key=self.rule_client_key, + rule_version_id=self.rule_version_id, + rule_version_number=self.rule_version_number, + report_rule_version_id=self.report_rule_version_id, + num_open=self.num_open, + num_failed=self.num_failed, + num_passed=self.num_passed, + status=self.status, + created_date=self.created_date, + modified_date=self.modified_date, + asset_id=self.asset_id, + deleted_date=self.deleted_date, + ) + + +class Report(BaseType[ReportProto, "Report"]): + """Report model representing a data analysis report.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + report_template_id: str + run_id: str + organization_id: str + name: str + description: str + created_by_user_id: str + modified_by_user_id: str + created_date: datetime + modified_date: datetime + summaries: list[ReportRuleSummary] + tags: list[str] + rerun_from_report_id: str + + @classmethod + def _from_proto(cls, proto: ReportProto, sift_client: SiftClient | None = None) -> Report: + return cls( + id_=proto.report_id, + report_template_id=proto.report_template_id, + run_id=proto.run_id, + organization_id=proto.organization_id, + name=proto.name, + description=proto.description, + created_by_user_id=proto.created_by_user_id, + modified_by_user_id=proto.modified_by_user_id, + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), + summaries=[ReportRuleSummary._from_proto(summary, sift_client) for summary in proto.summaries], + tags=[tag.tag_name for tag in proto.tags], + rerun_from_report_id=proto.rerun_from_report_id, + _client=sift_client, + ) + + def _to_proto(self) -> ReportProto: + """Convert to protobuf message.""" + proto = ReportProto( + report_id=self.id_ or "", + run_id=self.run_id, + organization_id=self.organization_id, + name=self.name, + description=self.description, + report_template_id=self.report_template_id, + tags=[ReportTagProto(tag_name=tag) for tag in self.tags], + summaries=[summary._to_proto() for summary in self.summaries], + ) + + return proto diff --git a/python/lib/sift_client/sift_types/tag.py b/python/lib/sift_client/sift_types/tag.py new file mode 100644 index 000000000..1c46378d7 --- /dev/null +++ b/python/lib/sift_client/sift_types/tag.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from pydantic import ConfigDict +from sift.tags.v2.tags_pb2 import Tag as TagProto + +from sift_client.sift_types._base import BaseType, ModelUpdate + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class TagUpdate(ModelUpdate[TagProto]): + """Update model for Tag.""" + + name: str | None = None + + def _get_proto_class(self) -> type[TagProto]: + return TagProto + + def _add_resource_id_to_proto(self, proto_msg: TagProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.tag_id = self._resource_id + + +class Tag(BaseType[TagProto, "Tag"]): + """Model of the Sift Tag.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + name: str + created_date: datetime + created_by_user_id: str + + @classmethod + def _from_proto(cls, proto: TagProto, sift_client: SiftClient | None = None) -> Tag: + return cls( + id_=proto.tag_id, + name=proto.name, + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + created_by_user_id=proto.created_by_user_id, + _client=sift_client, + ) + + def _to_proto(self) -> TagProto: + """Convert to protobuf message.""" + proto = TagProto( + tag_id=self.id_ or "", + name=self.name, + created_by_user_id=self.created_by_user_id, + created_date=self.created_date, # type: ignore + ) + return proto diff --git a/python/lib/sift_client/util/cel_utils.py b/python/lib/sift_client/util/cel_utils.py index 219f6fe19..f2abba800 100644 --- a/python/lib/sift_client/util/cel_utils.py +++ b/python/lib/sift_client/util/cel_utils.py @@ -198,7 +198,7 @@ def greater_than(field: str, value: int | float | datetime) -> str: as_string = value.isoformat() else: as_string = str(value) - return f"{field} > {as_string}" + return f"{field} > timestamp('{as_string}')" def less_than(field: str, value: int | float | datetime) -> str: @@ -215,4 +215,4 @@ def less_than(field: str, value: int | float | datetime) -> str: as_string = value.isoformat() else: as_string = str(value) - return f"{field} < {as_string}" + return f"{field} < timestamp('{as_string}')" diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 4202ee715..ace8fdd41 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -9,8 +9,10 @@ ChannelsAPIAsync, IngestionAPIAsync, PingAPIAsync, + ReportsAPIAsync, RulesAPIAsync, RunsAPIAsync, + TagsAPIAsync, ) @@ -32,8 +34,14 @@ class AsyncAPIs(NamedTuple): ingestion: IngestionAPIAsync """Instance of the Ingestion API for making asynchronous requests.""" + reports: ReportsAPIAsync + """Instance of the Reports API for making asynchronous requests.""" + runs: RunsAPIAsync """Instance of the Runs API for making asynchronous requests.""" + tags: TagsAPIAsync + """Instance of the Tags API for making asynchronous requests.""" + rules: RulesAPIAsync """Instance of the Rules API for making asynchronous requests.""" From 95e25bfabdc9aedba405c5bf6765e6566b168f4f Mon Sep 17 00:00:00 2001 From: Ian Later Date: Thu, 11 Sep 2025 12:20:56 -0700 Subject: [PATCH 2/2] Use rule_evaluation protos for rule evaluation. --- .../_internal/low_level_wrappers/rules.py | 85 +++++++++++++++++++ .../_internal/low_level_wrappers/tags.py | 4 +- .../sift_client/_tests/integrated/reports.py | 54 ++++-------- python/lib/sift_client/resources/__init__.py | 2 + python/lib/sift_client/resources/reports.py | 8 +- python/lib/sift_client/resources/rules.py | 55 ++++++++++++ python/lib/sift_client/resources/runs.py | 16 +++- .../resources/sync_stubs/__init__.pyi | 48 ++++++++++- python/lib/sift_client/resources/tags.py | 4 +- python/lib/sift_client/sift_types/__init__.py | 2 +- python/lib/sift_client/sift_types/report.py | 9 +- python/lib/sift_client/util/util.py | 7 +- 12 files changed, 238 insertions(+), 56 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 676b5fa71..435ff4bb5 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -3,6 +3,13 @@ import logging from typing import TYPE_CHECKING, Any, cast +from sift.common.type.v1.resource_identifier_pb2 import ResourceIdentifier +from sift.rule_evaluation.v1.rule_evaluation_pb2 import ( + EvaluateRulesRequest, + EvaluateRulesResponse, + RunTimeRange, +) +from sift.rule_evaluation.v1.rule_evaluation_pb2_grpc import RuleEvaluationServiceStub from sift.rules.v1.rules_pb2 import ( BatchDeleteRulesRequest, BatchGetRulesRequest, @@ -31,15 +38,20 @@ from sift.rules.v1.rules_pb2_grpc import RuleServiceStub from sift_client._internal.low_level_wrappers.base import LowLevelClientBase +from sift_client._internal.low_level_wrappers.reports import ReportsLowLevelClient from sift_client.sift_types.rule import ( Rule, RuleAction, RuleUpdate, ) from sift_client.transport import GrpcClient, WithGrpcClient +from sift_client.util.util import count_non_none if TYPE_CHECKING: + from datetime import datetime + from sift_client.sift_types.channel import ChannelReference + from sift_client.sift_types.report import Report # Configure logging logger = logging.getLogger(__name__) @@ -445,3 +457,76 @@ async def list_all_rules( order_by=order_by, max_results=max_results, ) + + async def evaluate_rules( + self, + *, + run_id: str | None = None, + assets: list[str] | None = None, + all_applicable_rules: bool | None = None, + run_start_time: datetime | None = None, + run_end_time: datetime | None = None, + rule_ids: list[str] | None = None, + rule_version_ids: list[str] | None = None, + report_template_id: str | None = None, + tags: list[str] | None = None, + ) -> Report | None: + """Evaluate a rule. + + Args: + run_id: The run ID to evaluate. + assets: The assets to evaluate. + run_start_time: The start time of the run. + run_end_time: The end time of the run. + all_applicable_rules: Whether to evaluate all rules applicable to the selected run, assets, or time range. + rule_ids: The rule IDs to evaluate. + rule_version_ids: The rule version IDs to evaluate. + report_template_id: The report template ID to evaluate. + tags: Optional tags to add to generated annotations. + + Returns: + The result of the rule execution. + """ + if count_non_none(run_id, assets, run_start_time, run_end_time) > 1: + raise ValueError( + "Pick only one run_id, assets, or (run_start_time and run_end_time) to select what to evaluate against." + ) + + all_applicable_rules = ( + None if not all_applicable_rules else True + ) # Cast to None if False so we don't count it against other filters if they aren't opting in. + if count_non_none(rule_ids, rule_version_ids, report_template_id, all_applicable_rules) > 1: + raise ValueError( + "Pick only one rule_ids, rule_version_ids, report_template_id, or all_applicable_rules to further filter which rules to evaluate." + ) + + kwargs: dict[str, Any] = {} + if run_start_time and run_end_time: + kwargs["run_time_range"] = RunTimeRange( + run=run_id, start_time=run_start_time, end_time=run_end_time + ) + if run_id: + kwargs["run"] = ResourceIdentifier(id=run_id) + if assets: + kwargs["assets"] = assets + if all_applicable_rules: + kwargs["all_applicable_rules"] = all_applicable_rules + if rule_ids: + kwargs["rules"] = rule_ids + if rule_version_ids: + kwargs["rule_versions"] = rule_version_ids + if report_template_id: + kwargs["report_template"] = report_template_id + if tags: + kwargs["tags"] = tags + + request = EvaluateRulesRequest(**kwargs) + response = await self._grpc_client.get_stub(RuleEvaluationServiceStub).EvaluateRules( + request + ) + response = cast("EvaluateRulesResponse", response) + report_id = response.report_id + if report_id: + report = await ReportsLowLevelClient(self._grpc_client).get_report(report_id=report_id) + return report + return None diff --git a/python/lib/sift_client/_internal/low_level_wrappers/tags.py b/python/lib/sift_client/_internal/low_level_wrappers/tags.py index bb67df27e..4ae12a4e4 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/tags.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/tags.py @@ -12,7 +12,7 @@ from sift.tags.v2.tags_pb2_grpc import TagServiceStub from sift_client._internal.low_level_wrappers.base import LowLevelClientBase -from sift_client.sift_types.tag import Tag, TagUpdate +from sift_client.sift_types.tag import Tag from sift_client.transport import WithGrpcClient if TYPE_CHECKING: @@ -114,4 +114,4 @@ async def list_all_tags( kwargs={"query_filter": query_filter}, order_by=order_by, max_results=max_results, - ) \ No newline at end of file + ) diff --git a/python/lib/sift_client/_tests/integrated/reports.py b/python/lib/sift_client/_tests/integrated/reports.py index d4be677ee..a6d2d8995 100644 --- a/python/lib/sift_client/_tests/integrated/reports.py +++ b/python/lib/sift_client/_tests/integrated/reports.py @@ -9,11 +9,11 @@ import asyncio import os -from datetime import datetime, timedelta, timezone +from datetime import datetime + from zoneinfo import ZoneInfo from sift_client import SiftClient -from sift_client.sift_types import report async def main(): @@ -23,6 +23,7 @@ async def main(): grpc_url = os.getenv("SIFT_GRPC_URI", "localhost:50051") rest_url = os.getenv("SIFT_REST_URI", "localhost:8080") api_key = os.getenv("SIFT_API_KEY", "") + client = SiftClient( api_key=api_key, grpc_url=grpc_url, @@ -35,49 +36,24 @@ async def main(): limit=100, ) - asset_ids = [] - asset_tags_names = [] rules = [] - reports = [] + failed_runs = [] for run in runs: print("run.name: ", run.name) print(" client_key: ", run.client_key) - if run.client_key: - # rules = client.rules.list_( - # client_key=run.client_key, - # limit=100, - # ) - raise Exception("client_key is not None! Let's add these rules") - run_assets = run.assets - print(" assets: ", [asset.name for asset in run_assets]) - asset_ids.extend([asset.id_ for asset in run_assets]) - asset_tags_names.extend([tag for asset in run_assets for tag in asset.tags]) - per_run_reports = client.reports.list_( - run_id=run.id_, - ) - print(" reports: ", [report.name for report in per_run_reports]) - reports.extend(per_run_reports) - - asset_ids = list(set(asset_ids)) - asset_tags_names = list(set(asset_tags_names)) - asset_tags = client.tags.list_( - names=asset_tags_names, - ) - print(" asset_tags: ", [(tag.name, tag.id_) for tag in asset_tags]) - print("Number of runs: ", len(runs)) - print("Number of assets: ", len(asset_ids)) + try: + report = client.rules.evaluate( + run_id=run.id_, + all_applicable_rules=True, + ) + except Exception as e: + failed_runs.append(run.id_) + print(f"Failed to evaluate rules for run {run.id_}: {e}") + + print("Number of successful runs: ", len(runs) - len(failed_runs)) + print("Number of failed runs: ", len(failed_runs)) - rules = client.rules.list_( - asset_ids=asset_ids, - asset_tags_ids=[tag.id_ for tag in asset_tags], - ) - print("reports: ", [report.name for report in reports]) - if len(rules) < 10: - print("rules: ", [rule.name for rule in rules]) - else: - print("number of rules: ", len(rules)) - if __name__ == "__main__": asyncio.run(main()) diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 5f22ecf6c..5810fe7cc 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -7,6 +7,8 @@ from sift_client.resources.rules import RulesAPIAsync from sift_client.resources.runs import RunsAPIAsync from sift_client.resources.tags import TagsAPIAsync + +# ruff: noqa TagsAPIAsync needs to be imported before sync_stubs to avoid circular import from sift_client.resources.sync_stubs import ( AssetsAPI, CalculatedChannelsAPI, diff --git a/python/lib/sift_client/resources/reports.py b/python/lib/sift_client/resources/reports.py index 521e0d4ad..e3b3263e1 100644 --- a/python/lib/sift_client/resources/reports.py +++ b/python/lib/sift_client/resources/reports.py @@ -6,15 +6,14 @@ from sift_client._internal.low_level_wrappers.reports import ReportsLowLevelClient from sift_client.resources._base import ResourceBase from sift_client.sift_types.report import Report -from sift_client.util.cel_utils import contains, equals, equals_null, match, not_ +from sift_client.util.cel_utils import contains, equals, match if TYPE_CHECKING: from sift_client.client import SiftClient class ReportsAPIAsync(ResourceBase): - """High-level API for interacting with reports. - """ + """High-level API for interacting with reports.""" def __init__(self, sift_client: SiftClient): """Initialize the ReportsAPI. @@ -201,7 +200,6 @@ async def create_from_rules( ) return self._apply_client_to_instance(created_report) - async def rerun( self, *, @@ -233,4 +231,4 @@ async def cancel( report_id = report.id_ if isinstance(report, Report) else report if not isinstance(report_id, str): raise TypeError(f"report_id must be a string not {type(report_id)}") - await self._low_level_client.cancel_report(report_id=report_id) \ No newline at end of file + await self._low_level_client.cancel_report(report_id=report_id) diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index ddd28d48d..ff7b6ef0e 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -9,9 +9,11 @@ if TYPE_CHECKING: import re + from datetime import datetime from sift_client.client import SiftClient from sift_client.sift_types.channel import ChannelReference + from sift_client.sift_types.report import Report class RulesAPIAsync(ResourceBase): @@ -273,3 +275,56 @@ async def batch_get( rule_ids=rule_ids, client_keys=client_keys ) return self._apply_client_to_instances(rules) + + async def evaluate( + self, + *, + run_id: str | None = None, + assets: list[str] | None = None, + all_applicable_rules: bool | None = None, + run_start_time: datetime | None = None, + run_end_time: datetime | None = None, + rule_ids: list[str] | None = None, + rule_version_ids: list[str] | None = None, + report_template_id: str | None = None, + tags: list[str] | None = None, + ) -> Report | None: + """Evaluate a rule. + + Pick one of the following grouping of rules to evaluate against: + - run_id + - assets + - run_start_time and run_end_time + And one of the following filters to select which rules to evaluate: + - rule_ids + - rule_version_ids + - report_template_id + - all_applicable_rules + + Args: + run_id: The run ID to evaluate. + assets: The assets to evaluate. + all_applicable_rules: Whether to evaluate all rules applicable to the selected run, assets, or time range. + run_start_time: The start time of the run. + run_end_time: The end time of the run. + rule_ids: The rule IDs to evaluate. + rule_version_ids: The rule version IDs to evaluate. + report_template_id: The report template ID to evaluate. + tags: Optional tags to add to generated annotations. + + Returns: + The result of the rule evaluation. + """ + report = await self._low_level_client.evaluate_rules( + run_id=run_id, + assets=assets, + all_applicable_rules=all_applicable_rules, + run_start_time=run_start_time, + run_end_time=run_end_time, + rule_ids=rule_ids, + rule_version_ids=rule_version_ids, + report_template_id=report_template_id, + tags=tags, + ) + if report: + return self._apply_client_to_instance(report) diff --git a/python/lib/sift_client/resources/runs.py b/python/lib/sift_client/resources/runs.py index 9ec8a50be..9c7543e43 100644 --- a/python/lib/sift_client/resources/runs.py +++ b/python/lib/sift_client/resources/runs.py @@ -6,7 +6,16 @@ from sift_client._internal.low_level_wrappers.runs import RunsLowLevelClient from sift_client.resources._base import ResourceBase from sift_client.sift_types.run import Run, RunUpdate -from sift_client.util.cel_utils import contains, equals, equals_null, greater_than, less_than, match, not_ +from sift_client.util.cel_utils import ( + contains, + equals, + equals_null, + greater_than, + in_, + less_than, + match, + not_, +) if TYPE_CHECKING: from datetime import datetime @@ -55,6 +64,7 @@ async def list_( name: str | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + run_ids: list[str] | None = None, description: str | None = None, description_contains: str | None = None, duration_seconds: int | None = None, @@ -82,6 +92,7 @@ async def list_( name: Exact name of the run. name_contains: Partial name of the run. name_regex: Regular expression string to filter runs by name. + run_ids: List of run IDs to filter by. description: Exact description of the run. description_contains: Partial description of the run. duration_seconds: Duration of the run in seconds. @@ -118,6 +129,9 @@ async def list_( name_regex = name_regex.pattern filter_parts.append(match("name", name_regex)) # type: ignore + if run_ids: + filter_parts.append(in_("run_id", run_ids)) + if description: filter_parts.append(equals("description", description)) elif description_contains: diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index a35c9d82b..be4a93745 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -494,7 +494,6 @@ class ReportsAPI: """Sync counterpart to `ReportsAPIAsync`. High-level API for interacting with reports. - """ def __init__(self, sift_client: SiftClient): @@ -709,6 +708,47 @@ class RulesAPI: """Create a new rule.""" ... + def evaluate( + self, + *, + run_id: str | None = None, + assets: list[str] | None = None, + all_applicable_rules: bool | None = None, + run_start_time: datetime | None = None, + run_end_time: datetime | None = None, + rule_ids: list[str] | None = None, + rule_version_ids: list[str] | None = None, + report_template_id: str | None = None, + tags: list[str] | None = None, + ) -> Report | None: + """Evaluate a rule. + + Pick one of the following grouping of rules to evaluate against: + - run_id + - assets + - run_start_time and run_end_time + And one of the following filters to select which rules to evaluate: + - rule_ids + - rule_version_ids + - report_template_id + - all_applicable_rules + + Args: + run_id: The run ID to evaluate. + assets: The assets to evaluate. + all_applicable_rules: Whether to evaluate all rules applicable to the selected run, assets, or time range. + run_start_time: The start time of the run. + run_end_time: The end time of the run. + rule_ids: The rule IDs to evaluate. + rule_version_ids: The rule version IDs to evaluate. + report_template_id: The report template ID to evaluate. + tags: Optional tags to add to generated annotations. + + Returns: + The result of the rule evaluation. + """ + ... + def find(self, **kwargs) -> Rule | None: """Find a single rule matching the given query. Takes the same arguments as `list`. If more than one rule is found, raises an error. @@ -740,7 +780,7 @@ class RulesAPI: name_contains: str | None = None, name_regex: str | re.Pattern | None = None, asset_ids: list[str] | None = None, - asset_tags: list[str] | None = None, + asset_tags_ids: list[str] | None = None, client_key: str | None = None, order_by: str | None = None, limit: int | None = None, @@ -753,7 +793,7 @@ class RulesAPI: name_contains: Partial name of the rule. name_regex: Regular expression string to filter rules by name. asset_ids: List of asset IDs to filter rules by. - asset_tags: List of asset tags to filter rules by. + asset_tags_ids: List of asset tags IDs to filter rules by. client_key: The client key of the rules. order_by: How to order the retrieved rules. limit: How many rules to retrieve. If None, retrieves all matches. @@ -891,6 +931,7 @@ class RunsAPI: name: str | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, + run_ids: list[str] | None = None, description: str | None = None, description_contains: str | None = None, duration_seconds: int | None = None, @@ -918,6 +959,7 @@ class RunsAPI: name: Exact name of the run. name_contains: Partial name of the run. name_regex: Regular expression string to filter runs by name. + run_ids: List of run IDs to filter by. description: Exact description of the run. description_contains: Partial description of the run. duration_seconds: Duration of the run in seconds. diff --git a/python/lib/sift_client/resources/tags.py b/python/lib/sift_client/resources/tags.py index e549e2b4c..f6b9fd0d0 100644 --- a/python/lib/sift_client/resources/tags.py +++ b/python/lib/sift_client/resources/tags.py @@ -5,11 +5,11 @@ from sift_client._internal.low_level_wrappers.tags import TagsLowLevelClient from sift_client.resources._base import ResourceBase -from sift_client.sift_types.tag import Tag, TagUpdate from sift_client.util.cel_utils import contains, equals, in_, match if TYPE_CHECKING: from sift_client.client import SiftClient + from sift_client.sift_types.tag import Tag, TagUpdate class TagsAPIAsync(ResourceBase): @@ -125,4 +125,4 @@ async def update(self, tag: str | Tag, update: TagUpdate | dict) -> Tag: """ # Note: The tags API doesn't have an update method in the proto, # so this would need to be implemented if the API supports it - raise NotImplementedError("Tag updates are not supported by the current API") \ No newline at end of file + raise NotImplementedError("Tag updates are not supported by the current API") diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index a9a85c9d4..88c235eaa 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -10,6 +10,7 @@ ChannelReference, ) from sift_client.sift_types.ingestion import IngestionConfig +from sift_client.sift_types.report import Report from sift_client.sift_types.rule import ( Rule, RuleAction, @@ -19,7 +20,6 @@ RuleVersion, ) from sift_client.sift_types.run import Run, RunUpdate -from sift_client.sift_types.report import Report from sift_client.sift_types.tag import Tag, TagUpdate __all__ = [ diff --git a/python/lib/sift_client/sift_types/report.py b/python/lib/sift_client/sift_types/report.py index 11f6f3f4f..35c13e81f 100644 --- a/python/lib/sift_client/sift_types/report.py +++ b/python/lib/sift_client/sift_types/report.py @@ -16,6 +16,7 @@ class ReportRuleSummary(BaseType[ReportRuleSummaryProto, "ReportRuleSummary"]): """ReportRuleSummary model representing a rule summary within a report.""" + rule_id: str rule_client_key: str | None = None rule_version_id: str @@ -31,7 +32,9 @@ class ReportRuleSummary(BaseType[ReportRuleSummaryProto, "ReportRuleSummary"]): deleted_date: datetime | None = None @classmethod - def _from_proto(cls, proto: ReportRuleSummaryProto, sift_client: SiftClient | None = None) -> ReportRuleSummary: + def _from_proto( + cls, proto: ReportRuleSummaryProto, sift_client: SiftClient | None = None + ) -> ReportRuleSummary: return cls( id_=proto.report_rule_version_id, rule_id=proto.rule_id, @@ -102,7 +105,9 @@ def _from_proto(cls, proto: ReportProto, sift_client: SiftClient | None = None) modified_by_user_id=proto.modified_by_user_id, created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), - summaries=[ReportRuleSummary._from_proto(summary, sift_client) for summary in proto.summaries], + summaries=[ + ReportRuleSummary._from_proto(summary, sift_client) for summary in proto.summaries + ], tags=[tag.tag_name for tag in proto.tags], rerun_from_report_id=proto.rerun_from_report_id, _client=sift_client, diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index ace8fdd41..e631351e1 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple if TYPE_CHECKING: from sift_client.resources import ( @@ -45,3 +45,8 @@ class AsyncAPIs(NamedTuple): rules: RulesAPIAsync """Instance of the Rules API for making asynchronous requests.""" + + +def count_non_none(*args: Any) -> int: + """Count the number of non-none arguments.""" + return sum(1 for arg in args if arg is not None)