diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a858204e..5e4eb80f9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,40 @@ History All release highlights of this project will be documented in this file. +4.6.2 - Oct 12, 2026 +____________________ + + +**Added** + + - ``SAClient.generate_team_api_key()`` Generates a new API key for the specified team. + + - ``SAClient.rotate_team_api_key()`` Generates a new active Team API Key while keeping the previous key valid for a configurable overlap period, enabling key rotation without downtime. + + - ``SAClient.list_team_api_keys()`` Returns Team API Keys for the specified team. + + - ``SAClient.revoke_team_api_key()`` Revokes a Team API Key that is in the Active or Rotating state. + + - ``SAClient.explore`` Added a new namespace for working with Explore-related SDK functions. + + - ``SAClient.explore.query()`` Returns items that satisfy a query string as a `QueryResult`, which behaves like a list of dictionaries and provides a `count()` method. + + - ``SAClient.explore.query().count()`` Returns the subsets available in a project. + + - ``SAClient.explore.get_subsets()`` Returns the subsets available in a project. + + - ``SAClient.explore.add_items_to_subset()`` Associates items with a subset and creates the subset if it does not already exist. + +**Deprecated** + + - ``SAClient.query()`` and ``SAClient.query().count()`` Use ``SAClient.explore.query()``. Will be removed in 4.7.0. + + - ``SAClient.get_subsets()`` Use ``SAClient.explore.get_subsets()``. Will be removed in 4.7.0. + + - ``SAClient.add_items_to_subset()`` Use ``SAClient.explore.add_items_to_subset()``. Will be removed in 4.7.0. + + + 4.6.1 - Sep 13, 2026 ____________________ diff --git a/docs/source/api_reference/api_client.rst b/docs/source/api_reference/api_client.rst index 4e2db478c..ac4300331 100644 --- a/docs/source/api_reference/api_client.rst +++ b/docs/source/api_reference/api_client.rst @@ -15,6 +15,7 @@ Contents api_annotation_class api_export api_custom_metadata + api_explore api_subset api_image api_team diff --git a/docs/source/api_reference/api_explore.rst b/docs/source/api_reference/api_explore.rst new file mode 100644 index 000000000..7f2e5605d --- /dev/null +++ b/docs/source/api_reference/api_explore.rst @@ -0,0 +1,11 @@ +======= +Explore +======= + +``SAClient.explore`` groups the functions that operate on the Explore surface of the +platform. Every query in this namespace uses the SuperAnnotate query language +documented at https://doc.superannotate.com/docs/queries-beta. + +.. automethod:: lib.app.interface.sdk.explore.Explore.query +.. automethod:: lib.app.interface.sdk.explore.Explore.get_subsets +.. automethod:: lib.app.interface.sdk.explore.Explore.add_items_to_subset diff --git a/docs/source/api_reference/api_team.rst b/docs/source/api_reference/api_team.rst index d34c1b0f3..3f111f013 100644 --- a/docs/source/api_reference/api_team.rst +++ b/docs/source/api_reference/api_team.rst @@ -19,3 +19,7 @@ Team .. automethod:: superannotate.SAClient.remove_contributors_categories .. automethod:: superannotate.SAClient.grant_team_user_permissions .. automethod:: superannotate.SAClient.revoke_team_user_permissions +.. automethod:: superannotate.SAClient.generate_team_api_key +.. automethod:: superannotate.SAClient.rotate_team_api_key +.. automethod:: superannotate.SAClient.list_team_api_keys +.. automethod:: superannotate.SAClient.revoke_team_api_key diff --git a/pytest.ini b/pytest.ini index 29a68f0a7..4d372b1b8 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -;addopts = -n 8 --dist loadscope +addopts = -n 8 --dist loadscope diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 5796d01eb..75a6af120 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.6.1" +__version__ = "4.6.2dev1" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/helpers.py b/src/superannotate/lib/app/helpers.py index 73fbef169..f8384f52f 100644 --- a/src/superannotate/lib/app/helpers.py +++ b/src/superannotate/lib/app/helpers.py @@ -1,16 +1,55 @@ from __future__ import annotations import os +import re +import sys import uuid +import warnings from pathlib import Path import boto3 import numpy as np import pandas as pd +import pydantic from lib.core import ATTACHED_VIDEO_ANNOTATION_POSTFIX from lib.core import VECTOR_ANNOTATION_POSTFIX from lib.core.exceptions import AppException +# Frames in these directories are the SDK's own wrappers (Tracker, argument +# validation) - a deprecation warning is attributed to the first frame outside them. +_WRAPPER_DIRS = ( + str(Path(__file__).resolve().parents[2]), # the superannotate package + str(Path(pydantic.__file__).resolve().parent), +) + + +# Python shows a DeprecationWarning by default only when it is attributed to +# __main__, so a deprecated SDK method called from any other module (a package, a +# notebook helper, a test) would be silent. Show the SDK's own deprecations wherever +# they are called from. Skipped when the user configured warnings themselves (-W or +# PYTHONWARNINGS); filters they add in code later still take precedence. +DEPRECATION_MESSAGE_PATTERN = r"This function .+ will be deprecated" +if not sys.warnoptions: + warnings.filterwarnings( + "default", message=DEPRECATION_MESSAGE_PATTERN, category=DeprecationWarning + ) + + +def warn_deprecated(message: str): + """Emit a DeprecationWarning attributed to the user's call. + + The message must match DEPRECATION_MESSAGE_PATTERN to be shown by default. The + stacklevel is computed rather than fixed: a fixed one would land inside the + wrappers every public method goes through (Tracker, argument validation). + """ + assert re.match(DEPRECATION_MESSAGE_PATTERN, message), message + stacklevel = 1 + frame = sys._getframe(0) + while frame is not None and frame.f_code.co_filename.startswith(_WRAPPER_DIRS): + frame = frame.f_back + stacklevel += 1 + warnings.warn(message, DeprecationWarning, stacklevel=stacklevel) + def get_annotation_paths(folder_path, s3_bucket=None, recursive=False): annotation_paths = [] diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 07a442a43..b7a60f1ec 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -230,8 +230,11 @@ def get_default_payload(team_name, user_email, auth_type) -> dict: "Env": os.environ.get("SA_ENV", "N/A"), } - def __init__(self, function): + def __init__(self, function, event_name: str | None = None): self.function = function + # Reported instead of the function's own name, e.g. for a helper that stands + # in for a method the user calls on a returned object (QueryResult.count). + self.event_name = event_name functools.update_wrapper(self, function) @staticmethod @@ -253,7 +256,7 @@ def extract_arguments(function, *args, **kwargs) -> dict: def default_parser(function_name: str, kwargs: dict) -> tuple: properties = {} for key, value in kwargs.items(): - if key == "self": + if key == "self" or key.startswith("_"): continue if key == "token": properties["sa_token"] = str(bool(value)) @@ -318,7 +321,14 @@ def _track_method( try: function_name = self.function.__name__ if self.function else "" arguments = self.extract_arguments(self.function, *args, **kwargs) - event_name, properties = self.default_parser(function_name, arguments) + event_name, properties = self.default_parser( + self.event_name or function_name, arguments + ) + # A namespace (e.g. SAClient.explore) reports its methods as + # ".", so they don't collide with SAClient's own. + tracking_prefix = getattr(instance, "TRACKING_PREFIX", None) + if tracking_prefix: + event_name = f"{tracking_prefix}.{event_name}" # instance is args[0] - the actual object the call was made on, captured # locally per call (see __call__), not shared/mutable state. It has no diff --git a/src/superannotate/lib/app/interface/sdk/__init__.py b/src/superannotate/lib/app/interface/sdk/__init__.py index e69de29bb..b6529151e 100644 --- a/src/superannotate/lib/app/interface/sdk/__init__.py +++ b/src/superannotate/lib/app/interface/sdk/__init__.py @@ -0,0 +1,12 @@ +class BaseNamespace: + """A group of SAClient methods exposed as an attribute, e.g. SAClient.explore. + + __init__ lives here rather than on the namespace, so TrackableMeta (applied to the + namespace) does not report building one as a telemetry event. + """ + + #: Mixpanel events of the namespace's methods are reported as ".". + TRACKING_PREFIX: str + + def __init__(self, controller): + self.controller = controller diff --git a/src/superannotate/lib/app/interface/sdk/explore.py b/src/superannotate/lib/app/interface/sdk/explore.py new file mode 100644 index 000000000..3075dade1 --- /dev/null +++ b/src/superannotate/lib/app/interface/sdk/explore.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from functools import partial +from typing import Annotated + +from lib.app.interface.base_interface import TrackableMeta +from lib.app.interface.base_interface import Tracker +from lib.app.interface.responses import QueryResult +from lib.app.interface.sdk import BaseNamespace +from lib.app.serializers import BaseSerializer +from lib.core.exceptions import AppException +from pydantic import StringConstraints + +NotEmptyStr = Annotated[str, StringConstraints(strict=True, min_length=1)] + + +class Explore(BaseNamespace, metaclass=TrackableMeta): + """Namespace for querying and curating project data with the Explore query language. + + Accessed as ``SAClient.explore``. Groups the functions that operate on the Explore + surface of the platform. Every query in this namespace uses the SuperAnnotate query + language documented at https://doc.superannotate.com/docs/queries-beta. + + Available methods: + + - ``explore.query()`` - Return items that satisfy a query string. Returns a + QueryResult, which behaves like a list of dicts and exposes a ``.count()`` method. + - ``explore.query().count()`` - Returns the total number of items matching the + query, without fetching item metadata. + - ``explore.get_subsets()`` - Return the subsets that exist in a project. + - ``explore.add_items_to_subset()`` - Associate items with a subset, creating the + subset if it does not exist. + """ + + TRACKING_PREFIX = "explore" + + def query( + self, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], + query: NotEmptyStr | None = None, + subset: NotEmptyStr | None = None, + ) -> QueryResult: + """Return items that satisfy the given Explore query. + Query syntax should be in the SuperAnnotate query language + (https://doc.superannotate.com/docs/queries-beta). + + The returned QueryResult behaves like a list of dicts, and additionally exposes a .count() method. + + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional. + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] + + :param query: Explore query string. + :type query: str + + :param subset: Subset name. Restricts the query to items in the given subset. + To return all the items in the specified subset, leave the query as None. + :type subset: str + + :return: queried items' metadata list + :rtype: QueryResult (list of dicts with .count() method) + + :raises AppException: If both query and subset are left empty, if the project, + folder or subset is not found, or with the backend's message if the query is + rejected (e.g. an invalid query string, or a project that was never resynced). + + Request Example: + :: + + # To query by status: + sa_client.explore.query( + project="Image Project", + query='_status = "InProgress"' + ) + + # To query within a subset: + sa_client.explore.query( + project="Image Project", + query='_status = "InProgress"', + subset="golden-set" + ) + + Response Example: + :: + + [ + { + "id": 196560932, + "name": "TEST_00001", + "folder_id": 867191, + "comments": [], + "annotation_status": "InProgress", + "path": "custom_llm", + "approval_status": 0, + "createdAt": "2026-08-04T03:50:53.000Z", + "updatedAt": "2026-09-02T10:37:18.000Z", + "meta": {"integration_id": None}, + "instances": [], + "custom_metadata": {}, + "subset_ids": [], + "category_id": [], + "category_name": None, + "assignment": [], + "last_action": { + "date": "2026-09-02T10:37:17.217Z", + "email": "test@superannotate.com" + }, + "score": [], + "is_pinned": 0, + "folder_name": "root", + "is_root_folder": 1 + } + ] + + .. py:method:: query.count() -> int + + Returns the total number of items matching the query, without fetching item metadata. + + :return: total number of matching items + :rtype: int + + Request Example: + :: + + total = sa_client.explore.query( + project="Image Project", query='_status = "InProgress"' + ).count() + print(f"Total matching items: {total}") + """ + if not any([query, subset]): + raise AppException("Provide 'query' or 'subset'") + project_entity, folder = self.controller.get_project_folder(project) + return QueryResult( + data_fetcher=lambda: [ + item.model_dump() + for item in self.controller.explore_query( + project_entity, folder, query, subset + ) + ], + count_fetcher=partial( + self._count, + query=query, + subset=subset, + _resolved=(project_entity, folder), + ), + ) + + def _count(self, query, subset, _resolved) -> int: + """Backs QueryResult.count() of explore.query(). + + query and subset are reported on the "explore.query.count" event; the already + resolved entities come in _resolved, which telemetry skips. + """ + project_entity, folder = _resolved + return self.controller.explore_query_count( + project_entity, folder, query, subset + ) + + _count = Tracker(_count, event_name="query.count") + + def get_subsets(self, project: NotEmptyStr | int): + """Returns the subsets that exist in the given project. + + :param project: The name or ID of the project. + :type project: Union[str, int] + + :return: subsets' metadata + :rtype: list of dicts + + Request Example: + :: + + subsets = sa_client.explore.get_subsets(project="Image Project") + + Response Example: + :: + + [{'name': 'test_subset'}] + """ + project = self.controller.get_project(project) + response = self.controller.subsets.list(project) + if response.errors: + raise AppException(response.errors) + return BaseSerializer.serialize_iterable(response.data, ["name"]) + + def add_items_to_subset( + self, project: NotEmptyStr | int, subset: NotEmptyStr, items: list[dict] + ): + """Associates selected items with a given subset. Non-existing subset will be automatically created. + + :param project: The name or ID of the project. + :type project: Union[str, int] + + :param subset: a name of an existing/new subset to associate items with. + New subsets will be automatically created. + :type subset: str + + :param items: list of items metadata. + Required keys are 'name' and 'path' if the 'id' key is not provided in the dict. + :type items: list of dicts + + :return: dictionary with succeeded, skipped, and failed items lists. + :rtype: dict + + Request Example: + :: + + sa_client = SAClient() + + # option 1 + queried_items = sa_client.explore.query( + project="Image Project", + query='_status = "InProgress"' + ) + + sa_client.explore.add_items_to_subset( + project="Medical Annotations", + subset="Brain Study - Disapproved", + items=queried_items + ) + + # option 2 + items_list = [ + { + 'name': 'image_1.jpeg', + 'path': 'Image Project' + }, + { + 'name': 'image_2.jpeg', + 'path': 'Image Project/Subfolder A' + } + ] + + sa_client.explore.add_items_to_subset( + project="Image Project", + subset="Subset Name", + items=items_list + ) + + Response Example: + :: + + { + "succeeded": [ + { + 'name': 'image_1.jpeg', + 'path': 'Image Project' + }, + { + 'name': 'image_2.jpeg', + 'path': 'Image Project/Subfolder A' + } + ], + "failed": [], + "skipped": [] + } + """ + project = self.controller.get_project(project) + response = self.controller.subsets.add_items(project, subset, items) + if response.errors: + raise AppException(response.errors) + return response.data diff --git a/src/superannotate/lib/app/interface/sdk/project.py b/src/superannotate/lib/app/interface/sdk/project.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 1e8968871..2104d0155 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -11,6 +11,7 @@ import warnings from collections.abc import Callable from collections.abc import Iterable +from functools import cached_property from functools import partial from pathlib import Path from typing import Annotated @@ -35,12 +36,14 @@ from lib.infrastructure.controller import OrgController from lib.infrastructure.controller import TeamController from lib.app.helpers import get_annotation_paths +from lib.app.helpers import warn_deprecated from lib.app.helpers import get_name_url_duplicated_from_csv from lib.app.helpers import wrap_error as wrap_validation_errors from lib.app.interface.base_interface import BaseInterfaceFacade from lib.app.interface.base_interface import TrackableMeta from lib.app.interface.types import EmailStr +from lib.app.interface.types import ExpiresIn from lib.app.serializers import BaseSerializer from lib.app.serializers import FolderSerializer from lib.app.serializers import ProjectSerializer @@ -88,6 +91,7 @@ from lib.infrastructure.query_builder import FieldValidationHandler from lib.app.interface.responses import QueryResult +from lib.app.interface.sdk.explore import Explore logger = logging.getLogger("sa") @@ -340,6 +344,13 @@ def team_id(self) -> int: """The team this client operates in.""" return self.controller.team_id + @cached_property + def explore(self) -> Explore: + """Namespace for querying and curating project data with the Explore query language. + See :class:`Explore`. + """ + return Explore(self.controller) + def get_project_by_id(self, project_id: int): """Returns the project metadata @@ -1408,6 +1419,254 @@ def revoke_team_user_permissions( operation="revoke", ) + def generate_team_api_key( + self, + name: str, + expires_in: ExpiresIn = 365, + ) -> dict: + """ + Generates a new team API key bound to the given team. Must use Organization API Key for this function. + + :param name: Key name. + :type name: str + + :param expires_in: When the key expires: + + - **int**: number of days from now (e.g., 30, 90, 365). + - **timedelta**: duration from now (e.g., timedelta(days=90)). + - **datetime**: absolute expiry date (e.g., datetime(2027, 1, 1)). + + Cannot exceed 365 days from the creation date. Defaults to 365 days. + :type expires_in: Union[int, timedelta, datetime], optional + + :return: The generated key, including the ``api_key`` value itself. The value + is returned only here and cannot be retrieved later. + :rtype: dict + + Request Example: + :: + + # To create a 30-day key: + + sa_client.generate_team_api_key( + name="CI pipeline key", + expires_in=datetime.now() + timedelta(days=30) + ) + + # To create a key with the default 365-day expiration: + + sa_client.generate_team_api_key(name="Local dev key") + + # To create a key with custom expiration: + + sa_client.generate_team_api_key(name="API key", expires_in=timedelta(days=365)) + + Response Example: + :: + + { + "id": 9010, + "public_id": "XBb9i1glPi8SCaHW", + "name": "new_api_key", + "scope": { + "team_id": 85922 + }, + "created_by": "test@superannotate.com", + "status": "ACTIVE", + "expiresAt": "2027-08-13T05:22:31.421Z", + "createdAt": "2026-08-13T05:22:32.044Z", + "updatedAt": "2026-08-13T05:22:32.044Z", + "api_key": "" + } + """ + key = self.controller.work_management.generate_team_api_key( + name=name, expires_in=expires_in + ) + return BaseSerializer(key).serialize(by_alias=True, exclude_unset=True) + + def rotate_team_api_key( + self, + name: str, + overlap: Literal[0, 1, 7, 14, 30] = 7, + expires_in: ExpiresIn = 365, + ) -> dict: + """ + Generates a new active key and keeps the old one valid for a defined overlap window, + supporting the two-valid-key model with no downtime. Must use Organization API Key for this function. + + + :param name: Name or public id of the key to rotate. A name is not unique - + a rotation leaves the old key behind under the same name - so of several + the Active one is rotated. + :type name: str + + :param overlap: How long the rotated key stays valid: 0 to expire it + immediately, or 1, 7, 14, 30 days. Defaults to 7 days. + :type overlap: int, optional + + :param expires_in: When the new key expires: + + - **int**: number of days from now (e.g., 30, 90, 365). + - **timedelta**: duration from now (e.g., timedelta(days=90)). + - **datetime**: absolute expiry date (e.g., datetime(2027, 1, 1)). + + Cannot exceed 365 days from the creation date. Defaults to 365 days. + :type expires_in: Union[int, timedelta, datetime], optional + + :return: The new key, including the ``api_key`` value itself. The value is + returned only here and cannot be retrieved later. + :rtype: dict + + Request Example: + :: + + # To rotate with a 24-hour overlap and a new 90-day expiration: + + sa_client.rotate_team_api_key(name="api_key_to_rotate", overlap=1, expires_in=90) + + # To rotate immediately with no overlap: + + sa_client.rotate_team_api_key(name="api_key_to_rotate", overlap=0) + + Response Example: + :: + + { + "id": 9010, + "public_id": "XBb9i1glPi8SCaHW", + "name": "api_key_to_rotate", + "scope": { + "team_id": 85922 + }, + "created_by": "test@superannotate.com", + "status": "ACTIVE", + "expiresAt": "2027-08-13T05:22:31.421Z", + "createdAt": "2026-08-13T05:22:32.044Z", + "updatedAt": "2026-08-13T05:22:32.044Z", + "api_key": "" + } + """ + key = self.controller.work_management.rotate_team_api_key( + name=name, overlap=overlap, expires_in=expires_in + ) + return BaseSerializer(key).serialize(by_alias=True, exclude_unset=True) + + def list_team_api_keys(self, **filters) -> list[dict]: + """ + Returns the team API keys in the given team, newest first. Can use Organization API Key, + Team API Key or Personal API Key for this function. + + + :param filters: Specifies filtering criteria, with all conditions combined + using logical AND. + + - Only team api keys matching all filter conditions are returned. + + - If no filter operation is provided, an exact match is applied. + + Supported operations: + + - __in: Value is in the provided list. + - __notin: Value is not in the provided list. + - __ne: Value is not equal to the given value. + - __contains: Value contains the specified substring. + - __starts: Value starts with the given prefix. + - __ends: Value ends with the given suffix. + + Filter params:: + + - id: int + - id__in: list[int] + - name: str + - name__in: list[str] + - name__contains: str + - name__starts: str + - name__ends: str + - status: Literal["Active", "Rotating", "Expired", "Revoked"] + - status__ne: Literal["Active", "Rotating", "Expired", "Revoked"] + - status__in: List[Literal["Active", "Rotating", "Expired", "Revoked"]] + - status__notin: List[Literal["Active", "Rotating", "Expired", "Revoked"]] + + :type filters: TeamAPIKeyFilters, optional + + :return: The team's API keys that match the filtering criteria; an empty list + if the team has no keys or none of them match. + :rtype: list of dicts + + Request Example: + :: + + sa_client.list_team_api_keys(status__in=["Active", "Rotating"]) + + Response Example: + :: + + [ + { + "id": 9010, + "public_id": "XBb9i1glPi8SCaHW", + "name": "active_api_key", + "scope": { + "team_id": 85922 + }, + "created_by": "test@superannotate.com", + "revoked_by": None, + "rotation_parent_id": None, + "status": "ACTIVE", + "deletedAt": None, + "expiresAt": "2027-08-13T05:22:31.421Z", + "revokedAt": None, + "overlapEndAt": None, + "createdAt": "2026-08-13T05:22:32.044Z", + "updatedAt": "2026-08-13T05:22:32.044Z", + "lastUsedAt": None + }, + { + "id": 8880, + "public_id": "otwr84WkHXh64Wms", + "name": "rotating_api_key", + "scope": { + "team_id": 85922 + }, + "created_by": "test@superannotate.com", + "revoked_by": None, + "rotation_parent_id": None, + "status": "ROTATING", + "deletedAt": None, + "expiresAt": "2026-08-14T05:22:31.421Z", + "revokedAt": None, + "overlapEndAt": "2026-08-14T05:22:31.421Z", + "createdAt": "2026-08-12T11:09:42.103Z", + "updatedAt": "2026-08-13T05:22:32.027Z", + "lastUsedAt": None + } + ] + """ + return BaseSerializer.serialize_iterable( + self.controller.work_management.list_team_api_keys(**filters), + by_alias=True, + exclude_unset=True, + ) + + def revoke_team_api_key(self, public_id: str) -> None: + """ + Revokes the team API key. Team API key should be in Active or Rotating state. + Can use Organization API Key, Team API Key or Personal API Key for this function. + + :param public_id: The public id of the key to revoke. + :type public_id: str + + :rtype: None + + Request Example: + :: + + # Revoke team API key + sa_client.revoke_team_api_key(public_id="UwBK9syTtXUGROrA") + """ + key = self.controller.work_management.revoke_team_api_key(public_id=public_id) + logger.info(f"Successfully revoked API key: {key.name}.") + def get_component_config(self, project: NotEmptyStr | int, component_id: str): """ Retrieves the configuration for a given project and component ID. @@ -1930,6 +2189,7 @@ def delete_project(self, project: NotEmptyStr | int): try: project = self.controller.get_project(project) except AppException as e: + logger.info("Project not found.") if str(e) == "Project not found.": return raise @@ -4689,21 +4949,36 @@ def query( ).count() print(f"Total matching items: {total}") """ + warn_deprecated( + "This function query() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.query()\n" + "Note: explore.query() uses a different query language. Existing queries " + "must be rewritten." + ) project, folder = self.controller.get_project_folder(project) fetch_entities = partial( self.controller.query_entities, project, folder, query, subset ) + fetch_count = partial( + self.controller.query_items_count, + project=project, + folder=folder, + query=query, + subset=subset, + ) + + def count_fetcher() -> int: + warn_deprecated( + "This function query().count() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.query().count()" + ) + return fetch_count() + return QueryResult( data_fetcher=lambda: BaseSerializer.serialize_iterable( fetch_entities(), exclude={"meta"} ), - count_fetcher=partial( - self.controller.query_items_count, - project=project, - folder=folder, - query=query, - subset=subset, - ), + count_fetcher=count_fetcher, ) def get_item_metadata( @@ -5541,6 +5816,10 @@ def get_subsets(self, project: NotEmptyStr | int): :return: subsets’ metadata :rtype: list of dicts """ + warn_deprecated( + "This function get_subsets() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.get_subsets()" + ) project = self.controller.get_project(project) response = self.controller.subsets.list(project) if response.errors: @@ -5916,7 +6195,10 @@ def add_items_to_subset( "skipped": [] } """ - + warn_deprecated( + "This function add_items_to_subset() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.add_items_to_subset()" + ) project = self.controller.get_project(project) response = self.controller.subsets.add_items(project, subset, items) if response.errors: diff --git a/src/superannotate/lib/app/interface/types.py b/src/superannotate/lib/app/interface/types.py index 3d5f8d4bb..1e3417fba 100644 --- a/src/superannotate/lib/app/interface/types.py +++ b/src/superannotate/lib/app/interface/types.py @@ -1,10 +1,14 @@ import re +from datetime import datetime +from datetime import timedelta from functools import wraps from typing import Annotated from lib.core.exceptions import AppException from lib.infrastructure.validators import wrap_error from pydantic import AfterValidator +from pydantic import Strict +from pydantic import StrictInt from pydantic import validate_call as pydantic_validate_arguments from pydantic import ValidationError @@ -25,6 +29,12 @@ def _validate_email(value: str) -> str: EmailStr = Annotated[str, AfterValidator(_validate_email)] +#: When something expires: a number of days from now, a duration, or the date itself. +#: Every member is strict on purpose - left lax, ``30.5`` is no valid number of days, +#: so it would be read as 30.5 *seconds* through timedelta and silently expire in half +#: a minute. +ExpiresIn = StrictInt | Annotated[timedelta, Strict()] | Annotated[datetime, Strict()] + def validate_arguments(func): @wraps(func) diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 325878ef1..bd6a1c606 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -200,6 +200,10 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): } +API_KEY_NOT_FOUND_ERROR = "API key not found" +API_KEY_ALREADY_REVOKED_ERROR = "This key is already revoked or expired" + + PROJECT_SETTINGS_VALID_ATTRIBUTES = [ "Brightness", "Fill", diff --git a/src/superannotate/lib/core/entities/filters.py b/src/superannotate/lib/core/entities/filters.py index 59fb49565..724948176 100644 --- a/src/superannotate/lib/core/entities/filters.py +++ b/src/superannotate/lib/core/entities/filters.py @@ -45,6 +45,18 @@ class FolderFilters(ProjectFilters): pass +#: The statuses a team API key can be filtered by, spelled as the SDK reports them +#: rather than in the backend's own upper case. +TEAM_API_KEY_STATUS = Literal["Active", "Rotating", "Expired", "Revoked"] + + +class TeamAPIKeyFilters(BaseFilters, total=False): + status: TEAM_API_KEY_STATUS + status__ne: TEAM_API_KEY_STATUS + status__in: list[TEAM_API_KEY_STATUS] + status__notin: list[TEAM_API_KEY_STATUS] + + class BaseUserFilters(TypedDict, total=False): id: int | None id__in: list[int] | None diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index f279d790c..34b1ea9a1 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -10,6 +10,7 @@ from lib.core.enums import WMGroupTypeEnum from lib.core.enums import WMUserStateEnum from lib.core.exceptions import AppException +from pydantic import AliasChoices from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field @@ -294,3 +295,53 @@ def validate_type(cls, v): except KeyError: pass raise ValueError(f"Invalid type: {v}") + + +class TeamAPIKeyStatus(str, Enum): + """The states a team API key moves through, named as the backend reports them.""" + + ACTIVE = "ACTIVE" + ROTATING = "ROTATING" + EXPIRED = "EXPIRED" + REVOKED = "REVOKED" + + +#: A key in one of these states is still usable, so it is the only kind that can be +#: rotated or revoked; anything else is already spent. +TEAM_API_KEY_LIVE_STATUSES = frozenset( + {TeamAPIKeyStatus.ACTIVE, TeamAPIKeyStatus.ROTATING} +) + + +class TeamAPIKeyEntity(BaseModel): + """A team-scoped API key. + + Its timestamps are kept exactly as the backend sends them. A key's dates carry + milliseconds, which is the precision the two keys of a rotation are told apart by, + and ``StringDate`` would re-format them (``.421Z`` into ``.421000.000Z``). + + ``api_key`` is the secret itself. The backend sends it (as ``token``) only in the + response that creates or rotates a key and never again, so it stays unset on a + listed key rather than being reported as None. + """ + + model_config = ConfigDict(extra="ignore") + + id: StrictInt | None = None + public_id: StrictStr | None = None + name: StrictStr | None = None + scope: dict | None = None + created_by: StrictStr | None = None + revoked_by: StrictStr | None = None + rotation_parent_id: StrictInt | None = None + status: TeamAPIKeyStatus | None = None + deletedAt: StrictStr | None = None + expiresAt: StrictStr | None = None + revokedAt: StrictStr | None = None + overlapEndAt: StrictStr | None = None + createdAt: StrictStr | None = None + updatedAt: StrictStr | None = None + lastUsedAt: StrictStr | None = None + api_key: StrictStr | None = Field( + default=None, validation_alias=AliasChoices("api_key", "token") + ) diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index 0740fdf09..8decce071 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -4,6 +4,7 @@ from lib.core import entities from lib.core.entities.work_managament import PermissionGroupEntity +from lib.core.entities.work_managament import TeamAPIKeyEntity from lib.core.entities.work_managament import TelemetryScoreEntity from lib.core.entities.work_managament import WMProjectEntity from lib.core.entities.work_managament import WMScoreEntity @@ -264,3 +265,51 @@ class TelemetryScoreListResponse(ServiceResponse): class WMPermissionGroupListResponse(ServiceResponse): res_data: list[PermissionGroupEntity] = None + + +class WMTeamAPIKeyListResponse(ServiceResponse): + res_data: list[TeamAPIKeyEntity] = None + + +class ExploreLastAction(BaseModel): + model_config = ConfigDict(extra="ignore") + + date: str | None = None + email: str | None = None + + +class ExploreItem(BaseModel): + """An item as returned by the Explore query language (v3 items/search). + + Values are kept as the backend sends them (e.g. is_pinned is an int), except + annotation_status, which ExploreQueryUseCase maps from its id to its name. Keys not + listed here are dropped. + """ + + model_config = ConfigDict(extra="ignore") + + id: int + name: str + folder_id: int | None = None + comments: list = Field(default_factory=list) + annotation_status: int | str | None = None + path: str | None = None + approval_status: int | None = None + createdAt: str | None = None + updatedAt: str | None = None + meta: dict | None = None + instances: list = Field(default_factory=list) + custom_metadata: dict = Field(default_factory=dict) + subset_ids: list[int] = Field(default_factory=list) + category_id: list = Field(default_factory=list) + category_name: str | None = None + assignment: list = Field(default_factory=list) + last_action: ExploreLastAction | None = None + score: list = Field(default_factory=list) + is_pinned: int | None = None + folder_name: str | None = None + is_root_folder: int | None = None + + +class ExploreQueryResponse(ServiceResponse): + res_data: list[ExploreItem] = None diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 92061b8e7..9caa93407 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -14,6 +14,7 @@ from lib.core.entities import TokenContext from lib.core.entities import WMAnnotationClassEntity from lib.core.entities.project_entities import BaseEntity +from lib.core.entities.work_managament import TeamAPIKeyEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.jsx_conditions import Query from lib.core.reporter import Reporter @@ -38,6 +39,7 @@ from lib.core.service_types import WMPermissionGroupListResponse from lib.core.service_types import WMProjectListResponse from lib.core.service_types import WMScoreListResponse +from lib.core.service_types import WMTeamAPIKeyListResponse from lib.core.service_types import WMUserListResponse from lib.core.service_types import WorkflowListResponse from lib.core.types import Attachment @@ -303,6 +305,38 @@ def update_annotation_class( ) -> WMClassesResponse: raise NotImplementedError + @abstractmethod + def create_team_api_key(self, name: str, expires_at: str) -> TeamAPIKeyEntity: + raise NotImplementedError + + @abstractmethod + def rotate_team_api_key( + self, key_id: int, overlap_end_at: str, expires_at: str + ) -> TeamAPIKeyEntity: + raise NotImplementedError + + @abstractmethod + def list_team_api_keys( + self, body_query: Query, chunk_size: int = 100 + ) -> WMTeamAPIKeyListResponse: + raise NotImplementedError + + @abstractmethod + def revoke_team_api_key(self, key_id: int): + raise NotImplementedError + + def create_custom_role(self, org_id: str, data: dict): + raise NotImplementedError + + def list_roles(self, org_id: str): + raise NotImplementedError + + def create_custom_status(self, org_id: str, data: dict): + raise NotImplementedError + + def list_statuses(self, org_id: str): + raise NotImplementedError + class BaseProjectService(SuperannotateServiceProvider): @abstractmethod @@ -807,6 +841,26 @@ def query_item_count( ) -> ServiceResponse: raise NotImplementedError + @abstractmethod + def explore_query( + self, + project: entities.ProjectEntity, + folder: entities.FolderEntity | None = None, + query: str | None = None, + subset_id: int | None = None, + ) -> ServiceResponse: + raise NotImplementedError + + @abstractmethod + def explore_query_count( + self, + project: entities.ProjectEntity, + folder: entities.FolderEntity | None = None, + query: str | None = None, + subset_id: int | None = None, + ) -> ServiceResponse: + raise NotImplementedError + class BaseTelemetryScoringService(SuperannotateServiceProvider): @abstractmethod diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 9e6348628..6064604fd 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -246,6 +246,95 @@ def execute(self) -> Response: return self._response +class BaseExploreQueryUseCase(BaseReportableUseCase): + """Shared by the Explore query language use cases (SAClient.explore.query). + + The query string is passed to the backend as is - it is not parsed or validated + here, the backend's error is reported instead. + """ + + def __init__( + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + service_provider: BaseServiceProvider, + query: str | None, + subset: str | None = None, + ): + super().__init__(reporter) + self._project = project + self._folder = folder + self._service_provider = service_provider + self._query = query + self._subset = subset + + def validate_query_or_subset(self): + if not any([self._query, self._subset]): + raise AppValidationException("Provide 'query' or 'subset'") + + def _query_kwargs(self) -> dict: + query_kwargs = { + "query": self._query, + "folder": None if self._folder.is_root else self._folder, + } + if self._subset: + response = self._service_provider.explore.list_subsets(self._project) + if not response.ok: + raise AppException(response.error) + subset = next( + (_sub for _sub in response.data if _sub.name == self._subset), None + ) + if not subset: + raise AppException("Subset not found") + query_kwargs["subset_id"] = subset.id + return query_kwargs + + +class ExploreQueryUseCase(BaseExploreQueryUseCase): + def execute(self) -> Response: + if self.is_valid(): + try: + query_kwargs = self._query_kwargs() + except AppException as e: + self._response.errors = e + return self._response + service_response = self._service_provider.explore.explore_query( + self._project, **query_kwargs + ) + if service_response.ok: + items = service_response.data + # the backend sends the status id - report its name, as list_items does + for item in items: + item.annotation_status = ( + self._service_provider.get_annotation_status_name( + self._project, item.annotation_status + ) + ) + self._response.data = items + else: + self._response.errors = service_response.error + return self._response + + +class ExploreQueryCountUseCase(BaseExploreQueryUseCase): + def execute(self) -> Response: + if self.is_valid(): + try: + query_kwargs = self._query_kwargs() + except AppException as e: + self._response.errors = e + return self._response + service_response = self._service_provider.explore.explore_query_count( + self._project, **query_kwargs + ) + if service_response.ok: + self._response.data = service_response.data["count"] + else: + self._response.errors = service_response.error + return self._response + + class AssignItemsUseCase(BaseUseCase): CHUNK_SIZE = 500 diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 348279943..d9efdf8df 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -6,6 +6,9 @@ from abc import ABCMeta from abc import abstractmethod from collections.abc import Callable +from datetime import datetime +from datetime import timedelta +from datetime import timezone from pathlib import Path from typing import Any from typing import Literal @@ -34,11 +37,15 @@ from lib.core.entities.filters import ItemFilters from lib.core.entities.filters import ProjectFilters from lib.core.entities.filters import ProjectUserFilters +from lib.core.entities.filters import TeamAPIKeyFilters from lib.core.entities.filters import TeamUserFilters from lib.core.entities.integrations import IntegrationEntity from lib.core.entities.items import ProjectCategoryEntity from lib.core.entities.work_managament import ScoreEntity from lib.core.entities.work_managament import ScorePayloadEntity +from lib.core.entities.work_managament import TEAM_API_KEY_LIVE_STATUSES +from lib.core.entities.work_managament import TeamAPIKeyEntity +from lib.core.entities.work_managament import TeamAPIKeyStatus from lib.core.enums import CustomFieldEntityEnum from lib.core.enums import CustomFieldType from lib.core.enums import ProjectType @@ -60,6 +67,7 @@ from lib.infrastructure.query_builder import ProjectFilterHandler from lib.infrastructure.query_builder import ProjectUserRoleFilterHandler from lib.infrastructure.query_builder import QueryBuilderChain +from lib.infrastructure.query_builder import TeamAPIKeyFilterHandler from lib.infrastructure.query_builder import TeamUserFilterHandler from lib.infrastructure.repositories import S3Repository from lib.infrastructure.serviceprovider import ServiceProvider @@ -638,6 +646,154 @@ def _resolve_team_user(self, user: int | str): return self.list_users(id__in=[user]) return self.list_users(email__in=[user]) + def generate_team_api_key( + self, name: str, expires_in: int | timedelta | datetime + ) -> TeamAPIKeyEntity: + return self.service_provider.work_management.create_team_api_key( + name=self._validate_key_name(name), + expires_at=self._resolve_expiration(expires_in), + ) + + def rotate_team_api_key( + self, + name: str, + overlap: int, + expires_in: int | timedelta | datetime, + ) -> TeamAPIKeyEntity: + key = self._find_rotatable_key(name) + expires_at = self._resolve_expiration(expires_in) + return self.service_provider.work_management.rotate_team_api_key( + key_id=key.id, + overlap_end_at=self._resolve_overlap_end(overlap, expires_at), + expires_at=expires_at, + ) + + def list_team_api_keys(self, **filters) -> list[TeamAPIKeyEntity]: + chain = QueryBuilderChain( + [ + FieldValidationHandler(TeamAPIKeyFilters.__annotations__.keys()), + TeamAPIKeyFilterHandler(), + ] + ) + response = self.service_provider.work_management.list_team_api_keys( + chain.handle(filters, EmptyQuery()) + ) + response.raise_for_status() + # Newest first. Key ids grow with every key, so they order the keys by age + # without relying on the order the backend happens to answer in. + return sorted(response.data, key=lambda key: key.id or 0, reverse=True) + + def revoke_team_api_key(self, public_id: str) -> TeamAPIKeyEntity: + key = self._find_key(public_id, self._keys_where("public_id", public_id)) + if key is None: + raise AppException(constants.API_KEY_NOT_FOUND_ERROR) + if key.status not in TEAM_API_KEY_LIVE_STATUSES: + raise AppException(constants.API_KEY_ALREADY_REVOKED_ERROR) + return self.service_provider.work_management.revoke_team_api_key(key_id=key.id) + + @staticmethod + def _validate_key_name(name: str) -> str: + """The backend accepts a blank name, so it is rejected here.""" + if not name or not name.strip(): + raise AppException("Name cannot be empty") + return name + + @classmethod + def _resolve_expiration(cls, expires_in: int | timedelta | datetime) -> str: + """``expires_in`` as the instant the backend takes in ``expiresAt``. + + A number of days or a duration is counted from now; a datetime is the expiry + itself, read in the local timezone when it carries none - that is the clock the + caller wrote it against. Whether the instant that produces is an acceptable + expiry - too far out, or already past - is the backend's to answer, so it is + sent as it is. + """ + now = datetime.now(timezone.utc) + if isinstance(expires_in, datetime): + expires_at = expires_in.astimezone(timezone.utc) + elif isinstance(expires_in, timedelta): + expires_at = now + expires_in + elif isinstance(expires_in, int) and not isinstance(expires_in, bool): + expires_at = now + timedelta(days=expires_in) + else: + # Not a rule but a conversion: there is no ``expiresAt`` to send for + # anything that is neither a number of days, a duration, nor a date. + raise AppException("Expiration date is invalid") + return cls._as_wire_date(expires_at) + + @classmethod + def _resolve_overlap_end(cls, overlap: int, expires_at: str) -> str: + """``overlap`` as the instant the rotated key stops being valid. + + The backend takes the end of the window rather than its length, and an overlap + of 0 is no window at all: the rotated key stops the moment the new one starts. + A window cannot outlast the new key, which the backend does not check either - + though only an expiry still ahead is worth measuring against, an expiry already + past being the backend's to refuse. + """ + now = datetime.now(timezone.utc) + overlap_end = now + timedelta(days=int(overlap)) + expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) + if expiry > now and overlap_end > expiry: + raise AppException( + "The overlap period cannot be longer than the new key expiration period." + ) + return cls._as_wire_date(overlap_end) + + @staticmethod + def _as_wire_date(value: datetime) -> str: + """A date the way the key endpoints send and take one: UTC, to the ms.""" + return value.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + def _keys_where(self, field: str, value: str) -> list[TeamAPIKeyEntity]: + """The team's keys whose ``field`` is exactly ``value``, newest first. + + Resolving a key the caller named is a question about one key, so it is asked + of the backend rather than by listing the team's keys and searching them + here: a team that has been rotating keys for a while has hundreds, and they + would be paged through in full for every rotate and revoke. + """ + query = EmptyQuery() & Filter(field, value, OperatorEnum.EQ) + response = self.service_provider.work_management.list_team_api_keys(query) + response.raise_for_status() + return sorted(response.data, key=lambda key: key.id or 0, reverse=True) + + @staticmethod + def _find_key( + public_id: str, keys: list[TeamAPIKeyEntity] + ) -> TeamAPIKeyEntity | None: + """The key a public id names - the live one, when a rotation left two. + + A rotation hands the new key the public id of the one it replaced, so a public + id can name both a Rotating key and the Active key that took over from it. + The keys arrive newest first, so the newest live one is the key still being + handed out; once that is revoked the same public id names the other. + """ + matching = [key for key in keys if key.public_id == public_id] + live = [k for k in matching if k.status in TEAM_API_KEY_LIVE_STATUSES] + return next(iter(live), next(iter(matching), None)) + + def _find_rotatable_key(self, name: str) -> TeamAPIKeyEntity: + """The key ``name`` names, by public id or by name. + + A name is not unique - a rotation leaves the old key behind under the same + name - so of several the Active one is rotated, that being the one still handed + out. + """ + key = self._find_key(name, self._keys_where("public_id", name)) + if key is None: + named = self._keys_where("name", name) + live = [k for k in named if k.status in TEAM_API_KEY_LIVE_STATUSES] + key = next( + (k for k in live if k.status == TeamAPIKeyStatus.ACTIVE), + next(iter(live), next(iter(named), None)), + ) + if key is None: + raise AppException(constants.API_KEY_NOT_FOUND_ERROR) + if key.status not in TEAM_API_KEY_LIVE_STATUSES: + raise AppException(constants.API_KEY_ALREADY_REVOKED_ERROR) + return key + class ProjectManager(BaseManager): def __init__( @@ -2207,6 +2363,44 @@ def query_items_count( raise AppException(response.errors) return response.data["count"] + def explore_query( + self, + project: ProjectEntity, + folder: FolderEntity, + query: str | None = None, + subset: str | None = None, + ) -> list: + response = usecases.ExploreQueryUseCase( + reporter=self.get_default_reporter(), + project=project, + folder=folder, + query=query, + subset=subset, + service_provider=self.service_provider, + ).execute() + if response.errors: + raise AppException(response.errors) + return response.data + + def explore_query_count( + self, + project: ProjectEntity, + folder: FolderEntity, + query: str | None = None, + subset: str | None = None, + ) -> int: + response = usecases.ExploreQueryCountUseCase( + reporter=self.get_default_reporter(), + project=project, + folder=folder, + query=query, + subset=subset, + service_provider=self.service_provider, + ).execute() + if response.errors: + raise AppException(response.errors) + return response.data + def get_project_folder( self, path: str | int | tuple[int, int] | tuple[str, str] ) -> tuple[ProjectEntity, FolderEntity | None]: diff --git a/src/superannotate/lib/infrastructure/query_builder.py b/src/superannotate/lib/infrastructure/query_builder.py index 4c906dab1..180d64c7f 100644 --- a/src/superannotate/lib/infrastructure/query_builder.py +++ b/src/superannotate/lib/infrastructure/query_builder.py @@ -7,6 +7,7 @@ from lib.core.entities import BaseItemEntity from lib.core.entities import ProjectEntity +from lib.core.entities.work_managament import TeamAPIKeyStatus from lib.core.enums import ApprovalStatus from lib.core.enums import CustomFieldEntityEnum from lib.core.enums import CustomFieldType @@ -346,6 +347,38 @@ def handle(self, filters: dict[str, Any], query: Query = None) -> Query: return super().handle(filters, query) +class TeamAPIKeyFilterHandler(AbstractQueryHandler): + """Filters over a team's API keys. + + ``status`` is given the way the SDK reports a key ("Active") and sent in the + backend's own upper case ("ACTIVE"). + """ + + @staticmethod + def _status(value) -> str: + try: + return TeamAPIKeyStatus(str(value).upper()).value + except ValueError: + raise AppException(f"Invalid status filter: {value}") + + def _handle_special_fields(self, keys: list[str], val): + if keys[0] == "status": + if isinstance(val, (list, tuple, set)): + return [self._status(i) for i in val] + return self._status(val) + return val + + def handle(self, filters: dict[str, Any], query: Query = None) -> Query: + if query is None: + query = EmptyQuery() + for key, val in filters.items(): + _keys = key.split("__") + val = self._handle_special_fields(_keys, val) + condition, _key = determine_condition_and_key(_keys) + query &= Filter(_key, val, condition) + return super().handle(filters, query) + + class QueryBuilderChain: def __init__(self, handlers: list[QueryHandler]): if not handlers: diff --git a/src/superannotate/lib/infrastructure/services/explore.py b/src/superannotate/lib/infrastructure/services/explore.py index 341fae491..c04b5440b 100644 --- a/src/superannotate/lib/infrastructure/services/explore.py +++ b/src/superannotate/lib/infrastructure/services/explore.py @@ -6,6 +6,7 @@ import lib.core as constants from lib.core import entities from lib.core.conditions import Condition +from lib.core.service_types import ExploreQueryResponse from lib.core.service_types import ServiceResponse from lib.core.service_types import SubsetListResponse from lib.core.service_types import UploadCustomFieldValuesResponse @@ -28,13 +29,20 @@ class ExploreService(BaseExploreService): URL_VALIDATE_SAQUL_QUERY = "items/parse/query" URL_QUERY_COUNT = "items/count" + # Explore query language (SAClient.explore.query) + EXPLORE_API_VERSION = "v3" + EXPLORE_QUERY_CHUNK_SIZE = 25 + URL_EXPLORE_QUERY = "items/search" + URL_EXPLORE_QUERY_COUNT = "items/count" + + def _explore_service_url(self, api_version: str) -> str: + if self.client.api_url != constants.BACKEND_URL: + return f"https://explore-service.devsuperannotate.com/api/{api_version}/" + return f"https://explore-service.superannotate.com/api/{api_version}/" + @property def explore_service_url(self): - if self.client.api_url != constants.BACKEND_URL: - return ( - f"https://explore-service.devsuperannotate.com/api/{self.API_VERSION}/" - ) - return f"https://explore-service.superannotate.com/api/{self.API_VERSION}/" + return self._explore_service_url(self.API_VERSION) def create_schema(self, project: entities.ProjectEntity, schema: dict): return self.client.request( @@ -219,3 +227,70 @@ def query_item_count( if not response.ok: raise AppException(response.error) return response + + @staticmethod + def _explore_query_params( + project: entities.ProjectEntity, + folder: entities.FolderEntity = None, + subset_id: int = None, + ) -> dict: + # without includeFolderNames folder_name and is_root_folder come back empty + params = {"project_id": project.id, "includeFolderNames": True} + if folder: + params["folder_id"] = folder.id + if subset_id: + params["subset_id"] = subset_id + return params + + def explore_query( + self, + project: entities.ProjectEntity, + folder: entities.FolderEntity = None, + query: str = None, + subset_id: int = None, + ) -> ServiceResponse: + params = self._explore_query_params(project, folder, subset_id) + data = {"image_index": 0, "limit": self.EXPLORE_QUERY_CHUNK_SIZE} + if query: + data["query"] = query + url = urljoin( + self._explore_service_url(self.EXPLORE_API_VERSION), + self.URL_EXPLORE_QUERY, + ) + items = [] + while True: + response = self.client.request( + url, + "post", + params=params, + data=data, + content_type=ExploreQueryResponse, + ) + if not response.ok: + return ExploreQueryResponse( + status=response.status_code, + res_data=items, + res_error=response.error, + ) + items.extend(response.data) + if len(response.data) < self.EXPLORE_QUERY_CHUNK_SIZE: + break + data["image_index"] += self.EXPLORE_QUERY_CHUNK_SIZE + return ExploreQueryResponse(status=200, res_data=items) + + def explore_query_count( + self, + project: entities.ProjectEntity, + folder: entities.FolderEntity = None, + query: str = None, + subset_id: int = None, + ) -> ServiceResponse: + return self.client.request( + urljoin( + self._explore_service_url(self.EXPLORE_API_VERSION), + self.URL_EXPLORE_QUERY_COUNT, + ), + "post", + params=self._explore_query_params(project, folder, subset_id), + data={"query": query} if query else None, + ) diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 326b10c71..919859aa9 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -4,9 +4,11 @@ from lib.core.entities import CategoryEntity from lib.core.entities import FolderEntity +from lib.core.entities import TokenScope from lib.core.entities import WorkflowEntity from lib.core.entities.project_entities import BaseEntity from lib.core.entities.work_managament import PermissionGroupEntity +from lib.core.entities.work_managament import TeamAPIKeyEntity from lib.core.entities.work_managament import WMAnnotationClassEntity from lib.core.entities.work_managament import WMProjectEntity from lib.core.entities.work_managament import WMProjectUserEntity @@ -14,6 +16,7 @@ from lib.core.entities.work_managament import WMUserEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.exceptions import AppException +from lib.core.exceptions import SAAuthError from lib.core.jsx_conditions import EmptyQuery from lib.core.jsx_conditions import Filter from lib.core.jsx_conditions import OperatorEnum @@ -26,6 +29,7 @@ from lib.core.service_types import WMPermissionGroupListResponse from lib.core.service_types import WMProjectListResponse from lib.core.service_types import WMScoreListResponse +from lib.core.service_types import WMTeamAPIKeyListResponse from lib.core.service_types import WMUserListResponse from lib.core.serviceproviders import BaseWorkManagementService from lib.infrastructure.services.http_client import encode_entity_context @@ -81,6 +85,10 @@ class WorkManagementService(BaseWorkManagementService): URL_SET_TEAM_USER_PERMISSIONS = "teamusers/setpermissions" URL_PERMISSION_GROUPS = "permissiongroups" URL_UPDATE_ANNOTATION_CLASS = "classes/{class_id}" + URL_CREATE_TEAM_API_KEY = "teamkeys" + URL_SEARCH_API_KEYS = "teamkeys/search" + URL_ROTATE_API_KEY = "teamkeys/{key_id}/rotate" + URL_REVOKE_API_KEY = "teamkeys/{key_id}" def list_folders(self, project_id: int, query: Query) -> ServiceResponse: result = self.client.jsx_paginate( @@ -209,6 +217,17 @@ def create_custom_role(self, org_id: str, data: dict): data=data, ) + def list_roles(self, org_id: str): + return self.client.request( + url=self.URL_CREATE_ROLE, + method="get", + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id, organization_id=org_id + ) + }, + ) + def create_custom_status(self, org_id: str, data: dict): return self.client.request( url=self.URL_CREATE_STATUS, @@ -221,6 +240,17 @@ def create_custom_status(self, org_id: str, data: dict): data=data, ) + def list_statuses(self, org_id: str): + return self.client.request( + url=self.URL_CREATE_STATUS, + method="get", + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id, organization_id=org_id + ) + } + ) + def list_custom_field_templates( self, entity: CustomFieldEntityEnum, @@ -661,3 +691,71 @@ def update_annotation_class( content_type=WMClassesResponse, dispatcher="data", ) + + def create_team_api_key(self, name: str, expires_at: str) -> TeamAPIKeyEntity: + response = self.client.request( + url=self.URL_CREATE_TEAM_API_KEY, + method="post", + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id + ), + }, + data={"name": name, "expiresAt": expires_at}, + ) + if response.status_code == 403: + raise SAAuthError("Unauthorized") + response.raise_for_status() + return TeamAPIKeyEntity(**response.res_data["data"]) + + def rotate_team_api_key( + self, key_id: int, overlap_end_at: str, expires_at: str + ) -> TeamAPIKeyEntity: + response = self.client.request( + url=self.URL_ROTATE_API_KEY.format(key_id=key_id), + method="post", + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id + ), + }, + # The rotation terms go under "body", and the overlap is the instant the + # rotated key stops rather than how long it lasts. + data={"body": {"overlapEndAt": overlap_end_at, "expiresAt": expires_at}}, + ) + if response.status_code == 403: + raise SAAuthError("Unauthorized") + response.raise_for_status() + return TeamAPIKeyEntity(**response.res_data["data"]) + + def list_team_api_keys( + self, body_query: Query, chunk_size=100 + ) -> WMTeamAPIKeyListResponse: + return self.client.jsx_paginate( + url=self.URL_SEARCH_API_KEYS, + method="post", + body_query=body_query + & Filter("scope_type", TokenScope.TEAM.value, OperatorEnum.EQ), + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id + ), + }, + chunk_size=chunk_size, + item_type=TeamAPIKeyEntity, + ) + + def revoke_team_api_key(self, key_id: int) -> TeamAPIKeyEntity: + response = self.client.request( + url=self.URL_REVOKE_API_KEY.format(key_id=key_id), + method="delete", + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id + ), + }, + ) + if response.status_code == 403: + raise SAAuthError("Unauthorized") + response.raise_for_status() + return TeamAPIKeyEntity(**response.res_data["data"]) diff --git a/tests/applicatoin/test_custom_workflow.py b/tests/applicatoin/test_custom_workflow.py index bbc8db169..4a020bfa1 100644 --- a/tests/applicatoin/test_custom_workflow.py +++ b/tests/applicatoin/test_custom_workflow.py @@ -38,7 +38,6 @@ def setUpClass(cls, *args, **kwargs): "rolePermissions": [{"permission_id": 11}, {"permission_id": 12}], }, ) - # setup custom status sa.controller.service_provider.work_management.create_custom_status( org_id=sa.controller.org_id, diff --git a/tests/env.py b/tests/env.py index 4e0c5f078..b1a4cea74 100644 --- a/tests/env.py +++ b/tests/env.py @@ -124,54 +124,23 @@ def environ(**values): os.environ[key] = value -def _sdk_environ(token: str, team_id: int | None = None) -> dict: - """The whole environment a client is built in: every variable the SDK reads. - - Every one is set here rather than inherited, so building a client does not depend - on what the ambient environment happens to hold. Two ways that used to bite: - a suite that scrubs the environment (``tests/unit/conftest.py``) would silently get - a client pointed at production, and a stray ``SA_TEAM_ID`` would attach itself to a - token that never asked for one. The backend comes from the environment first, then - the ``.env`` file, so an exported override still wins. - """ - from_file = dotenv_values() - values = { - "SA_TOKEN": token, - "SA_TEAM_ID": str(team_id) if team_id is not None else None, - } - # SA_URL only: the SDK reads SA_SSL but can never act on it, since - # _retrieve_configs_from_env assigns VERIFY_SSL only when it is already True. - values["SA_URL"] = os.environ.get("SA_URL") or from_file.get("SA_URL") - return values - - -def build_client(token: str, team_id: int | None = None, team_id_via_env: bool = False): - """An ``SAClient`` for an ad-hoc token, on the backend the ``.env`` names. - - The token reaches the SDK the way the suite's own credentials do - through the - environment - so ``SA_URL`` still applies. Passing it as ``SAClient(token=...)`` - would not: only the no-argument path reads ``SA_URL``. - - The team is passed as the ``team_id`` argument, or as ``SA_TEAM_ID`` when - ``team_id_via_env`` is set; both are paths a caller has. It is never inherited from - the ``.env``, so a client can be built with no team at all. - """ - from src.superannotate import SAClient - - with environ(**_sdk_environ(token, team_id if team_id_via_env else None)): - return SAClient(team_id=None if team_id_via_env else team_id) - - @contextlib.contextmanager -def _config_file(token: str): - """A throwaway ini config naming the credentials and the backend. +def _config_file(token: str, team_id: int | None = None): + """A throwaway ini config naming the credentials, the team and the backend. Given ``config_path``, the SDK reads the file and consults the environment for nothing, so a client built from one can neither be perturbed by the ambient - environment nor leak into it. The backend still comes from the environment first - and the ``.env`` file second, so an exported override wins as it always did. + environment nor leak into it - not even for the duration of the call. The backend + still comes from the environment first and the ``.env`` file second, so an exported + override wins as it always did. + + Only ``SA_URL`` is carried over: the SDK reads ``SA_SSL`` but can never act on it, + since ``_retrieve_configs_from_env`` assigns ``VERIFY_SSL`` only when it is already + True. """ settings = {"SA_TOKEN": token} + if team_id is not None: + settings["SA_TEAM_ID"] = str(team_id) url = os.environ.get("SA_URL") or dotenv_values().get("SA_URL") if url: settings["SA_URL"] = url @@ -185,6 +154,29 @@ def _config_file(token: str): yield str(path) +def build_client( + token: str, team_id: int | None = None, team_id_via_config: bool = False +): + """An ``SAClient`` for an ad-hoc token, on the backend the ``.env`` names. + + Built from a throwaway config file, the way ``build_org_client`` is: nothing about + the client is inherited from the ambient environment, and nothing it needs is + written there. That matters twice over - a suite that scrubs the environment + (``tests/unit/conftest.py``) would otherwise get a client pointed at production, + and a stray ``SA_TEAM_ID`` would attach itself to a token that never asked for one. + + The team is passed as the ``team_id`` argument, or written into the config as + ``SA_TEAM_ID`` when ``team_id_via_config`` is set; both are paths a caller has. It + is never taken from the ``.env``, so a client can be built with no team at all. + """ + from src.superannotate import SAClient + + with _config_file(token, team_id if team_id_via_config else None) as config_path: + return SAClient( + config_path=config_path, team_id=None if team_id_via_config else team_id + ) + + def build_org_client(token: str): """An ``SAORGClient`` for an ad-hoc token, on the backend the ``.env`` names. diff --git a/tests/integration/client/test_project_admin_token.py b/tests/integration/client/test_project_admin_token.py index 90b4be47d..23f43fa8f 100644 --- a/tests/integration/client/test_project_admin_token.py +++ b/tests/integration/client/test_project_admin_token.py @@ -149,7 +149,6 @@ def test_lists_only_the_projects_it_has_access_to(self): } def test_add_remove_a_contributor_to_its_project(self): - # TODO should raise error on ProjectAdmin deletion scapegoat = self._team_contributor() self.project_admin.add_contributors_to_project( @@ -161,14 +160,19 @@ def test_add_remove_a_contributor_to_its_project(self): for user in self.project_admin.list_users(project=self.PROJECT_NAME) } assert project_roles.get(scapegoat["email"]) == "ProjectAdmin" - self.project_admin.remove_users_from_project( - self.PROJECT_NAME, [scapegoat["email"]] - ) + with self.assertLogs("sa", level="INFO") as cm: + self.project_admin.remove_users_from_project( + self.PROJECT_NAME, [scapegoat["email"]] + ) + assert ( + cm.output[0] + == f"INFO:sa:Successfully removed 0 users(s) out of the 1 provided from the project {self.PROJECT_NAME}." + ) project_roles = { user["email"]: user["role"] for user in self.project_admin.list_users(project=self.PROJECT_NAME) } - assert scapegoat["email"] not in project_roles + assert scapegoat["email"] in project_roles def test_lists_team_users(self): team_users = self.project_admin.list_users() @@ -255,10 +259,13 @@ def test_get_project_metadata(self): ) def test_delete_project(self): - # TODO fix project admin should not be able to delete project - self.project_admin.delete_project(self.PROJECT_NAME) - projects = self.project_admin.list_projects(name=self.PROJECT_NAME) - assert not projects + with self.assertRaisesRegex(AppException, "Couldn't delete project"): + self.project_admin.delete_project(self.PROJECT_NAME) + self.owner.add_contributors_to_project( + self.FOREIGN_PROJECT_NAME, [self.project_admin_email], "Annotator" + ) + with self.assertRaisesRegex(AppException, "Couldn't delete project"): + self.project_admin.delete_project(self.FOREIGN_PROJECT_NAME) @env.requires_env_vars(env.OWNER_PERSONAL_TOKEN_ENV, env.SA_CONTRIBUTOR_TOKEN_ENV) @@ -282,7 +289,7 @@ def test_item_deletion(self): def test_create_items(self): # todo update error message with self.assertRaisesRegex( - AppException, "You do not have sufficient access export." + AppException, "You do not have sufficient access to create item." ): self.project_admin.generate_items(self.PROJECT_NAME, count=5, name="test") diff --git a/tests/integration/client/test_team_api_keys.py b/tests/integration/client/test_team_api_keys.py new file mode 100644 index 000000000..00dcdf8c4 --- /dev/null +++ b/tests/integration/client/test_team_api_keys.py @@ -0,0 +1,315 @@ +"""Minting, rotating, listing and revoking a team API key, against the backend. + +Every test builds its client from the organization key named in the .env, plus the +team id that key can reach - what generate and rotate require - and is skipped while +either is unset (see tests/env.py). + +Keys are real: each one a test mints is named after this run and revoked when the test +finishes, so a run leaves the team as it found it. +""" + +import uuid +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from unittest import TestCase + +import pytest +from src.superannotate import AppException +from tests import env + +#: Every key a run mints carries this prefix, so one left behind by a run that died +#: mid-test is recognisable at a glance. +NAME_PREFIX = "sdk-test-key" +#: The statuses a key can still be revoked in, and so the ones cleanup looks for. +LIVE = ["Active", "Rotating"] + + +def as_datetime(value: str) -> datetime: + """A date the backend reports, as an aware datetime.""" + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def days_from_now(value: str) -> float: + return (as_datetime(value) - datetime.now(timezone.utc)).total_seconds() / 86400 + + +@env.requires_env_vars(env.SA_ORGANIZATION_TOKEN_ENV, env.SA_ORGANIZATION_TEAM_ID_ENV) +class TeamAPIKeyTestCase(TestCase): + """A team-scoped client on an organization key, and cleanup after each test.""" + + @classmethod + def setUpClass(cls): + cls.team_id = int(env.var(env.SA_ORGANIZATION_TEAM_ID_ENV)) + cls.client = env.build_client( + env.var(env.SA_ORGANIZATION_TOKEN_ENV), team_id=cls.team_id + ) + + def setUp(self): + #: The public ids this test minted. A rotation hands the new key the public + #: id of the one it replaced, so one entry can name a pair of them. + self.minted = [] + + def tearDown(self): + """Revoke exactly what this test minted, and nothing else. + + By the public ids it handed out rather than by listing the team's keys and + matching on the name prefix: cleanup is the one place where getting the set + wrong revokes a key that is none of this suite's business, so it does not + depend on a filter behaving, on a status reading the way it is spelled, or + on the team holding nothing else that looks like a test key. + """ + for public_id in self.minted: + # A rotation leaves two keys under one public id and revoking takes the + # live one, so clearing a pair takes two rounds. It ends when there is + # nothing live left to revoke. + for _ in range(2): + try: + self.client.revoke_team_api_key(public_id=public_id) + except AppException: + break + + def generate(self, name: str = None, **kwargs) -> dict: + """A key of this run's own, revoked when the test ends.""" + name = name if name is not None else f"{NAME_PREFIX}-{uuid.uuid4().hex[:8]}" + key = self.client.generate_team_api_key(name=name, **kwargs) + self.minted.append(key["public_id"]) + return key + + def listed(self, key: dict) -> dict: + """One key as the backend now reports it. + + By id, not public id: a rotation hands the new key the public id of the one + it replaced, so only the id tells the two of them apart. + """ + keys = self.client.list_team_api_keys() + found = next((k for k in keys if k["id"] == key["id"]), None) + assert found is not None, f"{key['name']} is not among the team's keys" + return found + + +class TestGenerateTeamAPIKey(TeamAPIKeyTestCase): + def test_a_generated_key_is_active_and_carries_its_secret(self): + generated = self.generate() + + assert generated["status"] == "ACTIVE" + assert generated["scope"] == {"team_id": self.team_id} + assert generated["name"].startswith(NAME_PREFIX) + assert generated["public_id"] + # The secret is reported once, here, and never again. + assert generated["api_key"].startswith("sa_") + + def test_a_key_lives_a_year_unless_told_otherwise(self): + generated = self.generate() + + assert 364 < days_from_now(generated["expiresAt"]) <= 365 + + def test_expires_in_takes_days_a_duration_or_a_date(self): + by_days = self.generate(expires_in=30) + by_duration = self.generate(expires_in=timedelta(days=60)) + expiry = datetime.now() + timedelta(days=90) + by_date = self.generate(expires_in=expiry) + + assert 29 < days_from_now(by_days["expiresAt"]) <= 30 + assert 59 < days_from_now(by_duration["expiresAt"]) <= 60 + # A date is the expiry itself, to the millisecond the wire format carries. + assert as_datetime(by_date["expiresAt"]) == expiry.astimezone( + timezone.utc + ).replace(microsecond=expiry.microsecond // 1000 * 1000) + + def test_expires_in_takes_nothing_else(self): + # 30.5 is no number of days, and must not be read as 30.5 *seconds*. + for invalid in (30.5, 30.0, "30", "2027-01-01", None, True): + with pytest.raises(AppException): + self.generate(expires_in=invalid) + + def test_a_blank_name_is_rejected(self): + for blank in ("", " "): + with self.assertRaisesRegex(AppException, "Name cannot be empty"): + self.generate(name=blank) + + def test_a_duplicate_name_is_refused_by_the_backend(self): + taken = self.generate()["name"] + + # The SDK does not check this one: the backend owns the rule, and says so. + with pytest.raises(AppException): + self.generate(name=taken) + + +class TestListTeamAPIKeys(TeamAPIKeyTestCase): + def test_a_generated_key_is_listed_first(self): + generated = self.generate() + + keys = self.client.list_team_api_keys() + + assert keys[0]["public_id"] == generated["public_id"] + # Newest first, all the way down. + assert [k["id"] for k in keys] == sorted((k["id"] for k in keys), reverse=True) + + def test_a_listed_key_carries_no_secret(self): + generated = self.generate() + + assert "api_key" not in self.listed(generated) + + def test_filtering_by_name_and_status(self): + generated = self.generate() + + by_name = self.client.list_team_api_keys(name=generated["name"]) + active = self.client.list_team_api_keys(name=generated["name"], status="Active") + revoked = self.client.list_team_api_keys( + name=generated["name"], status="Revoked" + ) + other_than_active = self.client.list_team_api_keys( + name=generated["name"], status__ne="Active" + ) + + assert [k["id"] for k in by_name] == [generated["id"]] + assert [k["id"] for k in active] == [generated["id"]] + assert revoked == [] + assert other_than_active == [] + + def test_filtering_by_several_statuses_at_once(self): + generated = self.generate() + + assert [ + k["id"] + for k in self.client.list_team_api_keys( + name=generated["name"], status__in=LIVE + ) + ] == [generated["id"]] + + def test_filtering_by_a_fragment_of_the_name(self): + generated = self.generate() + + by_prefix = self.client.list_team_api_keys(name__starts=NAME_PREFIX) + by_fragment = self.client.list_team_api_keys(name__contains=NAME_PREFIX) + + for listed in (by_prefix, by_fragment): + assert generated["id"] in [k["id"] for k in listed] + assert all(NAME_PREFIX in k["name"] for k in listed) + + def test_nothing_matching_is_an_empty_list(self): + assert self.client.list_team_api_keys(name=f"absent-{uuid.uuid4().hex}") == [] + + def test_an_unknown_status_is_rejected(self): + with self.assertRaisesRegex(AppException, "Invalid status filter: Rotated"): + self.client.list_team_api_keys(status="Rotated") + + def test_an_unsupported_filter_is_rejected(self): + with self.assertRaisesRegex(AppException, "Invalid filter param provided."): + self.client.list_team_api_keys(scope_type="team") + + +class TestRotateTeamAPIKey(TeamAPIKeyTestCase): + def test_rotating_mints_a_new_key_and_leaves_the_old_one_rotating(self): + old = self.generate() + + new = self.client.rotate_team_api_key( + name=old["name"], overlap=1, expires_in=90 + ) + + assert new["id"] != old["id"] + assert new["rotation_parent_id"] == old["id"] + # The new key takes over the public id of the one it replaced. + assert new["public_id"] == old["public_id"] + assert new["status"] == "ACTIVE" + assert new["api_key"].startswith("sa_") + assert 89 < days_from_now(new["expiresAt"]) <= 90 + # The key that was rotated stays valid for the overlap window. + rotated = self.listed(old) + assert rotated["status"] == "ROTATING" + assert 0 < days_from_now(rotated["expiresAt"]) <= 1 + + def test_a_key_is_rotated_by_public_id_too(self): + old = self.generate() + + new = self.client.rotate_team_api_key(name=old["public_id"]) + + assert new["rotation_parent_id"] == old["id"] + assert self.listed(old)["status"] == "ROTATING" + + def test_rotating_immediately_leaves_no_overlap(self): + old = self.generate() + + self.client.rotate_team_api_key(name=old["name"], overlap=0) + + # The rotated key stops the moment the new one starts. + assert days_from_now(self.listed(old)["expiresAt"]) <= 0 + + def test_the_new_key_authenticates_while_the_old_one_still_can(self): + old = self.generate() + + new = self.client.rotate_team_api_key(name=old["name"], overlap=1) + + assert env.build_client(new["api_key"]).controller.team_id == self.team_id + assert env.build_client(old["api_key"]).controller.team_id == self.team_id + + def test_an_overlap_longer_than_the_new_expiry_is_rejected(self): + old = self.generate() + + with self.assertRaisesRegex( + AppException, + "The overlap period cannot be longer than the new key expiration", + ): + self.client.rotate_team_api_key(name=old["name"], overlap=30, expires_in=7) + + assert self.listed(old)["status"] == "ACTIVE" + + def test_an_overlap_outside_the_offered_windows_is_rejected(self): + old = self.generate() + + for invalid in (2, 3, 60, "immediately"): + with pytest.raises(AppException): + self.client.rotate_team_api_key(name=old["name"], overlap=invalid) + + assert self.listed(old)["status"] == "ACTIVE" + + def test_an_unknown_key_cannot_be_rotated(self): + with self.assertRaisesRegex(AppException, "API key not found"): + self.client.rotate_team_api_key(name=f"absent-{uuid.uuid4().hex}") + + +class TestRevokeTeamAPIKey(TeamAPIKeyTestCase): + def test_revoking_marks_the_key_revoked(self): + generated = self.generate() + + assert self.client.revoke_team_api_key(public_id=generated["public_id"]) is None + + revoked = self.listed(generated) + assert revoked["status"] == "REVOKED" + assert revoked["revokedAt"] + + def test_a_rotation_pair_is_revoked_a_key_at_a_time(self): + # Both keys of a rotation answer to one public id, so revoking it takes the + # live one: the key still being handed out first, then the one it replaced. + old = self.generate() + new = self.client.rotate_team_api_key(name=old["name"], overlap=7) + + self.client.revoke_team_api_key(public_id=old["public_id"]) + + assert self.listed(new)["status"] == "REVOKED" + assert self.listed(old)["status"] == "ROTATING" + + self.client.revoke_team_api_key(public_id=old["public_id"]) + + assert self.listed(old)["status"] == "REVOKED" + + def test_a_revoked_key_cannot_be_revoked_again(self): + generated = self.generate() + self.client.revoke_team_api_key(public_id=generated["public_id"]) + + with self.assertRaisesRegex( + AppException, "This key is already revoked or expired" + ): + self.client.revoke_team_api_key(public_id=generated["public_id"]) + + def test_a_revoked_key_no_longer_authenticates(self): + generated = self.generate() + self.client.revoke_team_api_key(public_id=generated["public_id"]) + + with pytest.raises(AppException): + env.build_client(generated["api_key"]) + + def test_an_unknown_public_id_cannot_be_revoked(self): + with self.assertRaisesRegex(AppException, "API key not found"): + self.client.revoke_team_api_key(public_id=f"absent-{uuid.uuid4().hex[:16]}") diff --git a/tests/integration/client/test_token_scopes.py b/tests/integration/client/test_token_scopes.py index 4425cd58a..e9f4feb52 100644 --- a/tests/integration/client/test_token_scopes.py +++ b/tests/integration/client/test_token_scopes.py @@ -34,9 +34,9 @@ def test_team_id_as_an_argument(self): assert client.controller.token_context.scope == TokenScope.ORGANIZATION assert client.controller.team_id == self.team_id - def test_team_id_from_the_environment(self): + def test_team_id_from_the_configuration(self): client = env.build_client( - self.token, team_id=self.team_id, team_id_via_env=True + self.token, team_id=self.team_id, team_id_via_config=True ) assert client.controller.config["SA_TEAM_ID"] == self.team_id @@ -66,5 +66,8 @@ def test_acts_as_a_user_of_its_own_team(self): def test_rejects_a_conflicting_team_id(self): # The key names its own team, so a team_id that disagrees is a caller mistake. - with self.assertRaisesRegex(AppException, r"Invalid team id provided\."): + with self.assertRaisesRegex( + AppException, + r'Team context not provided. An Organization API key requires a "team_id"\.' + ): env.build_client(self.token, team_id=self.client.team_id + 1) diff --git a/src/superannotate/lib/app/interface/sdk/folders.py b/tests/integration/explore/__init__.py similarity index 100% rename from src/superannotate/lib/app/interface/sdk/folders.py rename to tests/integration/explore/__init__.py diff --git a/tests/integration/explore/test_explore.py b/tests/integration/explore/test_explore.py new file mode 100644 index 000000000..06ab29cce --- /dev/null +++ b/tests/integration/explore/test_explore.py @@ -0,0 +1,329 @@ +import time +from unittest import TestCase + +from src.superannotate import AppException +from src.superannotate import SAClient +from tests.integration.base import BaseTestCase + +sa = SAClient() + +ITEM_KEYS = { + "id", + "name", + "folder_id", + "comments", + "annotation_status", + "path", + "approval_status", + "createdAt", + "updatedAt", + "meta", + "instances", + "custom_metadata", + "subset_ids", + "category_id", + "category_name", + "assignment", + "last_action", + "score", + "is_pinned", + "folder_name", + "is_root_folder", +} + + +class TestExploreMM(TestCase): + PROJECT_NAME = "TestExploreMM" + PROJECT_DESCRIPTION = "TestExploreMM" + PROJECT_TYPE = "Multimodal" + FOLDER_NAME = "test_folder" + SUBSET_NAME = "test_subset" + QUERY = '_status = "NotStarted"' + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def setUp(self, *args, **kwargs): + self.tearDown() + self._project = sa.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[{"attribute": "TemplateState", "value": 1}], + form=self.MULTIMODAL_FORM, + ) + + def tearDown(self) -> None: + try: + projects = sa.list_projects(name=self.PROJECT_NAME) + for project in projects: + try: + sa.delete_project(project=project["id"]) + except Exception as e: + print(str(e)) + except Exception as e: + print(str(e)) + + @property + def folder_path(self): + return f"{self.PROJECT_NAME}/{self.FOLDER_NAME}" + + def test_query(self): + # more than one page (25 items), so paging is exercised + sa.generate_items(self.PROJECT_NAME, 60, name="a") + + items = sa.explore.query(self.PROJECT_NAME, self.QUERY) + + assert len(items) == 60 + assert {i["name"] for i in items} == {f"a_{i:05d}" for i in range(1, 61)} + assert all(set(i) == ITEM_KEYS for i in items) + assert all(i["is_root_folder"] for i in items) + + def test_annotation_status_is_a_name(self): + sa.generate_items(self.PROJECT_NAME, 3, name="a") + sa.set_annotation_statuses(self.PROJECT_NAME, "InProgress", ["a_00001"]) + + # the explore index picks up a status change asynchronously + for _ in range(15): + in_progress = sa.explore.query(self.PROJECT_NAME, '_status = "InProgress"') + if len(in_progress): + break + time.sleep(2) + not_started = sa.explore.query(self.PROJECT_NAME, self.QUERY) + + assert [i["annotation_status"] for i in in_progress] == ["InProgress"] + assert {i["annotation_status"] for i in not_started} == {"NotStarted"} + # same representation as list_items + listed = { + i["name"]: i["annotation_status"] for i in sa.list_items(self.PROJECT_NAME) + } + queried = { + i["name"]: i["annotation_status"] for i in [*in_progress, *not_started] + } + assert queried == listed + + def test_query_count(self): + sa.generate_items(self.PROJECT_NAME, 30, name="a") + + assert sa.explore.query(self.PROJECT_NAME, self.QUERY).count() == 30 + + def test_query_in_folder(self): + sa.create_folder(self.PROJECT_NAME, self.FOLDER_NAME) + sa.generate_items(self.PROJECT_NAME, 3, name="root") + sa.generate_items(self.folder_path, 5, name="folder") + + items = sa.explore.query(self.folder_path, self.QUERY) + + assert len(items) == 5 + assert {i["folder_name"] for i in items} == {self.FOLDER_NAME} + assert sa.explore.query(self.folder_path, self.QUERY).count() == 5 + assert sa.explore.query(self.PROJECT_NAME, self.QUERY).count() == 8 + + def test_query_by_ids(self): + folder = sa.create_folder(self.PROJECT_NAME, self.FOLDER_NAME) + sa.generate_items(self.folder_path, 5, name="folder") + + by_project_id = sa.explore.query(self._project["id"], self.QUERY) + by_ids = sa.explore.query((self._project["id"], folder["id"]), self.QUERY) + + assert len(by_project_id) == 5 + assert len(by_ids) == 5 + + def test_subsets(self): + sa.generate_items(self.PROJECT_NAME, 10, name="a") + items = sa.explore.query(self.PROJECT_NAME, self.QUERY) + + result = sa.explore.add_items_to_subset( + self.PROJECT_NAME, self.SUBSET_NAME, items[:4] + ) + + assert len(result["succeeded"]) == 4 + assert self.SUBSET_NAME in [ + s["name"] for s in sa.explore.get_subsets(self.PROJECT_NAME) + ] + in_subset = sa.explore.query(self.PROJECT_NAME, subset=self.SUBSET_NAME) + assert {i["id"] for i in in_subset} == {i["id"] for i in items[:4]} + assert ( + sa.explore.query( + self.PROJECT_NAME, self.QUERY, subset=self.SUBSET_NAME + ).count() + == 4 + ) + + def test_add_items_to_subset_by_name_and_path(self): + sa.generate_items(self.PROJECT_NAME, 2, name="a") + + result = sa.explore.add_items_to_subset( + self._project["id"], + self.SUBSET_NAME, + [{"name": "a_00001", "path": self.PROJECT_NAME}], + ) + + assert len(result["succeeded"]) == 1 + + def test_query_or_subset_required(self): + with self.assertRaisesRegex(AppException, r"^Provide 'query' or 'subset'$"): + sa.explore.query(self.PROJECT_NAME) + + def test_subset_not_found(self): + # QueryResult is lazy - the subset is resolved when the data or count is read + result = sa.explore.query(self.PROJECT_NAME, self.QUERY, subset="missing") + with self.assertRaisesRegex(AppException, r"^Subset not found$"): + result.data() + with self.assertRaisesRegex(AppException, r"^Subset not found$"): + result.count() + + def test_invalid_query(self): + sa.generate_items(self.PROJECT_NAME, 1, name="a") + result = sa.explore.query(self.PROJECT_NAME, "!invalid query!") + + with self.assertRaisesRegex(AppException, r"^Expected a field but found '!'$"): + result.data() + with self.assertRaisesRegex(AppException, r"^Expected a field but found '!'$"): + result.count() + + def test_unknown_field_in_query(self): + sa.generate_items(self.PROJECT_NAME, 1, name="a") + + with self.assertRaisesRegex( + AppException, r"^Unknown built-in field _nosuchfield$" + ): + sa.explore.query(self.PROJECT_NAME, '_nosuchfield = "x"').data() + + def test_project_not_found(self): + with self.assertRaisesRegex(AppException, r"^Project not found\.$"): + sa.explore.query("TestExploreMM-missing", self.QUERY) + with self.assertRaisesRegex(AppException, r"^Project not found\.$"): + sa.explore.query(999999999, self.QUERY) + + def test_folder_not_found(self): + with self.assertRaisesRegex(AppException, r"^Folder not found\.$"): + sa.explore.query(f"{self.PROJECT_NAME}/missing", self.QUERY) + + def test_folder_id_not_found(self): + with self.assertRaisesRegex( + AppException, r"^You do not have sufficient access to get this items\.$" + ): + sa.explore.query((self._project["id"], 999999999), self.QUERY) + + def test_query_invalid_arguments(self): + with self.assertRaisesRegex( + AppException, + r"argument at index 2\s+String should have at least 1 character", + ): + sa.explore.query(self.PROJECT_NAME, "") + with self.assertRaisesRegex( + AppException, r"argument at index 2\s+Input should be a valid string" + ): + sa.explore.query(self.PROJECT_NAME, 123) + with self.assertRaisesRegex( + AppException, r"argument at index 1\s+Input should be a valid string" + ): + sa.explore.query(1.5, self.QUERY) + + def test_get_subsets_errors(self): + with self.assertRaisesRegex(AppException, r"^Project not found\.$"): + sa.explore.get_subsets("TestExploreMM-missing") + with self.assertRaisesRegex( + AppException, r"argument at index 1\s+Input should be a valid string" + ): + sa.explore.get_subsets(None) + + def test_add_items_to_subset_errors(self): + with self.assertRaisesRegex(AppException, r"^Project not found\.$"): + sa.explore.add_items_to_subset( + "TestExploreMM-missing", self.SUBSET_NAME, [{"id": 1}] + ) + with self.assertRaisesRegex( + AppException, + r"argument at index 2\s+String should have at least 1 character", + ): + sa.explore.add_items_to_subset(self.PROJECT_NAME, "", [{"id": 1}]) + with self.assertRaisesRegex( + AppException, r"argument at index 3\s+Input should be a valid list" + ): + sa.explore.add_items_to_subset( + self.PROJECT_NAME, self.SUBSET_NAME, "not a list" + ) + + def test_add_items_to_subset_skips_unresolvable_items(self): + sa.generate_items(self.PROJECT_NAME, 1, name="a") + missing_path = {"name": "a_00001"} + unknown_item = {"name": "missing", "path": self.PROJECT_NAME} + + result = sa.explore.add_items_to_subset( + self.PROJECT_NAME, self.SUBSET_NAME, [missing_path, unknown_item] + ) + + assert result == { + "succeeded": [], + "failed": [], + "skipped": [missing_path, unknown_item], + } + + +class TestExploreVector(BaseTestCase): + PROJECT_NAME = "TestExploreVector" + PROJECT_TYPE = "Vector" + + def test_unsupported_project_type(self): + self._attach_items(count=1) + result = sa.explore.query(self.PROJECT_NAME, '_status = "NotStarted"') + + with self.assertRaisesRegex(AppException, r"^Unsupported project type\.$"): + result.data() + with self.assertRaisesRegex(AppException, r"^Unsupported project type\.$"): + result.count() + + +class TestExploreDeprecations(TestCase): + PROJECT_NAME = "TestExploreDeprecations" + PROJECT_TYPE = "Vector" + + @classmethod + def setUpClass(cls): + cls.tearDownClass() + sa.create_project(cls.PROJECT_NAME, "desc", cls.PROJECT_TYPE) + sa.attach_items(cls.PROJECT_NAME, [{"name": "a.jpg", "url": "url_1"}]) + + @classmethod + def tearDownClass(cls): + for project in sa.list_projects(name=cls.PROJECT_NAME): + sa.delete_project(project["id"]) + + def test_query(self): + with self.assertWarnsRegex( + DeprecationWarning, r"query\(\) will be deprecated.*4\.7\.0" + ): + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + with self.assertWarnsRegex( + DeprecationWarning, r"query\(\)\.count\(\) will be deprecated.*4\.7\.0" + ): + assert result.count() == 1 + + def test_get_subsets(self): + with self.assertWarnsRegex( + DeprecationWarning, r"get_subsets\(\) will be deprecated.*4\.7\.0" + ): + sa.get_subsets(self.PROJECT_NAME) + + def test_add_items_to_subset(self): + with self.assertWarnsRegex( + DeprecationWarning, r"add_items_to_subset\(\) will be deprecated.*4\.7\.0" + ): + sa.add_items_to_subset( + self.PROJECT_NAME, + "subset", + [{"name": "a.jpg", "path": self.PROJECT_NAME}], + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a4098f6f4..82f66c2f3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,6 +7,7 @@ """ import pytest +import superannotate # noqa: F401 - puts lib/ on sys.path, for tests importing lib.* from tests import env CREDENTIAL_VARS = ( diff --git a/tests/unit/test_env.py b/tests/unit/test_env.py index a24118a0d..c9c7d6853 100644 --- a/tests/unit/test_env.py +++ b/tests/unit/test_env.py @@ -162,7 +162,22 @@ def _respond_with(self, payload): class BuildClientTestCase(_BuilderFixture): - """env.build_client sets every variable the SDK reads, inheriting none.""" + """env.build_client goes through a config file, not the environment. + + Given ``config_path`` the SDK reads the file and consults the environment for + nothing, so there is nothing for the ambient environment to perturb and nothing + to put back afterwards - the same way ``build_org_client`` works. + """ + + def setUp(self): + super().setUp() + # A team-scoped client fetches its team and its user while it is built. + # Neither is what these tests are about, and the .env names a backend that + # does not resolve. + for call in ("get_team", "get_current_user"): + patcher = patch(f"lib.infrastructure.controller.TeamController.{call}") + patcher.start() + self.addCleanup(patcher.stop) def test_the_backend_comes_from_the_dotenv_even_when_the_environment_is_scrubbed( self, @@ -188,6 +203,17 @@ def test_an_ambient_team_id_does_not_attach_itself_to_the_token(self): assert client.controller.team_id == 6085 + def test_a_team_id_can_be_written_into_the_config_instead_of_passed(self): + # The other path a caller has: an organization key carries no team, so the + # one to act in is named by the configuration the client is built from. + with self._respond_with(self.ORG_TOKEN): + client = env.build_client( + self.API_KEY, team_id=6085, team_id_via_config=True + ) + + assert client.controller.config["SA_TEAM_ID"] == 6085 + assert client.controller.team_id == 6085 + def test_nothing_is_left_behind_in_the_environment(self): # It used to call load_dotenv, which writes to os.environ for good. with self._respond_with(self.TEAM_TOKEN): @@ -196,6 +222,29 @@ def test_nothing_is_left_behind_in_the_environment(self): assert "SA_TOKEN" not in os.environ assert "SA_URL" not in os.environ + def test_the_environment_is_untouched_while_it_is_built(self): + # The point of the config file: nothing is written to the environment, not + # even for the duration of the call. Observed from inside the auth request, + # which happens mid-construction - checking afterwards proves nothing, + # because setting and restoring also leaves the environment as it was. + seen = {} + + def record(*args, **kwargs): + seen["SA_TOKEN"] = os.environ.get("SA_TOKEN") + seen["SA_TEAM_ID"] = os.environ.get("SA_TEAM_ID") + return self._response(self.TEAM_TOKEN) + + with patch.dict(os.environ, {"SA_TEAM_ID": "42"}), patch( + "lib.infrastructure.services.auth.requests.post", side_effect=record + ): + client = env.build_client(self.API_KEY) + + assert client.controller.token_context.token == self.API_KEY + # The token never entered the environment, and the ambient team id was left + # exactly as it was rather than being cleared and put back. + assert seen["SA_TOKEN"] is None + assert seen["SA_TEAM_ID"] == "42" + class BuildOrgClientTestCase(_BuilderFixture): """env.build_org_client goes through a config file, not the environment. diff --git a/tests/unit/test_explore.py b/tests/unit/test_explore.py new file mode 100644 index 000000000..013e5d03a --- /dev/null +++ b/tests/unit/test_explore.py @@ -0,0 +1,394 @@ +"""SAClient.explore - the Explore query language namespace. + +Nothing here touches the network: the HTTP client, the controller or Tracker._track +are replaced, so what is under test is the SDK's own behaviour - paging, subset +resolution, argument checks, the response model, deprecations and telemetry names. +""" + +import os +import warnings +from unittest import TestCase +from unittest.mock import MagicMock +from unittest.mock import patch + +from lib.app.interface.base_interface import Tracker +from lib.app.interface.sdk.explore import Explore +from lib.core.entities import FolderEntity +from lib.core.entities import ProjectEntity +from lib.core.entities import SubSetEntity +from lib.core.service_types import ExploreItem +from lib.core.service_types import ExploreQueryResponse +from lib.core.service_types import ServiceResponse +from lib.core.usecases import ExploreQueryCountUseCase +from lib.core.usecases import ExploreQueryUseCase +from lib.infrastructure.services.explore import ExploreService +from superannotate import AppException +from superannotate import SAClient + +ITEM = { + "id": 196560932, + "name": "TEST_00001", + "folder_id": 867191, + "comments": [], + "annotation_status": 2, + "annotator_id": None, + "annotator_name": None, + "path": "custom_llm", + "qa_id": None, + "approval_status": 0, + "createdAt": "2026-08-04T03:50:53.000Z", + "updatedAt": "2026-09-02T10:37:18.000Z", + "meta": {"integration_id": None}, + "instances": [], + "componentGenerationStatuses": {}, + "custom_metadata": {}, + "subset_ids": [], + "category_id": [], + "category_name": None, + "assignment": [], + "last_action": { + "date": "2026-09-02T10:37:17.217Z", + "email": "test@superannotate.com", + }, + "score": [], + "is_pinned": 0, + "folder_name": "root", + "is_root_folder": 1, + "lores": "custom_llm", + "thumbnail": "custom_llm", +} + +EXPECTED_KEYS = { + "id", + "name", + "folder_id", + "comments", + "annotation_status", + "path", + "approval_status", + "createdAt", + "updatedAt", + "meta", + "instances", + "custom_metadata", + "subset_ids", + "category_id", + "category_name", + "assignment", + "last_action", + "score", + "is_pinned", + "folder_name", + "is_root_folder", +} + +PROJECT = ProjectEntity(id=1476298, name="Project", type=8) +ROOT = FolderEntity(id=1, name="root", is_root=True) +FOLDER = FolderEntity(id=1767159, name="folder", is_root=False) + + +def _page(n: int, status=200): + return ExploreQueryResponse(status=status, res_data=[ITEM] * n) + + +class ExploreItemTestCase(TestCase): + def test_keeps_only_the_documented_keys_as_sent(self): + dumped = ExploreItem(**ITEM).model_dump() + + assert set(dumped) == EXPECTED_KEYS + assert dumped["annotation_status"] == 2 + assert dumped["is_pinned"] == 0 + assert dumped["meta"] == {"integration_id": None} + assert dumped["last_action"] == ITEM["last_action"] + + +class ExploreServiceTestCase(TestCase): + def setUp(self): + self.client = MagicMock() + self.client.api_url = "https://api.devsuperannotate.com" + self.service = ExploreService(self.client) + + def test_query_pages_by_25_until_a_short_page(self): + pages = iter([_page(25), _page(25), _page(3)]) + sent = [] + + def request(url, method, params, data, content_type): + sent.append(dict(data)) # the service reuses one body dict across pages + return next(pages) + + self.client.request.side_effect = request + + response = self.service.explore_query( + PROJECT, FOLDER, query='_status = "InProgress"', subset_id=7 + ) + + assert response.ok + assert len(response.data) == 53 + calls = self.client.request.call_args_list + assert [body["image_index"] for body in sent] == [0, 25, 50] + url, method = calls[0].args + assert url == "https://explore-service.devsuperannotate.com/api/v3/items/search" + assert method == "post" + assert calls[0].kwargs["params"] == { + "project_id": 1476298, + "includeFolderNames": True, + "folder_id": 1767159, + "subset_id": 7, + } + assert sent[0]["limit"] == 25 + assert sent[0]["query"] == '_status = "InProgress"' + + def test_query_returns_the_backend_error(self): + self.client.request.return_value = ExploreQueryResponse( + status=400, + res_error="Data may be incomplete. Resync from the Explore page to ensure accuracy.", + ) + + response = self.service.explore_query(PROJECT, query="x") + + assert not response.ok + assert response.error.startswith("Data may be incomplete") + + def test_count_calls_v3_count(self): + self.client.request.return_value = ServiceResponse( + status=200, res_data={"count": 5} + ) + + self.service.explore_query_count(PROJECT, query="x") + + call = self.client.request.call_args + assert call.args[0] == ( + "https://explore-service.devsuperannotate.com/api/v3/items/count" + ) + assert call.kwargs["params"] == { + "project_id": 1476298, + "includeFolderNames": True, + } + assert call.kwargs["data"] == {"query": "x"} + + +class ExploreQueryUseCaseTestCase(TestCase): + def setUp(self): + self.service_provider = MagicMock() + self.explore = self.service_provider.explore + + def _use_case(self, cls=ExploreQueryUseCase, folder=ROOT, **kwargs): + return cls( + reporter=MagicMock(), + project=PROJECT, + folder=folder, + service_provider=self.service_provider, + **{"query": None, **kwargs}, + ) + + def test_requires_query_or_subset(self): + response = self._use_case().execute() + + assert str(response.errors) == "Provide 'query' or 'subset'" + self.explore.explore_query.assert_not_called() + + def test_resolves_the_subset_name(self): + self.explore.list_subsets.return_value = ServiceResponse( + status=200, res_data=[SubSetEntity(id=7, name="golden-set")] + ) + self.explore.explore_query.return_value = _page(1) + + response = self._use_case(subset="golden-set", folder=FOLDER).execute() + + assert not response.errors + self.explore.explore_query.assert_called_once_with( + PROJECT, query=None, folder=FOLDER, subset_id=7 + ) + + def test_annotation_status_is_reported_by_name(self): + self.explore.explore_query.return_value = _page(2) + self.service_provider.get_annotation_status_name.return_value = "InProgress" + + response = self._use_case(query="x").execute() + + assert [i.annotation_status for i in response.data] == ["InProgress"] * 2 + self.service_provider.get_annotation_status_name.assert_called_with(PROJECT, 2) + + def test_unknown_subset(self): + self.explore.list_subsets.return_value = ServiceResponse( + status=200, res_data=[SubSetEntity(id=7, name="other")] + ) + + response = self._use_case(subset="golden-set").execute() + + assert str(response.errors) == "Subset not found" + + def test_root_folder_is_not_sent(self): + self.explore.explore_query.return_value = _page(1) + + self._use_case(query="x").execute() + + self.explore.explore_query.assert_called_once_with( + PROJECT, query="x", folder=None + ) + + def test_backend_error_is_reported(self): + self.explore.explore_query.return_value = ExploreQueryResponse( + status=400, res_error="Invalid query" + ) + + response = self._use_case(query="x").execute() + + assert response.errors == "Invalid query" + + def test_count(self): + self.explore.explore_query_count.return_value = ServiceResponse( + status=200, res_data={"count": 42} + ) + + response = self._use_case(ExploreQueryCountUseCase, query="x").execute() + + assert response.data == 42 + + +class _Recorder: + def __init__(self): + self.events = [] + + def as_track(self): + events = self.events + + def _track( + tracker, user_id, event_name, data, *, client, explicit_credentials=False + ): + events.append({"event": event_name, "data": data}) + + return _track + + +@patch.dict(os.environ, {"sa_version": "4.6.2"}) +class ExploreTestCase(TestCase): + def setUp(self): + self.recorder = _Recorder() + patcher = patch.object(Tracker, "_track", self.recorder.as_track()) + patcher.start() + self.addCleanup(patcher.stop) + self.controller = MagicMock() + self.controller.get_project_folder.return_value = (PROJECT, ROOT) + self.controller.explore_query.return_value = [ExploreItem(**ITEM)] + self.controller.explore_query_count.return_value = 42 + self.explore = Explore(self.controller) + + def test_query_returns_serialized_items_lazily(self): + result = self.explore.query(project="Project", query="x") + self.controller.explore_query.assert_not_called() + + assert list(result) == [ExploreItem(**ITEM).model_dump()] + self.controller.explore_query.assert_called_once_with(PROJECT, ROOT, "x", None) + + def test_count_does_not_fetch_items(self): + result = self.explore.query(project="Project", query="x", subset="s") + + assert result.count() == 42 + self.controller.explore_query.assert_not_called() + self.controller.explore_query_count.assert_called_once_with( + PROJECT, ROOT, "x", "s" + ) + + def test_query_or_subset_is_required(self): + with self.assertRaisesRegex(AppException, "Provide 'query' or 'subset'"): + self.explore.query(project="Project") + self.controller.get_project_folder.assert_not_called() + + def test_events_are_prefixed_with_the_namespace(self): + self.explore.query(project="Project/folder", query="x").count() + self.controller.subsets.list.return_value = MagicMock(errors=None, data=[]) + self.explore.get_subsets(project="Project") + + assert [e["event"] for e in self.recorder.events] == [ + "explore.query", + "explore.query.count", + "explore.get_subsets", + ] + count_data = self.recorder.events[1]["data"] + assert count_data["query"] == "x" + assert "_resolved" not in count_data + + def test_client_exposes_one_namespace(self): + client = object.__new__(SAClient) + client.controller = self.controller + + assert isinstance(client.explore, Explore) + assert client.explore is client.explore + assert client.explore.controller is self.controller + + +@patch.dict(os.environ, {"sa_version": "4.6.2", "SA_SKIP_METRICS": "true"}) +class DeprecationsTestCase(TestCase): + def setUp(self): + self.client = object.__new__(SAClient) + self.client.controller = MagicMock() + self.client.controller.get_project_folder.return_value = (PROJECT, ROOT) + self.client.controller.query_items_count.return_value = 3 + self.client.controller.subsets.list.return_value = MagicMock( + errors=None, data=[] + ) + self.client.controller.subsets.add_items.return_value = MagicMock( + errors=None, data={} + ) + + def _warnings(self, call): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + call() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + # Attributed to the caller, not to the SDK's wrappers - Python's default filter + # only shows a DeprecationWarning attributed to the user's own code. + assert all(w.filename == __file__ for w in deprecations), [ + w.filename for w in deprecations + ] + return [str(w.message) for w in deprecations] + + def test_query(self): + messages = self._warnings(lambda: self.client.query("Project", query="x")) + + assert len(messages) == 1 + assert "query() will be deprecated and removed in version 4.7.0" in messages[0] + assert "SAClient.explore.query()" in messages[0] + + def test_query_count(self): + result = self.client.query("Project", query="x") + + messages = self._warnings(result.count) + + assert messages == [ + "This function query().count() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.query().count()" + ] + + def test_get_subsets(self): + messages = self._warnings(lambda: self.client.get_subsets("Project")) + + assert messages == [ + "This function get_subsets() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.get_subsets()" + ] + + def test_add_items_to_subset(self): + messages = self._warnings( + lambda: self.client.add_items_to_subset("Project", "s", [{"id": 1}]) + ) + + assert messages == [ + "This function add_items_to_subset() will be deprecated and removed in version 4.7.0\n" + "Recommended replacement: SAClient.explore.add_items_to_subset()" + ] + + def test_explore_methods_do_not_warn(self): + explore = Explore(self.client.controller) + self.client.controller.explore_query_count.return_value = 1 + + messages = self._warnings( + lambda: ( + explore.query("Project", query="x").count(), + explore.get_subsets("Project"), + explore.add_items_to_subset("Project", "s", [{"id": 1}]), + ) + ) + + assert messages == []