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 69722cc41..d4122d3aa 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/__init__.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/__init__.py @@ -5,8 +5,10 @@ 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 +from sift_client._internal.low_level_wrappers.tags import TagsLowLevelClient from sift_client._internal.low_level_wrappers.test_results import TestResultsLowLevelClient from sift_client._internal.low_level_wrappers.upload import UploadLowLevelClient @@ -16,8 +18,10 @@ "ChannelsLowLevelClient", "IngestionLowLevelClient", "PingLowLevelClient", + "ReportsLowLevelClient", "RulesLowLevelClient", "RunsLowLevelClient", + "TagsLowLevelClient", "TestResultsLowLevelClient", "UploadLowLevelClient", ] 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..ab7178166 --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/reports.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from sift.reports.v1.reports_pb2 import ( + CancelReportRequest, + GetReportRequest, + GetReportResponse, + ListReportsRequest, + ListReportsResponse, + RerunReportRequest, + RerunReportResponse, + UpdateReportRequest, +) +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, ReportUpdate +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 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) + + async def update_report(self, update: ReportUpdate) -> Report: + """Update a report. + + Args: + update: The updates to apply. + + Returns: + The updated report. + """ + report_proto, field_mask = update.to_proto_with_mask() + request = UpdateReportRequest(report=report_proto, update_mask=field_mask) + await self._grpc_client.get_stub(ReportServiceStub).UpdateReport(request) + # Unfortunately, updating a report doesn't return the updated report. + return await self.get_report(update.resource_id) 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 b578b6177..8b38b3945 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,14 @@ import logging from typing import TYPE_CHECKING, Any, cast +from sift.common.type.v1.resource_identifier_pb2 import ResourceIdentifier, ResourceIdentifiers +from sift.rule_evaluation.v1.rule_evaluation_pb2 import ( + AssetsTimeRange, + EvaluateRulesRequest, + EvaluateRulesResponse, + RunTimeRange, +) +from sift.rule_evaluation.v1.rule_evaluation_pb2_grpc import RuleEvaluationServiceStub from sift.rules.v1.rules_pb2 import ( ArchiveRuleRequest, BatchArchiveRulesRequest, @@ -30,15 +38,22 @@ 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._internal.util.timestamp import to_pb_timestamp +from sift_client._internal.util.util import count_non_none from sift_client.sift_types.rule import ( Rule, RuleCreate, RuleUpdate, ) +from sift_client.sift_types.tag import Tag from sift_client.transport import GrpcClient, WithGrpcClient 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__) @@ -437,3 +452,93 @@ async def list_all_rules( order_by=order_by, max_results=max_results, ) + + async def evaluate_rules( + self, + *, + run_id: str | None = None, + asset_ids: list[str] | None = None, + all_applicable_rules: bool | None = None, + start_time: datetime | None = None, + end_time: datetime | None = None, + rule_ids: list[str] | None = None, + rule_version_ids: list[str] | None = None, + report_template_id: str | None = None, + report_name: str | None = None, + tags: list[str | Tag] | None = None, + organization_id: str | None = None, + ) -> tuple[int, Report | None, str | None]: + """Evaluate a rule. + + Args: + run_id: The run ID to evaluate. + asset_ids: The asset IDs to evaluate. + start_time: The start time of the 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. + report_name: The name of the report to create. + tags: Optional tags to add to generated annotations. + organization_id: The organization ID to evaluate. + + Returns: + The result of the rule execution. + """ + if count_non_none(run_id, asset_ids) > 1: + raise ValueError( + "Pick only one run_id or asset_ids 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] = {} + # Time frame filters are run(ID), run_time_range(ID + start/end time), or assets(asset_ids + start/end time) + if start_time and end_time: + if run_id: + kwargs["run_time_range"] = RunTimeRange( + run=run_id, # type: ignore + start_time=to_pb_timestamp(start_time), + end_time=to_pb_timestamp(end_time), # type: ignore + ) + kwargs["assets"] = AssetsTimeRange( + assets={"ids": {"ids": asset_ids}}, # type: ignore + start_time=to_pb_timestamp(start_time), + end_time=to_pb_timestamp(end_time), + ) + elif run_id: + kwargs["run"] = ResourceIdentifier(id=run_id) + if all_applicable_rules: + kwargs["all_applicable_rules"] = all_applicable_rules + if rule_ids: + kwargs["rules"] = {"rules": ResourceIdentifiers(ids={"ids": rule_ids})} # type: ignore + if rule_version_ids: + kwargs["rule_versions"] = rule_version_ids + if report_template_id: + kwargs["report_template"] = report_template_id + if tags: + kwargs["tags"] = [tag.name if isinstance(tag, Tag) else tag for tag in tags] + if report_name: + kwargs["report_name"] = report_name + if organization_id: + kwargs["organization_id"] = organization_id + + request = EvaluateRulesRequest(**kwargs) + response = await self._grpc_client.get_stub(RuleEvaluationServiceStub).EvaluateRules( + request + ) + response = cast("EvaluateRulesResponse", response) + created_annotation_count = response.created_annotation_count + report_id = response.report_id + job_id = response.job_id + if report_id: + 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 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..4ae12a4e4 --- /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 +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, + ) diff --git a/python/lib/sift_client/_internal/util/util.py b/python/lib/sift_client/_internal/util/util.py new file mode 100644 index 000000000..15b33847e --- /dev/null +++ b/python/lib/sift_client/_internal/util/util.py @@ -0,0 +1,6 @@ +from typing import Any + + +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) diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index 3d22df167..070696f0e 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -5,8 +5,7 @@ import pytest -from sift_client import SiftClient -from sift_client.transport import SiftConnectionConfig +from sift_client import SiftClient, SiftConnectionConfig from sift_client.util.util import AsyncAPIs @@ -26,7 +25,7 @@ def sift_client() -> SiftClient: api_key=api_key, grpc_url=grpc_url, rest_url=rest_url, - use_ssl=True, + # use_ssl=True, ) ) @@ -37,11 +36,37 @@ def mock_client(): client = MagicMock(spec=SiftClient) # Configure the mock to have the necessary API attributes client.assets = MagicMock() + client.reports = MagicMock() client.runs = MagicMock() client.channels = MagicMock() client.calculated_channels = MagicMock() client.rules = MagicMock() + client.tags = MagicMock() client.test_results = MagicMock() client.async_ = MagicMock(spec=AsyncAPIs) client.async_.ingestion = MagicMock() return client + + +@pytest.fixture(scope="session") +def nostromo_asset(sift_client): + return sift_client.assets.find(name="NostromoLV426") + + +@pytest.fixture(scope="session") +def nostromo_run(nostromo_asset): + return nostromo_asset.runs[0] + + +@pytest.fixture(scope="session") +def test_tag(sift_client): + tag = sift_client.tags.find_or_create(names=["test"])[0] + assert tag is not None + return tag + + +@pytest.fixture(scope="session") +def ci_pytest_tag(sift_client): + tag = sift_client.tags.find_or_create(names=["sift-client-pytest"])[0] + assert tag is not None + return tag diff --git a/python/lib/sift_client/_tests/resources/test_calculated_channels.py b/python/lib/sift_client/_tests/resources/test_calculated_channels.py index b9dab9fed..bc75465e9 100644 --- a/python/lib/sift_client/_tests/resources/test_calculated_channels.py +++ b/python/lib/sift_client/_tests/resources/test_calculated_channels.py @@ -46,7 +46,7 @@ def calculated_channels_api_sync(sift_client: SiftClient): @pytest.fixture def test_calculated_channel(calculated_channels_api_sync): - calculated_channels = calculated_channels_api_sync.list_(limit=1) + calculated_channels = calculated_channels_api_sync.list_(limit=1, include_archived=True) assert calculated_channels assert len(calculated_channels) >= 1 return calculated_channels[0] @@ -115,7 +115,9 @@ class TestList: @pytest.mark.asyncio async def test_basic_list(self, calculated_channels_api_async): """Test basic calculated channel listing functionality.""" - calc_channels = await calculated_channels_api_async.list_(limit=5) + calc_channels = await calculated_channels_api_async.list_( + limit=5, include_archived=True + ) assert isinstance(calc_channels, list) assert len(calc_channels) == 5 @@ -126,11 +128,13 @@ async def test_basic_list(self, calculated_channels_api_async): @pytest.mark.asyncio async def test_list_with_name_filter(self, calculated_channels_api_async): """Test calculated channel listing with name filtering.""" - all_calc_channels = await calculated_channels_api_async.list_(limit=10) + all_calc_channels = await calculated_channels_api_async.list_( + limit=10, include_archived=True + ) test_calc_channel_name = all_calc_channels[0].name filtered_calc_channels = await calculated_channels_api_async.list_( - name=test_calc_channel_name + name=test_calc_channel_name, include_archived=True ) assert isinstance(filtered_calc_channels, list) @@ -142,7 +146,9 @@ async def test_list_with_name_filter(self, calculated_channels_api_async): @pytest.mark.asyncio async def test_list_with_name_contains_filter(self, calculated_channels_api_async): """Test calculated channel listing with name contains filtering.""" - calc_channels = await calculated_channels_api_async.list_(name_contains="test", limit=5) + calc_channels = await calculated_channels_api_async.list_( + name_contains="test", limit=5, include_archived=True + ) assert isinstance(calc_channels, list) assert calc_channels @@ -154,7 +160,7 @@ async def test_list_with_name_contains_filter(self, calculated_channels_api_asyn async def test_list_with_name_regex_filter(self, calculated_channels_api_async): """Test calculated channel listing with regex name filtering.""" calc_channels = await calculated_channels_api_async.list_( - name_regex=r".*test.*", limit=5 + name_regex=r".*test.*", limit=5, include_archived=True ) assert isinstance(calc_channels, list) @@ -186,7 +192,7 @@ async def test_find_calculated_channel( ): """Test finding a single calculated channel.""" found_calc_channel = await calculated_channels_api_async.find( - name=test_calculated_channel.name + name=test_calculated_channel.name, include_archived=True ) assert found_calc_channel is not None @@ -196,7 +202,7 @@ async def test_find_calculated_channel( async def test_find_nonexistent_calculated_channel(self, calculated_channels_api_async): """Test finding a non-existent calculated channel returns None.""" found_calc_channel = await calculated_channels_api_async.find( - name="nonexistent-calculated-channel-name-12345" + name="nonexistent-calculated-channel-name-12345", include_archived=True ) assert found_calc_channel is None @@ -204,7 +210,9 @@ async def test_find_nonexistent_calculated_channel(self, calculated_channels_api async def test_find_multiple_raises_error(self, calculated_channels_api_async): """Test finding multiple calculated channels raises an error.""" with pytest.raises(ValueError, match="Multiple"): - await calculated_channels_api_async.find(name_contains="test", limit=5) + await calculated_channels_api_async.find( + name_contains="test", limit=5, include_archived=True + ) class TestCreate: """Tests for the async create method.""" @@ -519,7 +527,7 @@ class TestListVersions: async def test_list_versions(self, calculated_channels_api_async, test_calculated_channel): """Test listing versions of a calculated channel.""" versions = await calculated_channels_api_async.list_versions( - calculated_channel=test_calculated_channel + calculated_channel=test_calculated_channel, include_archived=True ) assert isinstance(versions, list) diff --git a/python/lib/sift_client/_tests/resources/test_channels.py b/python/lib/sift_client/_tests/resources/test_channels.py index 8afdb60be..e369652db 100644 --- a/python/lib/sift_client/_tests/resources/test_channels.py +++ b/python/lib/sift_client/_tests/resources/test_channels.py @@ -158,14 +158,17 @@ async def test_list_with_asset_filter(self, channels_api_async): async def test_list_with_description_contains_filter(self, channels_api_async): """Test channel listing with description contains filtering.""" # Test with a common substring that might exist in descriptions - channels = await channels_api_async.list_(description_contains="test", limit=5) + description_contains = "the" + channels = await channels_api_async.list_( + description_contains=description_contains, limit=5 + ) assert isinstance(channels, list) assert channels # If we found channels, verify they contain the substring in description for channel in channels: - assert "test" in channel.description.lower() + assert description_contains in channel.description.lower() @pytest.mark.asyncio async def test_list_with_limit(self, channels_api_async): diff --git a/python/lib/sift_client/_tests/resources/test_reports.py b/python/lib/sift_client/_tests/resources/test_reports.py new file mode 100644 index 000000000..dad6ee0a5 --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_reports.py @@ -0,0 +1,161 @@ +import pytest + +from sift_client.resources import ReportsAPI, ReportsAPIAsync +from sift_client.sift_types import ( + ChannelReference, + ReportRuleStatus, + RuleAction, + RuleAnnotationType, +) + + +@pytest.fixture(scope="session") +def tags(sift_client, test_tag, ci_pytest_tag): + tags = sift_client.tags.find_or_create(names=[test_tag.name, ci_pytest_tag.name]) + return tags + + +@pytest.fixture(scope="session") +def test_rule(sift_client, nostromo_asset, ci_pytest_tag): + rule = sift_client.rules.find(name="test_rule") + created_rule = None + if not rule: + created_rule = sift_client.rules.create( + { + "name": "test_rule", + "description": "Test rule", + "expression": "$1 > 0.1", + "asset_ids": [nostromo_asset._id_or_error], + "channel_references": [ + ChannelReference( + channel_reference="$1", channel_identifier="mainmotor.velocity" + ), + ], + "action": RuleAction.annotation( + annotation_type=RuleAnnotationType.DATA_REVIEW, + tags=[ci_pytest_tag], + ), + }, + ) + rule = created_rule + if rule.is_archived: + rule = rule.unarchive() + yield rule + if created_rule: + created_rule.archive() + + +def test_client_binding(sift_client): + assert sift_client.reports + assert isinstance(sift_client.reports, ReportsAPI) + assert sift_client.async_.reports + assert isinstance(sift_client.async_.reports, ReportsAPIAsync) + + +@pytest.mark.integration +class TestReports: + 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", + run=nostromo_run, + rules=[test_rule], + ) + assert report_from_rules is not None + assert report_from_rules.run_id == nostromo_run.id_ + + def test_create_from_applicable_rules( + self, test_rule, nostromo_asset, nostromo_run, sift_client + ): + if not test_rule.asset_ids: + # Test rule may exist but be in a state where it no longer applies to the asset associated w/ the run so re-attach it if necessary. + test_rule = test_rule.update(update={"asset_ids": [nostromo_asset._id_or_error]}) + report_from_applicable_rules = sift_client.reports.create_from_applicable_rules( + name="report_from_applicable_rules_run", + run=nostromo_run, + organization_id=nostromo_run.organization_id, + ) + assert report_from_applicable_rules is not None + assert report_from_applicable_rules.run_id == nostromo_run.id_ + + def test_list(self, nostromo_asset, nostromo_run, tags, sift_client): + reports = sift_client.reports.list_( + run=nostromo_run, + organization_id=nostromo_asset.organization_id, + ) + assert len(reports) > 0 + + def test_rerun(self, nostromo_asset, nostromo_run, test_rule, sift_client): + report_from_rules = sift_client.reports.create_from_rules( + name="report_from_rules", + run=nostromo_run, + rules=[test_rule], + ) + assert report_from_rules is not None + job_id, rerun_report_id = sift_client.reports.rerun(report=report_from_rules) + rerun_report = sift_client.reports.get(report_id=rerun_report_id) + assert rerun_report is not None + assert rerun_report.run_id == nostromo_run.id_ + assert rerun_report.rerun_from_report_id == report_from_rules.id_ + + def test_update(self, nostromo_asset, nostromo_run, test_rule, sift_client): + report_from_rules = sift_client.reports.create_from_rules( + name="report_from_rules", + run=nostromo_run, + rules=[test_rule], + ) + assert report_from_rules is not None + updated_report = sift_client.reports.update( + report=report_from_rules, + update={ + "metadata": { + "test_type": "ci", + }, + }, + ) + assert updated_report is not None + assert updated_report.metadata == {"test_type": "ci"} + + def test_find_multiple(self, sift_client): + with pytest.raises(ValueError, match="Multiple reports found for query"): + sift_client.reports.find(name="report_from_rules") + + def test_cancel(self, nostromo_asset, nostromo_run, test_rule, sift_client): + report_from_rules = sift_client.reports.create_from_rules( + name="report_from_rules", + run=nostromo_run, + rules=[test_rule], + ) + assert report_from_rules is not None + job_id, second_rerun_report_id = sift_client.reports.rerun(report=report_from_rules) + assert second_rerun_report_id is not None + sift_client.reports.cancel(report=second_rerun_report_id) + canceled_report = sift_client.reports.find(report_ids=[second_rerun_report_id]) + assert canceled_report is not None + for summary in canceled_report.summaries: + assert summary.status == ReportRuleStatus.CANCELED + + def test_archive(self, nostromo_run, test_rule, sift_client): + report_from_rules = sift_client.reports.create_from_rules( + name="report_from_rules", + run=nostromo_run, + rules=[test_rule], + ) + assert report_from_rules is not None + archived_report = sift_client.reports.archive(report=report_from_rules) + 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 + ) + 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) + assert unarchived_report is not None + assert unarchived_report.is_archived == False diff --git a/python/lib/sift_client/_tests/resources/test_rules.py b/python/lib/sift_client/_tests/resources/test_rules.py index 539b3d006..f4f270c93 100644 --- a/python/lib/sift_client/_tests/resources/test_rules.py +++ b/python/lib/sift_client/_tests/resources/test_rules.py @@ -267,7 +267,7 @@ async def test_create_basic_rule(self, rules_api_async): annotation_type=RuleAnnotationType.DATA_REVIEW, tags=[], ), - asset_ids=[assets[0].id_], + asset_ids=[assets[0]._id_or_error], ) created_rule = await rules_api_async.create(rule_create) @@ -395,15 +395,15 @@ async def test_update_with_version_notes(self, rules_api_async, new_rule): await rules_api_async.archive(new_rule.id_) @pytest.mark.asyncio - async def test_update_rule_action(self, rules_api_async, new_rule): + async def test_update_rule_action(self, rules_api_async, new_rule, ci_pytest_tag): """Test updating a rule's action including annotation type, tags, and assignee.""" try: # Update the action with new annotation type, tags, and assignee update = RuleUpdate( action=RuleAction.annotation( annotation_type=RuleAnnotationType.PHASE, - tags=["sift-client-pytest"], - default_assignee_user_id=new_rule.created_by_user_id, + tags=[ci_pytest_tag], + default_assignee_user=new_rule.created_by_user_id, ), ) updated_rule = await rules_api_async.update(new_rule, update) @@ -412,8 +412,8 @@ async def test_update_rule_action(self, rules_api_async, new_rule): assert updated_rule.id_ == new_rule.id_ assert updated_rule.action.action_type == RuleActionType.ANNOTATION assert updated_rule.action.annotation_type == RuleAnnotationType.PHASE - assert set(updated_rule.action.tags) == {"sift-client-pytest"} - assert updated_rule.action.default_assignee_user_id == new_rule.created_by_user_id + assert set(updated_rule.action.tags_ids) == {ci_pytest_tag.id_} + assert updated_rule.action.default_assignee_user == new_rule.created_by_user_id # Verify other fields remain unchanged assert updated_rule.name == new_rule.name @@ -422,7 +422,7 @@ async def test_update_rule_action(self, rules_api_async, new_rule): await rules_api_async.archive(new_rule.id_) @pytest.mark.asyncio - async def test_update_with_complex_expression(self, rules_api_async, sift_client): + async def test_update_with_complex_expression(self, rules_api_async, sift_client, test_tag): """Test updating a rule with a complex expression (range check).""" # Get channels and assets channels = await sift_client.async_.channels.list_(limit=2) @@ -441,7 +441,7 @@ async def test_update_with_complex_expression(self, rules_api_async, sift_client ], action=RuleAction.annotation( annotation_type=RuleAnnotationType.DATA_REVIEW, - tags=["test"], + tags=[test_tag], ), asset_ids=[assets[0].id_], ) @@ -472,7 +472,9 @@ async def test_update_with_complex_expression(self, rules_api_async, sift_client await rules_api_async.archive(created_rule.id_) @pytest.mark.asyncio - async def test_update_with_multiple_channel_references(self, rules_api_async, sift_client): + async def test_update_with_multiple_channel_references( + self, rules_api_async, sift_client, test_tag + ): """Test updating a rule expression to use multiple channel references.""" # Get channels and assets channels = await sift_client.async_.channels.list_(limit=3) @@ -492,7 +494,7 @@ async def test_update_with_multiple_channel_references(self, rules_api_async, si ], action=RuleAction.annotation( annotation_type=RuleAnnotationType.DATA_REVIEW, - tags=["test"], + tags=[test_tag], ), asset_ids=[assets[0].id_], ) diff --git a/python/lib/sift_client/_tests/resources/test_runs.py b/python/lib/sift_client/_tests/resources/test_runs.py index d2b2defe5..798555635 100644 --- a/python/lib/sift_client/_tests/resources/test_runs.py +++ b/python/lib/sift_client/_tests/resources/test_runs.py @@ -47,7 +47,7 @@ def test_run(runs_api_sync): @pytest.fixture -def new_run(runs_api_sync): +def new_run(runs_api_sync, ci_pytest_tag): """Create a test run for update tests.""" run_name = f"test_run_update_{datetime.now(timezone.utc).isoformat()}" description = "Test run created by Sift Client pytest" @@ -55,7 +55,7 @@ def new_run(runs_api_sync): RunCreate( name=run_name, description=description, - tags=["sift-client-pytest"], + tags=[ci_pytest_tag.name], ) ) return created_run diff --git a/python/lib/sift_client/_tests/resources/test_tags.py b/python/lib/sift_client/_tests/resources/test_tags.py new file mode 100644 index 000000000..0e1269b5f --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_tags.py @@ -0,0 +1,141 @@ +"""Pytest tests for the Tags API. + +These tests demonstrate and validate the usage of the Tags API including: +- Basic tag operations (list, find) +- Tag filtering and searching +- Tag creation and find_or_create +- Error handling and edge cases +""" + +import re +from datetime import datetime, timezone + +import pytest + +from sift_client.resources import TagsAPI, TagsAPIAsync +from sift_client.sift_types import Tag + +pytestmark = pytest.mark.integration + + +def test_client_binding(sift_client): + assert sift_client.tags + assert isinstance(sift_client.tags, TagsAPI) + assert sift_client.async_.tags + assert isinstance(sift_client.async_.tags, TagsAPIAsync) + + +@pytest.fixture(scope="session") +def test_timestamp(): + """Setup a test tag for the session.""" + timestamp = datetime.now(timezone.utc) + return timestamp + + +@pytest.fixture(scope="session") +def test_timestamp_str(test_timestamp): + """Setup a test tag for the session.""" + return test_timestamp.isoformat() + + +@pytest.fixture(scope="session") +def test_tags(sift_client, test_timestamp_str): + """Setup test tags for the session.""" + tag1 = sift_client.tags.create(f"test_tag1_{test_timestamp_str}") + tag2 = sift_client.tags.create(f"test_tag2_{test_timestamp_str}") + return tag1, tag2 + # Would like to archive the tags, but this is not supported by the API + + +class TestTags: + """Tests for the Tags API.""" + + def test_basic_list(self, sift_client, test_tags, test_timestamp_str): + """Test basic tag listing functionality.""" + tags = sift_client.tags.list_(limit=5) + + # Verify we get a list + assert isinstance(tags, list) + + # If we have tags, verify their structure + tag = tags[0] + assert isinstance(tag, Tag) + assert tag.id_ is not None + assert tag.name is not None + + def test_list_with_name_filter(self, sift_client, test_tags, test_timestamp_str): + """Test tag listing with name filtering.""" + # Create a test tag with a unique name + name_filter = f"test_tag1_{test_timestamp_str}" + name_filter_contains = f"tag1_{test_timestamp_str}" + name_filter_regex = re.compile(rf".*_tag.+_{re.escape(test_timestamp_str)}") + + filtered_tags = sift_client.tags.list_(name=name_filter) + filtered_tags_contains = sift_client.tags.list_(name_contains=name_filter_contains) + filtered_tags_regex = sift_client.tags.list_(name_regex=name_filter_regex) + # Should find exactly one tag with this name + assert isinstance(filtered_tags, list) + assert len(filtered_tags) == 1 + assert filtered_tags[0].name == name_filter + assert filtered_tags_contains[0].name == test_tags[0].name + assert filtered_tags_regex[0].name == test_tags[0].name + assert filtered_tags[0].id_ == test_tags[0].id_ + assert filtered_tags_contains[0].id_ == test_tags[0].id_ + assert filtered_tags_regex[0].id_ == test_tags[0].id_ + + def test_find_tag(self, sift_client, test_tags, test_timestamp_str): + """Test finding a single tag. Excercises find and list_ limit functionality.""" + # Create a test tag + test_tag_name = f"test_tag1_{test_timestamp_str}" + + found_tag = sift_client.tags.find(name=test_tag_name) + + assert found_tag is not None + assert found_tag.id_ == test_tags[0].id_ + + def test_find_nonexistent_tag(self, sift_client): + """Test finding a non-existent tag returns None.""" + found_tag = sift_client.tags.find( + name=f"nonexistent_tag_{datetime.now(timezone.utc).timestamp()}" + ) + assert found_tag is None + + def test_find_multiple_raises_error(self, sift_client, test_timestamp_str, test_tags): + """Test finding multiple tags raises an error.""" + # Create multiple tags with similar names + name_filter_regex = re.compile(rf".*_tag(1|2)_{re.escape(test_timestamp_str)}") + with pytest.raises(ValueError, match="Multiple tags found"): + _ = sift_client.tags.find(name_regex=name_filter_regex) + + def test_find_or_create_existing_tags(self, sift_client, test_timestamp_str, test_tags): + """Test find_or_create with existing tags.""" + # Find or create the existing tags + existing_tag_names = [tag.name for tag in test_tags] + result_tags = sift_client.tags.find_or_create(existing_tag_names) + + assert len(result_tags) == 2 + result_ids = {tag.id_ for tag in result_tags} + assert test_tags[0].id_ in result_ids + assert test_tags[1].id_ in result_ids + + def test_find_or_create_new_tags(self, sift_client, test_timestamp_str, test_tags): + """Test find_or_create with new tags.""" + new_tag_name = f"test_find_or_create_new_{test_timestamp_str}" + + # Find or create tags that don't exist + existing_tag_names = [tag.name for tag in test_tags] + result_tags = sift_client.tags.find_or_create({*existing_tag_names, new_tag_name}) + + assert len(result_tags) == len(existing_tag_names) + 1 + result_names = {tag.name for tag in result_tags} + assert result_names == {*existing_tag_names, new_tag_name} + + # Verify all tags have IDs (were created) + for tag in result_tags: + assert tag.id_ is not None + + def test_update_not_implemented(self, sift_client, test_tags): + """Test that update raises NotImplementedError.""" + # Try to update (should raise NotImplementedError) + with pytest.raises(NotImplementedError, match="not supported"): + sift_client.tags.update(test_tags[0], {"name": "new_name"}) diff --git a/python/lib/sift_client/_tests/sift_types/test_report.py b/python/lib/sift_client/_tests/sift_types/test_report.py new file mode 100644 index 000000000..e35f99e66 --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_report.py @@ -0,0 +1,192 @@ +"""Tests for sift_types.Report model.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from sift_client.sift_types.report import ( + Report, + ReportRuleStatus, + ReportRuleSummary, + ReportUpdate, +) + + +class TestReportUpdate: + """Unit tests for ReportUpdate model - tests _to_proto_helpers.""" + + def test_metadata_converter(self): + """Test that metadata is converted using _to_proto_helpers.""" + metadata = {"key1": "value1", "key2": 42.5, "key3": True} + update = ReportUpdate(metadata=metadata) + update.resource_id = "test_report_id" + + proto, mask = update.to_proto_with_mask() + + assert proto.report_id == "test_report_id" + # Verify metadata was converted using the helper (returns a list) + assert len(proto.metadata) == 3 + + # Find each metadata value in the list + metadata_dict = {md.key.name: md for md in proto.metadata} + assert metadata_dict["key1"].string_value == "value1" + assert metadata_dict["key2"].number_value == 42.5 + assert metadata_dict["key3"].boolean_value is True + assert "metadata" in mask.paths + + def test_is_archive(self, mock_report, mock_client): + """Test that is_archived field is properly set.""" + archived_report = MagicMock() + archived_report.is_archived = True + mock_client.reports.archive.return_value = archived_report + with MagicMock() as mock_update: + mock_report._update = mock_update + result = mock_report.archive() + mock_client.reports.archive.assert_called_once_with(report=mock_report) + mock_update.assert_called_once_with(archived_report) + assert result is mock_report + + def test_unarchive(self, mock_report, mock_client): + """Test that unarchive() calls client.reports.unarchive and calls _update.""" + unarchived_report = MagicMock() + unarchived_report.is_archived = False + mock_client.reports.unarchive.return_value = unarchived_report + with MagicMock() as mock_update: + mock_report._update = mock_update + result = mock_report.unarchive() + mock_client.reports.unarchive.assert_called_once_with(report=mock_report) + mock_update.assert_called_once_with(unarchived_report) + assert result is mock_report + + def test_metadata_and_is_archived_update(self): + """Test updating multiple fields at once.""" + metadata = {"key": "value"} + update = ReportUpdate( + metadata=metadata, + is_archived=True, + ) + update.resource_id = "test_report_id" + + proto, mask = update.to_proto_with_mask() + + assert proto.report_id == "test_report_id" + assert proto.is_archived is True + assert len(proto.metadata) == 1 + assert "is_archived" in mask.paths + assert "metadata" in mask.paths + + +@pytest.fixture +def mock_report_rule_summary(): + """Create a mock ReportRuleSummary instance for testing.""" + return ReportRuleSummary( + id_="summary_id", + rule_id="rule1", + rule_client_key="rule_key", + rule_version_id="version1", + rule_version_number=1, + report_rule_version_id="report_version1", + num_open=5, + num_failed=2, + num_passed=3, + status=ReportRuleStatus.FINISHED, + created_date=datetime.now(timezone.utc), + modified_date=datetime.now(timezone.utc), + asset_id="asset1", + deleted_date=None, + ) + + +@pytest.fixture +def mock_report(mock_client, mock_report_rule_summary): + """Create a mock Report instance for testing.""" + report = Report( + proto=MagicMock(), + id_="test_report_id", + report_template_id="template1", + run_id="run1", + organization_id="org1", + name="test_report", + description="test description", + created_by_user_id="user1", + modified_by_user_id="user1", + created_date=datetime.now(timezone.utc), + modified_date=datetime.now(timezone.utc), + summaries=[mock_report_rule_summary], + tags=["tag1", "tag2"], + rerun_from_report_id=None, + metadata={"key": "value"}, + job_id="job1", + archived_date=None, + is_archived=False, + ) + report._apply_client_to_instance(mock_client) + return report + + +class TestReport: + """Unit tests for Report model - tests properties and methods.""" + + def test_report_properties(self, mock_report): + """Test that Report properties are accessible.""" + assert mock_report.id_ == "test_report_id" + assert mock_report.name == "test_report" + assert mock_report.description == "test description" + assert mock_report.report_template_id == "template1" + assert mock_report.run_id == "run1" + assert mock_report.organization_id == "org1" + assert mock_report.tags == ["tag1", "tag2"] + assert mock_report.metadata == {"key": "value"} + assert mock_report.is_archived is False + assert len(mock_report.summaries) == 1 + + def test_report_to_proto(self, mock_report): + """Test that Report can be converted to proto.""" + proto = mock_report.to_proto() + + assert proto.report_id == "test_report_id" + assert proto.name == "test_report" + assert proto.description == "test description" + assert proto.report_template_id == "template1" + assert proto.run_id == "run1" + assert proto.organization_id == "org1" + assert proto.is_archived is False + assert len(proto.tags) == 2 + assert proto.tags[0].tag_name == "tag1" + assert proto.tags[1].tag_name == "tag2" + assert len(proto.summaries) == 1 + + +class TestReportRuleSummary: + """Unit tests for ReportRuleSummary model.""" + + def test_report_rule_summary_properties(self, mock_report_rule_summary): + """Test that ReportRuleSummary properties are accessible.""" + assert mock_report_rule_summary.id_ == "summary_id" + assert mock_report_rule_summary.rule_id == "rule1" + assert mock_report_rule_summary.rule_client_key == "rule_key" + assert mock_report_rule_summary.rule_version_id == "version1" + assert mock_report_rule_summary.rule_version_number == 1 + assert mock_report_rule_summary.report_rule_version_id == "report_version1" + assert mock_report_rule_summary.num_open == 5 + assert mock_report_rule_summary.num_failed == 2 + assert mock_report_rule_summary.num_passed == 3 + assert mock_report_rule_summary.status == ReportRuleStatus.FINISHED + assert mock_report_rule_summary.asset_id == "asset1" + assert mock_report_rule_summary.deleted_date is None + + def test_report_rule_summary_to_proto(self, mock_report_rule_summary): + """Test that ReportRuleSummary can be converted to proto.""" + proto = mock_report_rule_summary.to_proto() + + assert proto.rule_id == "rule1" + assert proto.rule_client_key == "rule_key" + assert proto.rule_version_id == "version1" + assert proto.rule_version_number == 1 + assert proto.report_rule_version_id == "report_version1" + assert proto.num_open == 5 + assert proto.num_failed == 2 + assert proto.num_passed == 3 + assert proto.status == ReportRuleStatus.FINISHED.value + assert proto.asset_id == "asset1" diff --git a/python/lib/sift_client/_tests/sift_types/test_rule.py b/python/lib/sift_client/_tests/sift_types/test_rule.py index de9dbe7b1..31dc2fa74 100644 --- a/python/lib/sift_client/_tests/sift_types/test_rule.py +++ b/python/lib/sift_client/_tests/sift_types/test_rule.py @@ -38,7 +38,7 @@ def mock_rule(mock_client): action=RuleAction( action_type=RuleActionType.ANNOTATION, annotation_type=RuleAnnotationType.DATA_REVIEW, - tags=["tag1"], + tags_ids=["tag1"], ), asset_ids=["asset1", "asset2"], asset_tag_ids=["tag1"], @@ -63,7 +63,7 @@ def test_assets_property_calls_client(self, mock_rule, mock_client): # Verify client method was called with correct parameters mock_client.assets.list_.assert_called_once_with( - asset_ids=["asset1", "asset2"], _tag_ids=["tag1"] + asset_ids=["asset1", "asset2"], tags=["tag1"] ) def test_update_calls_client_and_updates_self(self, mock_rule, mock_client): diff --git a/python/lib/sift_client/_tests/sift_types/test_tag.py b/python/lib/sift_client/_tests/sift_types/test_tag.py new file mode 100644 index 000000000..43a5c9381 --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_tag.py @@ -0,0 +1,75 @@ +"""Tests for sift_types.Tag model.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from sift_client.sift_types.tag import Tag, TagCreate + + +@pytest.fixture +def mock_tag(mock_client): + """Create a mock Tag instance for testing.""" + tag = Tag( + proto=MagicMock(), + id_="test_tag_id", + name="test_tag", + created_date=datetime.now(timezone.utc), + created_by_user_id="user1", + ) + tag._apply_client_to_instance(mock_client) + return tag + + +class TestTagCreate: + """Unit tests for TagCreate model.""" + + def test_tag_create_basic(self): + """Test basic TagCreate instantiation.""" + create = TagCreate(name="test_tag") + + assert create.name == "test_tag" + + def test_tag_create_to_proto(self): + """Test that TagCreate converts to proto correctly.""" + create = TagCreate(name="test_tag") + proto = create.to_proto() + + assert proto.name == "test_tag" + + +class TestTag: + """Unit tests for Tag model - tests properties and methods.""" + + def test_tag_properties(self, mock_tag): + """Test that Tag properties are accessible.""" + assert mock_tag.id_ == "test_tag_id" + assert mock_tag.name == "test_tag" + assert mock_tag.created_by_user_id == "user1" + assert mock_tag.created_date is not None + assert mock_tag.created_date.tzinfo == timezone.utc + + def test_tag_str(self, mock_tag): + """Test Tag string representation.""" + assert str(mock_tag) == "test_tag" + + def test_tag_to_proto(self, mock_tag): + """Test that Tag can be converted to proto.""" + proto = mock_tag._to_proto() + + assert proto.tag_id == "test_tag_id" + assert proto.name == "test_tag" + assert proto.created_by_user_id == "user1" + + def test_tag_without_client_raises_error(self): + """Test that accessing client without setting it raises an error.""" + tag = Tag( + id_="test_tag_id", + name="test_tag", + created_date=datetime.now(timezone.utc), + created_by_user_id="user1", + ) + + with pytest.raises(AttributeError, match="Sift client not set"): + _ = tag.client diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index 9729c2f90..2a2252ef8 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, TestResultsAPI, TestResultsAPIAsync, ) @@ -82,12 +86,17 @@ 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.""" test_results: TestResultsAPI """Instance of the Test Results API for making synchronous requests.""" @@ -133,7 +142,9 @@ def __init__( self.calculated_channels = CalculatedChannelsAPI(self) self.channels = ChannelsAPI(self) self.rules = RulesAPI(self) + self.reports = ReportsAPI(self) self.runs = RunsAPI(self) + self.tags = TagsAPI(self) self.test_results = TestResultsAPI(self) # Accessor for the asynchronous APIs self.async_ = AsyncAPIs( @@ -142,8 +153,10 @@ def __init__( calculated_channels=CalculatedChannelsAPIAsync(self), channels=ChannelsAPIAsync(self), ingestion=IngestionAPIAsync(self), + reports=ReportsAPIAsync(self), rules=RulesAPIAsync(self), runs=RunsAPIAsync(self), + tags=TagsAPIAsync(self), test_results=TestResultsAPIAsync(self), ) diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index f31511e01..968fabdb3 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -155,8 +155,10 @@ async def main(): 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.test_results import TestResultsAPIAsync # ruff: noqa All imports needs to be imported before sync_stubs to avoid circular import @@ -165,8 +167,10 @@ async def main(): CalculatedChannelsAPI, ChannelsAPI, PingAPI, + ReportsAPI, RulesAPI, RunsAPI, + TagsAPI, TestResultsAPI, ) @@ -180,10 +184,14 @@ async def main(): "IngestionAPIAsync", "PingAPI", "PingAPIAsync", + "ReportsAPI", + "ReportsAPIAsync", "RulesAPI", "RulesAPIAsync", "RunsAPI", "RunsAPIAsync", + "TagsAPI", + "TagsAPIAsync", "TestResultsAPI", "TestResultsAPIAsync", ] diff --git a/python/lib/sift_client/resources/_base.py b/python/lib/sift_client/resources/_base.py index 8ab4ee6cf..676dedbca 100644 --- a/python/lib/sift_client/resources/_base.py +++ b/python/lib/sift_client/resources/_base.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any, TypeVar from sift_client.errors import _sift_client_experimental_warning +from sift_client.sift_types.tag import Tag from sift_client.util import cel_utils as cel _sift_client_experimental_warning() @@ -15,7 +16,6 @@ from sift_client.client import SiftClient from sift_client.sift_types._base import BaseType from sift_client.transport.base_connection import GrpcClient, RestClient - T = TypeVar("T", bound="BaseType") @@ -47,17 +47,22 @@ def _apply_client_to_instances(self, instances: list[T]) -> list[T]: # Common CEL filters used in resources def _build_name_cel_filters( self, + *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, ) -> list[str]: filter_parts = [] if name: filter_parts.append(cel.equals("name", name)) + if names: + filter_parts.append(cel.in_("name", names)) if name_contains: filter_parts.append(cel.contains("name", name_contains)) if name_regex: filter_parts.append(cel.match("name", name_regex)) + print(filter_parts) return filter_parts def _build_time_cel_filters( @@ -110,15 +115,28 @@ def _build_metadata_cel_filters( def _build_tags_metadata_cel_filters( self, *, - tags: list[Any] | list[str] | None = None, - metadata: list[Any] | None = None, + tag_names: list[Tag] | list[str] | None = None, + tag_ids: list[Any] | list[str] | None = None, + metadata: list[Any] | dict[str, Any] | None = None, ) -> list[str]: + """Build CEL filters for tags and metadata. + Note: Some resources only support filtering on tag_id but conceptually users are most likely to want to filter on tag names. Check the request proto when using this helper and consider using tag_names by default if supported as a filterable field by the request proto. + + Args: + tag_names: Creates filters for tag names + tag_ids: Creates filters for tag IDs + metadata: Creates filters for metadata. + + Returns: + A list of CEL filters. + """ filter_parts = [] - if tags: - if all(isinstance(tag, str) for tag in tags): - filter_parts.append(cel.in_("tag_name", tags)) - else: - raise NotImplementedError + if tag_names: + tag_names = [tag.name if isinstance(tag, Tag) else tag for tag in tag_names] + filter_parts.append(cel.in_("tag_name", tag_names)) + if tag_ids: + tag_ids = [tag._id_or_error if isinstance(tag, Tag) else tag for tag in tag_ids] + filter_parts.append(cel.in_("tag_id", tag_ids)) if metadata: filter_parts.extend(self._build_metadata_cel_filters(metadata)) return filter_parts @@ -133,8 +151,9 @@ def _build_common_cel_filters( filter_parts = [] if description_contains: filter_parts.append(cel.contains("description", description_contains)) - if include_archived is not None: - filter_parts.append(cel.equals("is_archived", include_archived)) + if include_archived is not None and not include_archived: + # By default, archived resources are included so only need to set if included_archived is explicitly false + filter_parts.append(cel.equals("is_archived", False)) if filter_query: filter_parts.append(filter_query) return filter_parts diff --git a/python/lib/sift_client/resources/assets.py b/python/lib/sift_client/resources/assets.py index 6d1727766..d8482061a 100644 --- a/python/lib/sift_client/resources/assets.py +++ b/python/lib/sift_client/resources/assets.py @@ -12,6 +12,7 @@ from datetime import datetime from sift_client.client import SiftClient + from sift_client.sift_types.tag import Tag class AssetsAPIAsync(ResourceBase): @@ -65,6 +66,7 @@ async def list_( *, # name name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids @@ -78,9 +80,7 @@ async def list_( created_by: Any | str | None = None, modified_by: Any | str | None = None, # tags - tags: list[Any] | list[str] | None = None, - _tag_ids: list[str] - | None = None, # For compatibility until first class Tag support is added + tags: list[Any] | list[str] | list[Tag] | None = None, # metadata metadata: list[Any] | None = None, # common filters @@ -94,6 +94,7 @@ async def list_( Args: name: Exact name of the asset. + names: List of asset names to filter by. name_contains: Partial name of the asset. name_regex: Regular expression to filter assets by name. asset_ids: Filter to assets with any of these Ids. @@ -116,7 +117,7 @@ async def list_( """ filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_time_cel_filters( created_after=created_after, @@ -126,7 +127,7 @@ async def list_( created_by=created_by, modified_by=modified_by, ), - *self._build_tags_metadata_cel_filters(tags=tags, metadata=metadata), + *self._build_tags_metadata_cel_filters(tag_names=tags, metadata=metadata), *self._build_common_cel_filters( description_contains=description_contains, include_archived=include_archived, @@ -135,8 +136,6 @@ async def list_( ] if asset_ids: filter_parts.append(cel.in_("asset_id", asset_ids)) - if _tag_ids: - filter_parts.append(cel.in_("tag_id", _tag_ids)) filter_query = cel.and_(*filter_parts) assets = await self._low_level_client.list_all_assets( diff --git a/python/lib/sift_client/resources/calculated_channels.py b/python/lib/sift_client/resources/calculated_channels.py index 2c1f30750..789d52be1 100644 --- a/python/lib/sift_client/resources/calculated_channels.py +++ b/python/lib/sift_client/resources/calculated_channels.py @@ -20,6 +20,7 @@ from datetime import datetime from sift_client.client import SiftClient + from sift_client.sift_types.tag import Tag class CalculatedChannelsAPIAsync(ResourceBase): @@ -72,6 +73,7 @@ async def list_( self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids @@ -86,7 +88,7 @@ async def list_( created_by: Any | str | None = None, modified_by: Any | str | None = None, # tags - tags: list[Any] | list[str] | None = None, + tags: list[Any] | list[str] | list[Tag] | None = None, # metadata metadata: list[Any] | None = None, # calculated channel specific @@ -104,6 +106,7 @@ async def list_( Args: name: Exact name of the calculated channel. + names: List of calculated channel names to filter by. name_contains: Partial name of the calculated channel. name_regex: Regular expression string to filter calculated channels by name. calculated_channel_ids: Filter to calculated channels with any of these IDs. @@ -130,7 +133,7 @@ async def list_( """ filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_time_cel_filters( created_after=created_after, @@ -140,7 +143,7 @@ async def list_( created_by=created_by, modified_by=modified_by, ), - *self._build_tags_metadata_cel_filters(tags=tags, metadata=metadata), + *self._build_tags_metadata_cel_filters(tag_names=tags, metadata=metadata), *self._build_common_cel_filters( description_contains=description_contains, include_archived=include_archived, @@ -280,6 +283,7 @@ async def list_versions( calculated_channel: CalculatedChannel | str | None = None, client_key: str | None = None, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # created/modified ranges @@ -291,7 +295,7 @@ async def list_versions( created_by: Any | str | None = None, modified_by: Any | str | None = None, # tags - tags: list[Any] | list[str] | None = None, + tags: list[Any] | list[str] | list[Tag] | None = None, # metadata metadata: list[Any] | None = None, # common filters @@ -307,6 +311,7 @@ async def list_versions( calculated_channel: The CalculatedChannel or ID of the calculated channel to get versions for. client_key: The client key of the calculated channel. name: Exact name of the calculated channel. + names: List of calculated channel names to filter by. name_contains: Partial name of the calculated channel. name_regex: Regular expression string to filter calculated channels by name. created_after: Filter versions created after this datetime. @@ -328,7 +333,7 @@ async def list_versions( """ filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_time_cel_filters( created_after=created_after, @@ -338,7 +343,7 @@ async def list_versions( created_by=created_by, modified_by=modified_by, ), - *self._build_tags_metadata_cel_filters(tags=tags, metadata=metadata), + *self._build_tags_metadata_cel_filters(tag_names=tags, metadata=metadata), *self._build_common_cel_filters( description_contains=description_contains, include_archived=include_archived, diff --git a/python/lib/sift_client/resources/channels.py b/python/lib/sift_client/resources/channels.py index 7a5bb1d9b..c65ade03c 100644 --- a/python/lib/sift_client/resources/channels.py +++ b/python/lib/sift_client/resources/channels.py @@ -61,6 +61,7 @@ async def list_( self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids @@ -84,6 +85,7 @@ async def list_( Args: name: Exact name of the channel. + names: List of channel names to filter by. name_contains: Partial name of the channel. name_regex: Regular expression to filter channels by name. channel_ids: Filter to channels with any of these IDs. @@ -104,7 +106,7 @@ async def list_( """ filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_time_cel_filters( created_after=created_after, @@ -113,7 +115,9 @@ async def list_( modified_before=modified_before, ), *self._build_common_cel_filters( - description_contains=description_contains, filter_query=filter_query + description_contains=description_contains, + filter_query=filter_query, + include_archived=include_archived, ), ] if channel_ids: diff --git a/python/lib/sift_client/resources/reports.py b/python/lib/sift_client/resources/reports.py new file mode 100644 index 000000000..86a19d5ae --- /dev/null +++ b/python/lib/sift_client/resources/reports.py @@ -0,0 +1,339 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.reports import ReportsLowLevelClient +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.run import Run +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + from datetime import datetime + + from sift_client.client import SiftClient + from sift_client.sift_types.tag import Tag + + +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) + self._rules_low_level_client = RulesLowLevelClient(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, + names: list[str] | None = None, + description_contains: str | None = None, + run: Run | str | None = None, + organization_id: str | None = None, + report_ids: list[str] | None = None, + report_template_id: str | None = None, + metadata: dict[str, str | float | bool] | None = None, + tag_names: list[str] | list[Tag] | None = None, + created_by: str | None = None, + modified_by: str | None = None, + order_by: str | None = None, + limit: int | None = None, + include_archived: bool = False, + filter_query: str | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | 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. + names: List of report names to filter by. + description_contains: Partial description of the report. + run: Run/run ID to filter by. + organization_id: Organization ID to filter by. + report_ids: List of report IDs to filter by. + report_template_id: Report template ID to filter by. + metadata: Metadata to filter by. + tag_names: List of tags or tag names to filter by. + created_by: The user ID of the creator of the reports. + modified_by: The user ID of the last modifier of the reports. + order_by: How to order the retrieved reports. + limit: How many reports to retrieve. If None, retrieves all matches. + include_archived: Whether to include archived reports. + filter_query: Explicit CEL query to filter reports. + created_after: Filter reports created after this datetime. + created_before: Filter reports created before this datetime. + modified_after: Filter reports modified after this datetime. + modified_before: Filter reports modified before this datetime. + + Returns: + A list of Reports that matches the filter. + """ + # Build CEL filter + filter_parts = [ + *self._build_name_cel_filters( + name=name, + names=names, + name_contains=name_contains, + name_regex=name_regex, + ), + *self._build_time_cel_filters( + created_after=created_after, + created_before=created_before, + modified_after=modified_after, + modified_before=modified_before, + created_by=created_by, + modified_by=modified_by, + ), + *self._build_tags_metadata_cel_filters(tag_names=tag_names, metadata=metadata), + *self._build_common_cel_filters( + description_contains=description_contains, + include_archived=include_archived, + filter_query=filter_query, + ), + ] + + if run: + run_id = run.id_ if isinstance(run, Run) else run + filter_parts.append(cel.equals("run_id", run_id)) + + if report_ids: + filter_parts.append(cel.in_("report_id", report_ids)) + + if report_template_id: + filter_parts.append(cel.equals("report_template_id", report_template_id)) + + query_filter = cel.and_(*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 | None = None, + name: str | None = None, + ) -> Report | None: + """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 or None if no report was created. + """ + ( + created_annotation_count, + created_report, + job_id, + ) = await self._rules_low_level_client.evaluate_rules( + report_template_id=report_template_id, + run_id=run_id, + organization_id=organization_id, + report_name=name, + ) + if created_report: + return self._apply_client_to_instance(created_report) + return None + + async def create_from_rules( + self, + *, + name: str, + run: Run | str | None = None, + organization_id: str | None = None, + rules: list[Rule] | list[str], + ) -> Report | None: + """Create a new report from rules. + + Args: + name: The name of the report. + run: The run or run ID to associate with the report. + organization_id: The organization ID. + rules: List of rules or rule 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_ids=[rule._id_or_error if isinstance(rule, Rule) else rule for rule in rules] + or [], + report_name=name, + ) + if created_report: + return self._apply_client_to_instance(created_report) + return None + + async def create_from_applicable_rules( + self, + *, + run: Run | str | None = None, + organization_id: str | None = None, + name: str | None = None, + start_time: datetime | None = None, + end_time: datetime | None = 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. + + Args: + run: The run or run ID to associate with the report. + organization_id: The organization ID. + name: Optional name for the report. + start_time: Optional start time to evaluate rules against. + end_time: Optional end time to evaluate rules against. + + 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, + start_time=start_time, + end_time=end_time, + report_name=name, + all_applicable_rules=True, + ) + if created_report: + return self._apply_client_to_instance(created_report) + return None + + 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) + + async def update(self, report: str | Report, update: ReportUpdate | dict) -> Report: + """Update a report. + + Args: + report: The Report or report ID to update. + update: The updates to apply. + """ + report_id = report.id_ if isinstance(report, Report) else report + + if isinstance(update, dict): + update = ReportUpdate.model_validate(update) + update.resource_id = report_id + updated_report = await self._low_level_client.update_report(update=update) + return self._apply_client_to_instance(updated_report) + + async def archive( + self, + *, + report: str | Report, + ) -> Report: + """Archive a report.""" + report_id = report.id_ if isinstance(report, Report) else report + update = ReportUpdate(is_archived=True) + update.resource_id = report_id + updated_report = await self._low_level_client.update_report(update=update) + return self._apply_client_to_instance(updated_report) + + async def unarchive( + self, + *, + report: str | Report, + ) -> Report: + """Unarchive a report.""" + report_id = report.id_ if isinstance(report, Report) else report + update = ReportUpdate(is_archived=False) + update.resource_id = report_id + updated_report = await self._low_level_client.update_report(update=update) + return self._apply_client_to_instance(updated_report) diff --git a/python/lib/sift_client/resources/rules.py b/python/lib/sift_client/resources/rules.py index 09d9f255c..03662dda4 100644 --- a/python/lib/sift_client/resources/rules.py +++ b/python/lib/sift_client/resources/rules.py @@ -4,6 +4,7 @@ from sift_client._internal.low_level_wrappers.rules import RulesLowLevelClient 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.util import cel_utils as cel @@ -12,6 +13,7 @@ from datetime import datetime from sift_client.client import SiftClient + from sift_client.sift_types.tag import Tag class RulesAPIAsync(ResourceBase): @@ -55,6 +57,7 @@ async def list_( self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids @@ -71,8 +74,8 @@ async def list_( # metadata metadata: list[Any] | None = None, # rule specific - asset_ids: list[str] | None = None, - asset_tag_ids: list[str] | None = None, + assets: list[str] | list[Asset] | None = None, + asset_tags: list[str | Tag] | None = None, # common filters description_contains: str | None = None, include_archived: bool = False, @@ -84,10 +87,11 @@ async def list_( Args: name: Exact name of the rule. + names: List of rule names to filter by. name_contains: Partial name of the rule. name_regex: Regular expression string to filter rules by name. - rule_ids: IDs of rules to filter to. client_keys: Client keys of rules to filter to. + rule_ids: IDs of rules to filter to. created_after: Rules created after this datetime. created_before: Rules created before this datetime. modified_after: Rules modified after this datetime. @@ -95,8 +99,8 @@ async def list_( created_by: Filter rules created by this User or user ID. modified_by: Filter rules last modified by this User or user ID. metadata: Filter rules by metadata criteria. - asset_ids: Filter rules associated with any of these Asset IDs. - asset_tag_ids: Filter rules associated with any of these Asset Tag IDs. + assets: Filter rules associated with any of these Assets. + asset_tags: Filter rules associated with any Assets that have these Tag IDs. description_contains: Partial description of the rule. include_archived: If True, include archived rules in results. filter_query: Explicit CEL query to filter rules. @@ -108,7 +112,10 @@ async def list_( """ filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, + names=names, + name_contains=name_contains, + name_regex=name_regex, ), *self._build_time_cel_filters( created_after=created_after, @@ -118,7 +125,7 @@ async def list_( created_by=created_by, modified_by=modified_by, ), - *self._build_tags_metadata_cel_filters(metadata=metadata), + *self._build_tags_metadata_cel_filters(tag_ids=asset_tags, metadata=metadata), *self._build_common_cel_filters( description_contains=description_contains, include_archived=include_archived, @@ -129,10 +136,9 @@ async def list_( filter_parts.append(cel.in_("rule_id", rule_ids)) if client_keys: filter_parts.append(cel.in_("client_key", client_keys)) - if asset_ids: - filter_parts.append(cel.in_("asset_id", asset_ids)) - if asset_tag_ids: - filter_parts.append(cel.in_("tag_id", asset_tag_ids)) + if assets: + ids = [a._id_or_error if isinstance(a, Asset) else a or "" for a in assets] + filter_parts.append(cel.in_("asset_id", ids)) query_filter = cel.and_(*filter_parts) rules = await self._low_level_client.list_all_rules( filter_query=query_filter, diff --git a/python/lib/sift_client/resources/runs.py b/python/lib/sift_client/resources/runs.py index 1e097612a..844139a7b 100644 --- a/python/lib/sift_client/resources/runs.py +++ b/python/lib/sift_client/resources/runs.py @@ -5,6 +5,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, RunCreate, RunUpdate +from sift_client.sift_types.tag import Tag from sift_client.util import cel_utils as cel if TYPE_CHECKING: @@ -59,6 +60,7 @@ async def list_( self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, # self ids @@ -72,10 +74,13 @@ async def list_( # created/modified users created_by: Any | str | None = None, modified_by: Any | str | None = None, + # tags + tags: list[str | Tag] | None = None, # metadata metadata: list[Any] | None = None, # run specific assets: list[Asset] | list[str] | None = None, + asset_tags: list[str | Tag] | None = None, duration_less_than: timedelta | None = None, duration_greater_than: timedelta | None = None, start_time_after: datetime | None = None, @@ -94,6 +99,7 @@ async def list_( Args: name: Exact name of the run. + names: List of run names to filter by. name_contains: Partial name of the run. name_regex: Regular expression to filter runs by name. run_ids: Filter to runs with any of these IDs. @@ -104,8 +110,10 @@ async def list_( modified_before: Filter runs modified before this datetime. created_by: Filter runs created by this User or user ID. modified_by: Filter runs last modified by this User or user ID. + tags: Filter runs with any of these Tags IDs. metadata: Filter runs by metadata criteria. assets: Filter runs associated with any of these Assets or asset IDs. + asset_tags: Filter runs associated with any Assets that have these Tag IDs. duration_less_than: Filter runs with duration less than this time. duration_greater_than: Filter runs with duration greater than this time. start_time_after: Filter runs that started after this datetime. @@ -124,7 +132,7 @@ async def list_( """ filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_time_cel_filters( created_after=created_after, @@ -134,7 +142,7 @@ async def list_( created_by=created_by, modified_by=modified_by, ), - *self._build_tags_metadata_cel_filters(metadata=metadata), + *self._build_tags_metadata_cel_filters(tag_ids=tags, metadata=metadata), *self._build_common_cel_filters( description_contains=description_contains, include_archived=include_archived, @@ -152,6 +160,11 @@ async def list_( else: asset = cast("list[Asset]", assets) # linting filter_parts.append(cel.in_("asset_id", [a._id_or_error for a in asset])) + if asset_tags: + asset_tag_ids = [ + tag._id_or_error if isinstance(tag, Tag) else tag or "" for tag in asset_tags + ] + filter_parts.append(cel.in_("asset_tag_id", asset_tag_ids)) if duration_less_than: filter_parts.append(cel.less_than("duration_string", duration_less_than)) if duration_greater_than: diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 440a9de21..6664a5e0f 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, TestResultsAPIAsync, ) @@ -19,6 +21,9 @@ 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") + TestResultsAPI = generate_sync_api(TestResultsAPIAsync, "TestResultsAPI") -__all__ = ["AssetsAPI", "CalculatedChannelsAPI", "PingAPI", "RunsAPI", "TestResultsAPI"] +__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 109517386..cf0ee1247 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -18,8 +18,10 @@ from sift_client.sift_types.calculated_channel import ( CalculatedChannelUpdate, ) from sift_client.sift_types.channel import Channel +from sift_client.sift_types.report import 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 from sift_client.sift_types.test_report import ( TestMeasurement, TestMeasurementCreate, @@ -96,6 +98,7 @@ class AssetsAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, asset_ids: list[str] | None = None, @@ -105,8 +108,7 @@ class AssetsAPI: modified_before: datetime | None = None, created_by: Any | str | None = None, modified_by: Any | str | None = None, - tags: list[Any] | list[str] | None = None, - _tag_ids: list[str] | None = None, + tags: list[Any] | list[str] | list[Tag] | None = None, metadata: list[Any] | None = None, description_contains: str | None = None, include_archived: bool = False, @@ -118,6 +120,7 @@ class AssetsAPI: Args: name: Exact name of the asset. + names: List of asset names to filter by. name_contains: Partial name of the asset. name_regex: Regular expression to filter assets by name. asset_ids: Filter to assets with any of these Ids. @@ -240,6 +243,7 @@ class CalculatedChannelsAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, calculated_channel_ids: list[str] | None = None, @@ -250,7 +254,7 @@ class CalculatedChannelsAPI: modified_before: datetime | None = None, created_by: Any | str | None = None, modified_by: Any | str | None = None, - tags: list[Any] | list[str] | None = None, + tags: list[Any] | list[str] | list[Tag] | None = None, metadata: list[Any] | None = None, asset: Asset | str | None = None, run: Run | str | None = None, @@ -265,6 +269,7 @@ class CalculatedChannelsAPI: Args: name: Exact name of the calculated channel. + names: List of calculated channel names to filter by. name_contains: Partial name of the calculated channel. name_regex: Regular expression string to filter calculated channels by name. calculated_channel_ids: Filter to calculated channels with any of these IDs. @@ -297,6 +302,7 @@ class CalculatedChannelsAPI: calculated_channel: CalculatedChannel | str | None = None, client_key: str | None = None, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, created_after: datetime | None = None, @@ -305,7 +311,7 @@ class CalculatedChannelsAPI: modified_before: datetime | None = None, created_by: Any | str | None = None, modified_by: Any | str | None = None, - tags: list[Any] | list[str] | None = None, + tags: list[Any] | list[str] | list[Tag] | None = None, metadata: list[Any] | None = None, description_contains: str | None = None, include_archived: bool = False, @@ -319,6 +325,7 @@ class CalculatedChannelsAPI: calculated_channel: The CalculatedChannel or ID of the calculated channel to get versions for. client_key: The client key of the calculated channel. name: Exact name of the calculated channel. + names: List of calculated channel names to filter by. name_contains: Partial name of the calculated channel. name_regex: Regular expression string to filter calculated channels by name. created_after: Filter versions created after this datetime. @@ -453,6 +460,7 @@ class ChannelsAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, channel_ids: list[str] | None = None, @@ -472,6 +480,7 @@ class ChannelsAPI: Args: name: Exact name of the channel. + names: List of channel names to filter by. name_contains: Partial name of the channel. name_regex: Regular expression to filter channels by name. channel_ids: Filter to channels with any of these IDs. @@ -515,6 +524,201 @@ 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 archive(self, *, report: str | Report) -> Report: + """Archive a report.""" + ... + + def cancel(self, *, report: str | Report) -> None: + """Cancel a report. + + Args: + report: The Report or report ID to cancel. + """ + ... + + def create_from_applicable_rules( + self, + *, + run: Run | str | None = None, + organization_id: str | None = None, + name: str | None = None, + start_time: datetime | None = None, + end_time: datetime | None = 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. + + Args: + run: The run or run ID to associate with the report. + organization_id: The organization ID. + name: Optional name for the report. + start_time: Optional start time to evaluate rules against. + end_time: Optional end time to evaluate rules against. + + Returns: + The created Report or None if no report was created. + """ + ... + + def create_from_rules( + self, + *, + name: str, + run: Run | str | None = None, + organization_id: str | None = None, + rules: list[Rule] | list[str], + ) -> Report | None: + """Create a new report from rules. + + Args: + name: The name of the report. + run: The run or run ID to associate with the report. + organization_id: The organization ID. + rules: List of rules or rule IDs to include in the report. + + Returns: + The created Report or None if no report was created. + """ + ... + + def create_from_template( + self, + *, + report_template_id: str, + run_id: str, + organization_id: str | None = None, + name: str | None = None, + ) -> Report | None: + """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 or None if no report was created. + """ + ... + + 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, + names: list[str] | None = None, + description_contains: str | None = None, + run: Run | str | None = None, + organization_id: str | None = None, + report_ids: list[str] | None = None, + report_template_id: str | None = None, + metadata: dict[str, str | float | bool] | None = None, + tag_names: list[str] | list[Tag] | None = None, + created_by: str | None = None, + modified_by: str | None = None, + order_by: str | None = None, + limit: int | None = None, + include_archived: bool = False, + filter_query: str | None = None, + created_after: datetime | None = None, + created_before: datetime | None = None, + modified_after: datetime | None = None, + modified_before: datetime | 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. + names: List of report names to filter by. + description_contains: Partial description of the report. + run: Run/run ID to filter by. + organization_id: Organization ID to filter by. + report_ids: List of report IDs to filter by. + report_template_id: Report template ID to filter by. + metadata: Metadata to filter by. + tag_names: List of tags or tag names to filter by. + created_by: The user ID of the creator of the reports. + modified_by: The user ID of the last modifier of the reports. + order_by: How to order the retrieved reports. + limit: How many reports to retrieve. If None, retrieves all matches. + include_archived: Whether to include archived reports. + filter_query: Explicit CEL query to filter reports. + created_after: Filter reports created after this datetime. + created_before: Filter reports created before this datetime. + modified_after: Filter reports modified after this datetime. + modified_before: Filter reports modified before this datetime. + + 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). + """ + ... + + def unarchive(self, *, report: str | Report) -> Report: + """Unarchive a report.""" + ... + + def update(self, report: str | Report, update: ReportUpdate | dict) -> Report: + """Update a report. + + Args: + report: The Report or report ID to update. + update: The updates to apply. + """ + ... + class RulesAPI: """Sync counterpart to `RulesAPIAsync`. @@ -586,6 +790,7 @@ class RulesAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, rule_ids: list[str] | None = None, @@ -597,8 +802,8 @@ class RulesAPI: created_by: Any | str | None = None, modified_by: Any | str | None = None, metadata: list[Any] | None = None, - asset_ids: list[str] | None = None, - asset_tag_ids: list[str] | None = None, + assets: list[str] | list[Asset] | None = None, + asset_tags: list[str | Tag] | None = None, description_contains: str | None = None, include_archived: bool = False, filter_query: str | None = None, @@ -609,10 +814,11 @@ class RulesAPI: Args: name: Exact name of the rule. + names: List of rule names to filter by. name_contains: Partial name of the rule. name_regex: Regular expression string to filter rules by name. - rule_ids: IDs of rules to filter to. client_keys: Client keys of rules to filter to. + rule_ids: IDs of rules to filter to. created_after: Rules created after this datetime. created_before: Rules created before this datetime. modified_after: Rules modified after this datetime. @@ -620,8 +826,8 @@ class RulesAPI: created_by: Filter rules created by this User or user ID. modified_by: Filter rules last modified by this User or user ID. metadata: Filter rules by metadata criteria. - asset_ids: Filter rules associated with any of these Asset IDs. - asset_tag_ids: Filter rules associated with any of these Asset Tag IDs. + assets: Filter rules associated with any of these Assets. + asset_tags: Filter rules associated with any Assets that have these Tag IDs. description_contains: Partial description of the rule. include_archived: If True, include archived rules in results. filter_query: Explicit CEL query to filter rules. @@ -738,6 +944,7 @@ class RunsAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, run_ids: list[str] | None = None, @@ -748,8 +955,10 @@ class RunsAPI: modified_before: datetime | None = None, created_by: Any | str | None = None, modified_by: Any | str | None = None, + tags: list[str | Tag] | None = None, metadata: list[Any] | None = None, assets: list[Asset] | list[str] | None = None, + asset_tags: list[str | Tag] | None = None, duration_less_than: timedelta | None = None, duration_greater_than: timedelta | None = None, start_time_after: datetime | None = None, @@ -767,6 +976,7 @@ class RunsAPI: Args: name: Exact name of the run. + names: List of run names to filter by. name_contains: Partial name of the run. name_regex: Regular expression to filter runs by name. run_ids: Filter to runs with any of these IDs. @@ -777,8 +987,10 @@ class RunsAPI: modified_before: Filter runs modified before this datetime. created_by: Filter runs created by this User or user ID. modified_by: Filter runs last modified by this User or user ID. + tags: Filter runs with any of these Tags IDs. metadata: Filter runs by metadata criteria. assets: Filter runs associated with any of these Assets or asset IDs. + asset_tags: Filter runs associated with any Assets that have these Tag IDs. duration_less_than: Filter runs with duration less than this time. duration_greater_than: Filter runs with duration greater than this time. start_time_after: Filter runs that started after this datetime. @@ -825,6 +1037,100 @@ class RunsAPI: """ ... +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 find_or_create(self, names: list[str]) -> list[Tag]: + """Find tags by name or create them if they don't exist. + + Args: + names: List of tag names to find or create. + + Returns: + List of Tags that were found or created. + """ + ... + + 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, + filter_query: 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. + filter_query: Explicit CEL query to filter tags. + 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. + """ + ... + class TestResultsAPI: """Sync counterpart to `TestResultsAPIAsync`. @@ -967,6 +1273,7 @@ class TestResultsAPI: self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, test_report_ids: list[str] | None = None, @@ -992,6 +1299,7 @@ class TestResultsAPI: Args: name: Exact name of the test report. + names: List of test report names to filter by. name_contains: Partial name of the test report. name_regex: Regular expression string to filter test reports by name. test_report_ids: Test report IDs to filter by. @@ -1025,6 +1333,7 @@ class TestResultsAPI: test_steps: list[str] | list[TestStep] | None = None, test_reports: list[str] | list[TestReport] | None = None, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, measurement_type: TestMeasurementType | None = None, @@ -1039,8 +1348,8 @@ class TestResultsAPI: measurements: Measurements to filter by. test_steps: Test steps to filter by. test_reports: Test reports to filter by. - test_report_id: Test report ID to filter by. name: Exact name of the test measurement. + names: List of test measurement names to filter by. name_contains: Partial name of the test measurement. name_regex: Regular expression string to filter test measurements by name. measurement_type: Measurement type to filter by (TestMeasurementType enum). @@ -1061,6 +1370,7 @@ class TestResultsAPI: test_reports: list[str] | list[TestReport] | None = None, parent_steps: list[str] | list[TestStep] | None = None, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, status: TestStatus | None = None, @@ -1076,6 +1386,7 @@ class TestResultsAPI: test_reports: Test reports to filter by. parent_steps: Parent steps to filter by. name: Exact name of the test step. + names: List of test step names to filter by. name_contains: Partial name of the test step. name_regex: Regular expression string to filter test steps by name. status: Status to filter by (TestStatus enum). diff --git a/python/lib/sift_client/resources/tags.py b/python/lib/sift_client/resources/tags.py new file mode 100644 index 000000000..6187ff7f9 --- /dev/null +++ b/python/lib/sift_client/resources/tags.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +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.util import cel_utils as cel + +if TYPE_CHECKING: + import re + + from sift_client.client import SiftClient + from sift_client.sift_types.tag import Tag, TagUpdate + + +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, + filter_query: 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. + filter_query: Explicit CEL query to filter tags. + 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 = [ + *self._build_name_cel_filters( + name=name, names=names, name_contains=name_contains, name_regex=name_regex + ), + *self._build_common_cel_filters( + filter_query=filter_query, + ), + ] + + if tag_ids: + filter_parts.append(cel.in_("tag_id", tag_ids)) + + query_filter = cel.and_(*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 find_or_create(self, names: list[str]) -> list[Tag]: + """Find tags by name or create them if they don't exist. + + Args: + names: List of tag names to find or create. + + Returns: + List of Tags that were found or created. + """ + tags = await self.list_(names=names) + existing_tag_names = {tag.name for tag in tags} + for name in names: + if name not in existing_tag_names: + tags.append(await self.create(name)) + return tags + + 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") diff --git a/python/lib/sift_client/resources/test_results.py b/python/lib/sift_client/resources/test_results.py index 9b978bd56..5c4f6abda 100644 --- a/python/lib/sift_client/resources/test_results.py +++ b/python/lib/sift_client/resources/test_results.py @@ -98,6 +98,7 @@ async def list_( self, *, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, test_report_ids: list[str] | None = None, @@ -123,6 +124,7 @@ async def list_( Args: name: Exact name of the test report. + names: List of test report names to filter by. name_contains: Partial name of the test report. name_regex: Regular expression string to filter test reports by name. test_report_ids: Test report IDs to filter by. @@ -150,7 +152,7 @@ async def list_( # Build CEL filter filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_time_cel_filters( created_after=created_after, @@ -289,6 +291,7 @@ async def list_steps( test_reports: list[str] | list[TestReport] | None = None, parent_steps: list[str] | list[TestStep] | None = None, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, status: TestStatus | None = None, @@ -304,6 +307,7 @@ async def list_steps( test_reports: Test reports to filter by. parent_steps: Parent steps to filter by. name: Exact name of the test step. + names: List of test step names to filter by. name_contains: Partial name of the test step. name_regex: Regular expression string to filter test steps by name. status: Status to filter by (TestStatus enum). @@ -318,7 +322,7 @@ async def list_steps( # Build CEL filter filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_common_cel_filters( filter_query=filter_query, @@ -453,6 +457,7 @@ async def list_measurements( test_steps: list[str] | list[TestStep] | None = None, test_reports: list[str] | list[TestReport] | None = None, name: str | None = None, + names: list[str] | None = None, name_contains: str | None = None, name_regex: str | re.Pattern | None = None, measurement_type: TestMeasurementType | None = None, @@ -468,6 +473,7 @@ async def list_measurements( test_steps: Test steps to filter by. test_reports: Test reports to filter by. name: Exact name of the test measurement. + names: List of test measurement names to filter by. name_contains: Partial name of the test measurement. name_regex: Regular expression string to filter test measurements by name. measurement_type: Measurement type to filter by (TestMeasurementType enum). @@ -482,7 +488,7 @@ async def list_measurements( # Build CEL filter filter_parts = [ *self._build_name_cel_filters( - name=name, name_contains=name_contains, name_regex=name_regex + name=name, names=names, name_contains=name_contains, name_regex=name_regex ), *self._build_common_cel_filters( filter_query=filter_query, diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index 32aa849fa..e5318dca7 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -142,6 +142,7 @@ ChannelReference, ) from sift_client.sift_types.ingestion import ChannelConfig, Flow, IngestionConfig +from sift_client.sift_types.report import Report, ReportRuleStatus, ReportRuleSummary, ReportUpdate from sift_client.sift_types.rule import ( Rule, RuleAction, @@ -152,6 +153,7 @@ RuleVersion, ) from sift_client.sift_types.run import Run, RunCreate, RunUpdate +from sift_client.sift_types.tag import Tag, TagCreate, TagUpdate from sift_client.sift_types.test_report import ( TestMeasurement, TestMeasurementCreate, @@ -178,6 +180,10 @@ "ChannelReference", "Flow", "IngestionConfig", + "Report", + "ReportRuleStatus", + "ReportRuleSummary", + "ReportUpdate", "Rule", "RuleAction", "RuleActionType", @@ -188,6 +194,9 @@ "Run", "RunCreate", "RunUpdate", + "Tag", + "TagCreate", + "TagUpdate", "TestMeasurement", "TestMeasurementCreate", "TestMeasurementType", diff --git a/python/lib/sift_client/sift_types/asset.py b/python/lib/sift_client/sift_types/asset.py index dfab26c9f..a0f088c7a 100644 --- a/python/lib/sift_client/sift_types/asset.py +++ b/python/lib/sift_client/sift_types/asset.py @@ -6,6 +6,7 @@ from sift.assets.v1.assets_pb2 import Asset as AssetProto from sift_client.sift_types._base import BaseType, MappingHelper, ModelUpdate +from sift_client.sift_types.tag import Tag from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: @@ -24,7 +25,7 @@ class Asset(BaseType[AssetProto, "Asset"]): created_by_user_id: str modified_date: datetime modified_by_user_id: str - tags: list[str] + tags: list[str | Tag] metadata: dict[str, str | float | bool] is_archived: bool @@ -109,7 +110,7 @@ def _from_proto(cls, proto: AssetProto, sift_client: SiftClient | None = None) - class AssetUpdate(ModelUpdate[AssetProto]): """Model of the Asset Fields that can be updated.""" - tags: list[str] | None = None + tags: list[str | Tag] | None = None metadata: dict[str, str | float | bool] | None = None is_archived: bool | None = None @@ -119,6 +120,11 @@ class AssetUpdate(ModelUpdate[AssetProto]): update_field="metadata", converter=metadata_dict_to_proto, ), + "tags": MappingHelper( + proto_attr_path="tags", + update_field="tags", + converter=lambda tags: [tag.name if isinstance(tag, Tag) else tag for tag in tags], + ), } def _get_proto_class(self) -> type[AssetProto]: 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..42f349f42 --- /dev/null +++ b/python/lib/sift_client/sift_types/report.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, ClassVar + +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, MappingHelper, ModelUpdate +from sift_client.sift_types.tag import Tag +from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class ReportRuleStatus(Enum): + """Report rule status.""" + + UNSPECIFIED = 0 + CREATED = 1 + LIVE = 2 + FINISHED = 3 + FAILED = 4 + CANCELED = 5 + ERROR = 6 + + +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: ReportRuleStatus + 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=ReportRuleStatus(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.""" + proto = ReportRuleSummaryProto( + rule_id=self.rule_id or "", + rule_client_key=self.rule_client_key or "", + rule_version_id=self.rule_version_id or "", + rule_version_number=self.rule_version_number, + report_rule_version_id=self.report_rule_version_id or "", + num_open=self.num_open, + num_failed=self.num_failed, + num_passed=self.num_passed, + status=self.status.value, # type: ignore + asset_id=self.asset_id, + ) + proto.created_date.FromDatetime(self.created_date) + proto.modified_date.FromDatetime(self.modified_date) + if self.deleted_date: + proto.deleted_date.FromDatetime(self.deleted_date) + return proto + + +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 | None = None + metadata: dict[str, str | float | bool] + job_id: str + archived_date: datetime | None = None + is_archived: bool + + @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, + metadata=metadata_proto_to_dict(proto.metadata), # type: ignore + job_id=proto.job_id, + archived_date=proto.archived_date.ToDatetime(tzinfo=timezone.utc) + if proto.HasField("archived_date") + else None, + is_archived=proto.is_archived, + _client=sift_client, + ) + + def to_proto(self) -> ReportProto: + """Convert to protobuf message.""" + proto = ReportProto( + report_id=self.id_ or "", + run_id=self.run_id or "", + organization_id=self.organization_id or "", + created_by_user_id=self.created_by_user_id, + modified_by_user_id=self.modified_by_user_id, + name=self.name, + description=self.description, + report_template_id=self.report_template_id or "", + tags=[ReportTagProto(tag_name=tag) for tag in self.tags], + summaries=[summary.to_proto() for summary in self.summaries], + job_id=self.job_id or "", + is_archived=self.is_archived, + ) + proto.created_date.FromDatetime(self.created_date) + proto.modified_date.FromDatetime(self.modified_date) + if self.archived_date: + proto.archived_date.FromDatetime(self.archived_date) + return proto + + def archive(self) -> Report: + """Archive the Report.""" + updated_report = self.client.reports.archive(report=self) + self._update(updated_report) + return self + + def unarchive(self) -> Report: + """Unarchive the Report.""" + updated_report = self.client.reports.unarchive(report=self) + self._update(updated_report) + return self + + +class ReportUpdate(ModelUpdate[ReportProto]): + """Model of the Report fields that can be updated.""" + + is_archived: bool | None = None + metadata: dict[str, str | float | bool] | None = None + tags: list[str | Tag] | None = None + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "metadata": MappingHelper( + proto_attr_path="metadata", update_field="metadata", converter=metadata_dict_to_proto + ), + "tags": MappingHelper( + proto_attr_path="tags", + update_field="tags", + converter=lambda tags: [tag.name if isinstance(tag, Tag) else tag for tag in tags], + ), + } + + def _get_proto_class(self) -> type[ReportProto]: + return ReportProto + + def _add_resource_id_to_proto(self, proto_msg: ReportProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.report_id = self._resource_id diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index df045f105..5713f0b7a 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from enum import Enum from typing import TYPE_CHECKING +from uuid import UUID from sift.rules.v1.rules_pb2 import ( ActionKind, @@ -29,8 +30,9 @@ RuleVersion as RuleVersionProto, ) -from sift_client.sift_types._base import BaseType, ModelCreate, ModelUpdate +from sift_client.sift_types._base import BaseType, ModelCreate, ModelCreateUpdateBase, ModelUpdate from sift_client.sift_types.channel import ChannelReference +from sift_client.sift_types.tag import Tag if TYPE_CHECKING: from sift_client.client import SiftClient @@ -66,7 +68,7 @@ class Rule(BaseType[RuleProto, "Rule"]): @property def assets(self) -> list[Asset]: """Get the assets that this rule applies to.""" - return self.client.assets.list_(asset_ids=self.asset_ids, _tag_ids=self.asset_tag_ids) + return self.client.assets.list_(asset_ids=self.asset_ids, tags=self.asset_tag_ids) @property def organization(self): @@ -84,9 +86,9 @@ def modified_by(self): raise NotImplementedError("Modified by is not supported yet.") @property - def tags(self): + def tags(self) -> list[Tag]: """Get the tags that this rule applies to.""" - raise NotImplementedError("Tags is not supported yet.") + return self.client.tags.list_(tag_ids=self.asset_tag_ids) def update(self, update: RuleUpdate | dict, version_notes: str | None = None) -> Rule: """Update the Rule. @@ -155,7 +157,18 @@ def _from_proto(cls, proto: RuleProto, sift_client: SiftClient | None = None) -> ) -class RuleCreate(ModelCreate[CreateRuleRequest]): +class RuleCreateUpdateBase(ModelCreateUpdateBase): + """Base class for Rule create and update models with shared fields and validation.""" + + organization_id: str | None = None + client_key: str | None = None + asset_ids: list[str] | None = None + asset_tag_ids: list[str] | None = None + contextual_channels: list[str] | None = None + is_external: bool = False + + +class RuleCreate(RuleCreateUpdateBase, ModelCreate[CreateRuleRequest]): """Model for creating a new Rule. Note: @@ -168,23 +181,18 @@ class RuleCreate(ModelCreate[CreateRuleRequest]): expression: str channel_references: list[ChannelReference] action: RuleAction - organization_id: str | None = None - client_key: str | None = None - asset_ids: list[str] | None = None - asset_tag_ids: list[str] | None = None - contextual_channels: list[str] | None = None - is_external: bool = False def _get_proto_class(self) -> type[CreateRuleRequest]: return CreateRuleRequest -class RuleUpdate(ModelUpdate[RuleProto]): +class RuleUpdate(RuleCreateUpdateBase, ModelUpdate[RuleProto]): """Model of the Rule fields that can be updated. Note: - - asset_ids applies this rule to those assets. - - asset_tag_ids applies this rule to assets with those tags. + - assets applies this rule to those assets. + - asset_tags applies this rule to assets with those tags. + - contextual_channels are shown by UI to give context when viewing an annotation, but are not part of rule evaluation. """ name: str | None = None @@ -192,9 +200,6 @@ class RuleUpdate(ModelUpdate[RuleProto]): expression: str | None = None channel_references: list[ChannelReference] | None = None action: RuleAction | None = None - asset_ids: list[str] | None = None - asset_tag_ids: list[str] | None = None - contextual_channels: list[str] | None = None is_archived: bool | None = None def _get_proto_class(self) -> type[RuleProto]: @@ -267,28 +272,34 @@ class RuleAction(BaseType[RuleActionProto, "RuleAction"]): modified_by_user_id: str | None = None version_id: str | None = None annotation_type: RuleAnnotationType | None = None - tags: list[str] | None = None - default_assignee_user_id: str | None = None + tags_ids: list[str] | None = None + default_assignee_user: str | None = None @classmethod def annotation( cls, annotation_type: RuleAnnotationType, - tags: list[str], - default_assignee_user_id: str | None = None, + tags: list[str | Tag], + default_assignee_user: str | None = None, ) -> RuleAction: """Create an annotation action. Args: annotation_type: Type of annotation to create. - default_assignee_user_id: User ID to assign the annotation to. - tags: List of tag IDs to add to the annotation. + default_assignee_user: User ID to assign the annotation to. + tags: List of tags or tag IDs to add to the annotation. """ + validated_tags = ( + [str(UUID(tag.id_)) if isinstance(tag, Tag) else str(UUID(tag)) for tag in tags] + if tags + else None + ) + return cls( action_type=RuleActionType.ANNOTATION, annotation_type=annotation_type, - tags=tags, - default_assignee_user_id=default_assignee_user_id, + tags_ids=validated_tags, + default_assignee_user=default_assignee_user, ) @classmethod @@ -304,12 +315,12 @@ def _from_proto( created_by_user_id=proto.created_by_user_id, modified_by_user_id=proto.modified_by_user_id, version_id=proto.rule_action_version_id, - tags=( + tags_ids=( list(proto.configuration.annotation.tag_ids) if proto.configuration.annotation.tag_ids else None ), - default_assignee_user_id=( + default_assignee_user=( proto.configuration.annotation.assigned_to_user_id if proto.configuration.annotation.assigned_to_user_id else None @@ -326,13 +337,14 @@ def _from_proto( ) def _to_update_request(self) -> UpdateActionRequest: + tags_ids = [str(UUID(tag)) for tag in self.tags_ids] if self.tags_ids else None return UpdateActionRequest( action_type=self.action_type.value, configuration=RuleActionConfiguration( annotation=( AnnotationActionConfiguration( - assigned_to_user_id=self.default_assignee_user_id, - tag_ids=self.tags, + assigned_to_user_id=self.default_assignee_user, + tag_ids=tags_ids, annotation_type=self.annotation_type.value, # type: ignore ) if self.action_type == RuleActionType.ANNOTATION @@ -341,6 +353,11 @@ def _to_update_request(self) -> UpdateActionRequest: ), ) + @property + def tags(self) -> list[Tag]: + """Get the tags that this rule action applies to.""" + return self.client.tags.list_(tag_ids=self.tags_ids) if self.tags_ids else [] + class RuleVersion(BaseType[RuleVersionProto, "RuleVersion"]): """Model of a Rule Version.""" diff --git a/python/lib/sift_client/sift_types/run.py b/python/lib/sift_client/sift_types/run.py index eb3e081b1..2e7816a5b 100644 --- a/python/lib/sift_client/sift_types/run.py +++ b/python/lib/sift_client/sift_types/run.py @@ -14,6 +14,7 @@ ModelCreateUpdateBase, ModelUpdate, ) +from sift_client.sift_types.tag import Tag from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict if TYPE_CHECKING: @@ -123,7 +124,7 @@ class RunBase(ModelCreateUpdateBase): description: str | None = None start_time: datetime | None = None stop_time: datetime | None = None - tags: list[str] | None = None + tags: list[str] | list[Tag] | None = None metadata: dict[str, str | float | bool] | None = None _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { @@ -132,6 +133,11 @@ class RunBase(ModelCreateUpdateBase): update_field="metadata", converter=metadata_dict_to_proto, ), + "tags": MappingHelper( + proto_attr_path="tags", + update_field="tags", + converter=lambda tags: [tag.name if isinstance(tag, Tag) else tag for tag in tags], + ), } @model_validator(mode="after") @@ -152,20 +158,8 @@ class RunCreate(RunBase, ModelCreate[CreateRunRequestProto]): name: str client_key: str | None = None - tags: list[str] | None = None - metadata: dict[str, str | float | bool] | None = None - start_time: datetime | None = None - stop_time: datetime | None = None organization_id: str | None = None - _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { - "metadata": MappingHelper( - proto_attr_path="metadata", - update_field="metadata", - converter=metadata_dict_to_proto, - ), - } - def _get_proto_class(self) -> type[CreateRunRequestProto]: return CreateRunRequestProto @@ -174,20 +168,8 @@ class RunUpdate(RunBase, ModelUpdate[RunProto]): """Update model for Run.""" name: str | None = None - tags: list[str] | None = None - metadata: dict[str, str | float | bool] | None = None - start_time: datetime | None = None - stop_time: datetime | None = None is_archived: bool | None = None - _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { - "metadata": MappingHelper( - proto_attr_path="metadata", - update_field="metadata", - converter=metadata_dict_to_proto, - ), - } - def _get_proto_class(self) -> type[RunProto]: return RunProto 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..6a565c657 --- /dev/null +++ b/python/lib/sift_client/sift_types/tag.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sift.tags.v2.tags_pb2 import CreateTagRequest as CreateTagRequestProto +from sift.tags.v2.tags_pb2 import Tag as TagProto + +from sift_client._internal.util.timestamp import to_pb_timestamp +from sift_client.sift_types._base import ( + BaseType, + ModelCreate, + ModelCreateUpdateBase, + ModelUpdate, +) + +if TYPE_CHECKING: + from sift_client.client import SiftClient + + +class TagCreateUpdateBase(ModelCreateUpdateBase): + """Base model for Tag create and update.""" + + name: str + + +class TagCreate(TagCreateUpdateBase, ModelCreate[CreateTagRequestProto]): + """Create model for Tag.""" + + def _get_proto_class(self) -> type[CreateTagRequestProto]: + return CreateTagRequestProto + + +class TagUpdate(TagCreateUpdateBase, ModelUpdate[TagProto]): + """Update model for Tag.""" + + 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 + + def _get_proto_class(self) -> type[TagProto]: + return TagProto + + +class Tag(BaseType[TagProto, "Tag"]): + """Model of the Sift Tag.""" + + 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, + proto=proto, + 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=to_pb_timestamp(self.created_date), + ) + return proto + + def __str__(self) -> str: + return self.name diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 58319e33d..143c04e52 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 ( @@ -9,8 +9,10 @@ ChannelsAPIAsync, IngestionAPIAsync, PingAPIAsync, + ReportsAPIAsync, RulesAPIAsync, RunsAPIAsync, + TagsAPIAsync, TestResultsAPIAsync, ) @@ -33,11 +35,22 @@ 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.""" rules: RulesAPIAsync """Instance of the Rules API for making asynchronous requests.""" + tags: TagsAPIAsync + """Instance of the Tags API for making asynchronous requests.""" + test_results: TestResultsAPIAsync """Instance of the Test Results 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)