From d0e976945b9bee5f11acf645ca449427f165015d Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 23 Sep 2026 09:56:38 +0400 Subject: [PATCH 1/9] Add new key managament interface --- CHANGELOG.rst | 15 + docs/source/api_reference/api_team.rst | 4 + .../lib/app/interface/sdk_interface.py | 266 +++++++++++++++++ src/superannotate/lib/app/interface/types.py | 10 + src/superannotate/lib/core/__init__.py | 7 + .../lib/core/entities/filters.py | 12 + .../lib/core/entities/work_managament.py | 51 ++++ src/superannotate/lib/core/service_types.py | 5 + .../lib/core/serviceproviders.py | 22 ++ .../lib/infrastructure/controller.py | 132 +++++++++ .../lib/infrastructure/query_builder.py | 33 +++ .../services/work_management.py | 62 ++++ tests/env.py | 76 +++-- .../integration/client/test_team_api_keys.py | 276 ++++++++++++++++++ tests/integration/client/test_token_scopes.py | 4 +- tests/unit/test_env.py | 51 +++- 16 files changed, 981 insertions(+), 45 deletions(-) create mode 100644 tests/integration/client/test_team_api_keys.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a858204e..33a67cf36 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,21 @@ History All release highlights of this project will be documented in this file. +4.6.2 - Sep 17, 2026 +____________________ + + +**Added** + + - ``SAClient.generate_team_api_key()`` Generates a new team API key bound to the given team, active immediately. Requires an Organization API Key and a team ID. + + - ``SAClient.rotate_team_api_key()`` 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. Requires an Organization API Key and a team ID. + + - ``SAClient.list_team_api_keys()`` Returns the team API keys in the given team, newest first, with filtering by ``id``, ``name`` and ``status``. + + - ``SAClient.revoke_team_api_key()`` Revokes a team API key that is in the Active or Rotating state. + + 4.6.1 - Sep 13, 2026 ____________________ 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/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 1e8968871..7e7f63a00 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -41,6 +41,7 @@ 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 @@ -1408,6 +1409,271 @@ 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; the key is active + immediately. Must use Organization API Key and team ID 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 + + :raises AppException: If the name is empty, or if ``expires_in`` is neither a + number of days, a duration, nor a date. Everything else the backend + decides - a duplicate name, an expiry it will not accept, or a key not + allowed to create keys - is reported with the backend's own message. + + 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["expire_immediately", 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 and team ID 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: "expire_immediately", + or 1, 7, 14, 30 days. Defaults to 7. + :type overlap: Union[int, str], 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 + + :raises AppException: If the key does not exist, is already revoked or + expired, if the overlap outlasts the new key's expiration, or if + ``expires_in`` is neither a number of days, a duration, nor a date. + + 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="expire_immediately") + + 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 and team ID, 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 + + :raises AppException: If a filter param is not supported, or if ``status`` + contains a value outside the four accepted statuses. + + 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 and team ID, 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 + + :raises AppException: If the key does not exist or does not belong to the + caller, or if it is already revoked or expired. + + 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. 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..cb42be0c0 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -200,6 +200,13 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): } +#: The overlap that expires the rotated key at once, instead of keeping a window. +TEAM_API_KEY_EXPIRE_IMMEDIATELY = "expire_immediately" + +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..22db622a7 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,7 @@ class TelemetryScoreListResponse(ServiceResponse): class WMPermissionGroupListResponse(ServiceResponse): res_data: list[PermissionGroupEntity] = None + + +class WMTeamAPIKeyListResponse(ServiceResponse): + res_data: list[TeamAPIKeyEntity] = None diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 92061b8e7..3751a6eba 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,26 @@ 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_days: int, 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 + class BaseProjectService(SuperannotateServiceProvider): @abstractmethod diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 348279943..02c30c8a0 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,130 @@ 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 | str, + expires_in: int | timedelta | datetime, + ) -> TeamAPIKeyEntity: + key = self._find_rotatable_key(name, self.list_team_api_keys()) + expires_at = self._resolve_expiration(expires_in) + return self.service_provider.work_management.rotate_team_api_key( + key_id=key.id, + overlap_days=self._resolve_overlap(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.list_team_api_keys()) + 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 + + @staticmethod + def _resolve_expiration(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 expires_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") + + @staticmethod + def _resolve_overlap(overlap: int | str, expires_at: str) -> int: + """``overlap`` as the number of days the rotated key stays valid for. + + "expire_immediately" is no window at all. 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. + """ + days = ( + 0 if overlap == constants.TEAM_API_KEY_EXPIRE_IMMEDIATELY else int(overlap) + ) + now = datetime.now(timezone.utc) + expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) + if expiry > now and now + timedelta(days=days) > expiry: + raise AppException( + "The overlap period cannot be longer than the new key expiration period." + ) + return days + + @staticmethod + def _find_key( + public_id: str, keys: list[TeamAPIKeyEntity] + ) -> TeamAPIKeyEntity | None: + return next((key for key in keys if key.public_id == public_id), None) + + @classmethod + def _find_rotatable_key( + cls, name: str, keys: list[TeamAPIKeyEntity] + ) -> 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 = cls._find_key(name, keys) + if key is None: + named = [k for k in keys if k.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__( 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/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 326b10c71..8b89bab90 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 @@ -26,6 +28,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 +84,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}" + URL_REVOKE_API_KEY = "teamkeys/{key_id}" def list_folders(self, project_id: int, query: Query) -> ServiceResponse: result = self.client.jsx_paginate( @@ -661,3 +668,58 @@ 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}, + ) + response.raise_for_status() + return TeamAPIKeyEntity(**response.res_data["data"]) + + def rotate_team_api_key( + self, key_id: int, overlap_days: int, expires_at: str + ) -> TeamAPIKeyEntity: + return self.client.request( + url=self.URL_ROTATE_API_KEY.format(key_id=key_id), + headers={ + "x-sa-entity-context": encode_entity_context( + team_id=self.client.team_id + ), + }, + ) + + 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="get", + 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): + 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 + ), + }, + ) + response.raise_for_status() 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_team_api_keys.py b/tests/integration/client/test_team_api_keys.py new file mode 100644 index 000000000..99c33f3a1 --- /dev/null +++ b/tests/integration/client/test_team_api_keys.py @@ -0,0 +1,276 @@ +"""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 contextlib +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 src.superannotate.lib.core.entities.context import TokenScope +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 names this test minted. A rotation reuses the name of the key it + #: rotates, so the pair it leaves behind is covered by the same entry. + self.minted = [] + + def tearDown(self): + for key in self.client.list_team_api_keys(status__in=LIVE, nane__startswith=NAME_PREFIX).keys(): + self.minted.append(key) + if key["name"] in self.minted: + with contextlib.suppress(AppException): + self.client.revoke_team_api_key(public_id=key["public_id"]) + + 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]}" + self.minted.append(name) + return self.client.generate_team_api_key(name=name, **kwargs) + + def listed(self, public_id: str) -> dict: + """One key as the backend now reports it, by its public id.""" + keys = self.client.list_team_api_keys() + key = next((k for k in keys if k["public_id"] == public_id), None) + assert key is not None, f"{public_id} is not among the team's keys" + return key + + +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"] in self.minted + 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) + + def test_the_generated_key_authenticates_as_its_team(self): + generated = self.generate() + + client = env.build_client(generated["api_key"]) + + assert client.controller.token_context.scope == TokenScope.TEAM + assert client.controller.team_id == self.team_id + + +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["public_id"]) + + def test_filtering_by_name_and_status(self): + generated = self.generate() + + by_name = self.client.list_team_api_keys(name=generated["name"]) + by_status = self.client.list_team_api_keys( + name=generated["name"], status__in=LIVE + ) + excluded = self.client.list_team_api_keys( + name=generated["name"], status__notin=LIVE + ) + + assert [k["public_id"] for k in by_name] == [generated["public_id"]] + assert [k["public_id"] for k in by_status] == [generated["public_id"]] + assert excluded == [] + + def test_filtering_by_a_fragment_of_the_name(self): + generated = self.generate() + + assert generated["public_id"] in [ + k["public_id"] + for k in self.client.list_team_api_keys(name__starts=NAME_PREFIX) + ] + + 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["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["public_id"]) + assert rotated["status"] == "ROTATING" + assert rotated["overlapEndAt"] + + 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["public_id"] != old["public_id"] + assert self.listed(old["public_id"])["status"] == "ROTATING" + + 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["public_id"])["status"] == "ACTIVE" + + def test_an_overlap_outside_the_offered_windows_is_rejected(self): + old = self.generate() + + for invalid in (3, 0, "immediately"): + with pytest.raises(AppException): + self.client.rotate_team_api_key(name=old["name"], overlap=invalid) + + assert self.listed(old["public_id"])["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["public_id"]) + assert revoked["status"] == "REVOKED" + assert revoked["revokedAt"] + + def test_a_rotating_key_can_be_revoked(self): + old = self.generate() + 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(old["public_id"])["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..234d767a0 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 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. From be3af6b792887593cdc6f58887f9e29071b7f9ba Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 23 Sep 2026 17:07:12 +0400 Subject: [PATCH 2/9] Add explore interface --- CHANGELOG.rst | 23 ++ docs/source/api_reference/api_client.rst | 1 + docs/source/api_reference/api_explore.rst | 11 + src/superannotate/__init__.py | 2 +- src/superannotate/lib/app/helpers.py | 39 ++ .../lib/app/interface/base_interface.py | 16 +- .../lib/app/interface/sdk/__init__.py | 12 + .../lib/app/interface/sdk/explore.py | 261 ++++++++++++ .../lib/app/interface/sdk/project.py | 0 .../lib/app/interface/sdk_interface.py | 48 ++- src/superannotate/lib/core/service_types.py | 43 ++ .../lib/core/serviceproviders.py | 20 + src/superannotate/lib/core/usecases/items.py | 81 ++++ .../lib/infrastructure/controller.py | 38 ++ .../lib/infrastructure/services/explore.py | 85 +++- .../integration/explore/__init__.py | 0 tests/integration/explore/test_explore.py | 305 ++++++++++++++ tests/unit/conftest.py | 1 + tests/unit/test_explore.py | 385 ++++++++++++++++++ 19 files changed, 1354 insertions(+), 17 deletions(-) create mode 100644 docs/source/api_reference/api_explore.rst create mode 100644 src/superannotate/lib/app/interface/sdk/explore.py delete mode 100644 src/superannotate/lib/app/interface/sdk/project.py rename src/superannotate/lib/app/interface/sdk/folders.py => tests/integration/explore/__init__.py (100%) create mode 100644 tests/integration/explore/test_explore.py create mode 100644 tests/unit/test_explore.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1a858204e..65789c5a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,29 @@ History All release highlights of this project will be documented in this file. +4.6.2 - Unreleased +__________________ + + +**Added** + + - ``SAClient.explore`` New namespace for querying and curating project data with the Explore query language (https://doc.superannotate.com/docs/queries-beta). + + - ``SAClient.explore.query()`` Returns items that satisfy an Explore query. ``.count()`` returns the number of matching items without fetching them. + + - ``SAClient.explore.get_subsets()`` Returns the subsets of a project. + + - ``SAClient.explore.add_items_to_subset()`` Associates items with a subset, creating the subset if it does not 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/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..8ddb6ee8d --- /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": 2, + "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..616b0e7be 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,6 +36,7 @@ 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 @@ -88,6 +90,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 +343,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 @@ -4689,21 +4699,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 +5566,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 +5945,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/core/service_types.py b/src/superannotate/lib/core/service_types.py index 0740fdf09..82a090004 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -264,3 +264,46 @@ class TelemetryScoreListResponse(ServiceResponse): class WMPermissionGroupListResponse(ServiceResponse): res_data: list[PermissionGroupEntity] = 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. annotation_status and is_pinned + are ints); 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 | 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..0ab562951 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -807,6 +807,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..ff860a251 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -246,6 +246,87 @@ 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: + self._response.data = service_response.data + 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..591d21d5c 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -2207,6 +2207,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/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/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..d56bd994d --- /dev/null +++ b/tests/integration/explore/test_explore.py @@ -0,0 +1,305 @@ +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_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_explore.py b/tests/unit/test_explore.py new file mode 100644 index 000000000..4e0d6c3c0 --- /dev/null +++ b/tests/unit/test_explore.py @@ -0,0 +1,385 @@ +"""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_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 == [] From e5c38933a0b6aa5e18f0be9ced3bdd742093bd54 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 23 Sep 2026 17:21:02 +0400 Subject: [PATCH 3/9] Add status serialzier --- .../lib/app/interface/sdk/explore.py | 2 +- src/superannotate/lib/core/service_types.py | 7 +++--- src/superannotate/lib/core/usecases/items.py | 10 +++++++- tests/integration/explore/test_explore.py | 24 +++++++++++++++++++ tests/unit/test_explore.py | 9 +++++++ 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk/explore.py b/src/superannotate/lib/app/interface/sdk/explore.py index 8ddb6ee8d..3075dade1 100644 --- a/src/superannotate/lib/app/interface/sdk/explore.py +++ b/src/superannotate/lib/app/interface/sdk/explore.py @@ -88,7 +88,7 @@ def query( "name": "TEST_00001", "folder_id": 867191, "comments": [], - "annotation_status": 2, + "annotation_status": "InProgress", "path": "custom_llm", "approval_status": 0, "createdAt": "2026-08-04T03:50:53.000Z", diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index 82a090004..608c50aa4 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -276,8 +276,9 @@ class ExploreLastAction(BaseModel): 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. annotation_status and is_pinned - are ints); keys not listed here are dropped. + 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") @@ -286,7 +287,7 @@ class ExploreItem(BaseModel): name: str folder_id: int | None = None comments: list = Field(default_factory=list) - annotation_status: int | None = None + annotation_status: int | str | None = None path: str | None = None approval_status: int | None = None createdAt: str | None = None diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index ff860a251..6064604fd 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -303,7 +303,15 @@ def execute(self) -> Response: self._project, **query_kwargs ) if service_response.ok: - self._response.data = service_response.data + 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 diff --git a/tests/integration/explore/test_explore.py b/tests/integration/explore/test_explore.py index d56bd994d..06ab29cce 100644 --- a/tests/integration/explore/test_explore.py +++ b/tests/integration/explore/test_explore.py @@ -1,3 +1,4 @@ +import time from unittest import TestCase from src.superannotate import AppException @@ -89,6 +90,29 @@ def test_query(self): 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") diff --git a/tests/unit/test_explore.py b/tests/unit/test_explore.py index 4e0d6c3c0..013e5d03a 100644 --- a/tests/unit/test_explore.py +++ b/tests/unit/test_explore.py @@ -200,6 +200,15 @@ def test_resolves_the_subset_name(self): 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")] From 4057e63d08ecea522a0eadbd42ae42798603cbc8 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 28 Sep 2026 11:14:19 +0400 Subject: [PATCH 4/9] Adjust team token api --- .../lib/app/interface/sdk_interface.py | 1 + .../lib/core/serviceproviders.py | 2 +- .../lib/infrastructure/controller.py | 71 ++++++--- .../services/work_management.py | 17 ++- .../integration/client/test_team_api_keys.py | 140 ++++++++++++------ 5 files changed, 155 insertions(+), 76 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 7e7f63a00..8e97ab448 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2196,6 +2196,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 diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 3751a6eba..ef832ce21 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -311,7 +311,7 @@ def create_team_api_key(self, name: str, expires_at: str) -> TeamAPIKeyEntity: @abstractmethod def rotate_team_api_key( - self, key_id: int, overlap_days: int, expires_at: str + self, key_id: int, overlap_end_at: str, expires_at: str ) -> TeamAPIKeyEntity: raise NotImplementedError diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 02c30c8a0..6fee3c2a4 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -660,11 +660,11 @@ def rotate_team_api_key( overlap: int | str, expires_in: int | timedelta | datetime, ) -> TeamAPIKeyEntity: - key = self._find_rotatable_key(name, self.list_team_api_keys()) + 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_days=self._resolve_overlap(overlap, expires_at), + overlap_end_at=self._resolve_overlap_end(overlap, expires_at), expires_at=expires_at, ) @@ -684,7 +684,7 @@ def list_team_api_keys(self, **filters) -> list[TeamAPIKeyEntity]: 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.list_team_api_keys()) + 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: @@ -698,8 +698,8 @@ def _validate_key_name(name: str) -> str: raise AppException("Name cannot be empty") return name - @staticmethod - def _resolve_expiration(expires_in: int | timedelta | datetime) -> str: + @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 @@ -719,46 +719,73 @@ def _resolve_expiration(expires_in: int | timedelta | datetime) -> str: # 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 expires_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") - - @staticmethod - def _resolve_overlap(overlap: int | str, expires_at: str) -> int: - """``overlap`` as the number of days the rotated key stays valid for. + return cls._as_wire_date(expires_at) - "expire_immediately" is no window at all. 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. + @classmethod + def _resolve_overlap_end(cls, overlap: int | str, 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 + "expire_immediately" 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. """ days = ( 0 if overlap == constants.TEAM_API_KEY_EXPIRE_IMMEDIATELY else int(overlap) ) now = datetime.now(timezone.utc) + overlap_end = now + timedelta(days=days) expiry = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) - if expiry > now and now + timedelta(days=days) > expiry: + if expiry > now and overlap_end > expiry: raise AppException( "The overlap period cannot be longer than the new key expiration period." ) - return days + 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: - return next((key for key in keys if key.public_id == public_id), None) + """The key a public id names - the live one, when a rotation left two. - @classmethod - def _find_rotatable_key( - cls, name: str, keys: list[TeamAPIKeyEntity] - ) -> TeamAPIKeyEntity: + 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 = cls._find_key(name, keys) + key = self._find_key(name, self._keys_where("public_id", name)) if key is None: - named = [k for k in keys if k.name == name] + 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), diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 8b89bab90..c0445d9d8 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -86,7 +86,7 @@ class WorkManagementService(BaseWorkManagementService): 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}" + 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: @@ -684,23 +684,29 @@ def create_team_api_key(self, name: str, expires_at: str) -> TeamAPIKeyEntity: return TeamAPIKeyEntity(**response.res_data["data"]) def rotate_team_api_key( - self, key_id: int, overlap_days: int, expires_at: str + self, key_id: int, overlap_end_at: str, expires_at: str ) -> TeamAPIKeyEntity: - return self.client.request( + 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}}, ) + 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="get", + method="post", body_query=body_query & Filter("scope_type", TokenScope.TEAM.value, OperatorEnum.EQ), headers={ @@ -712,7 +718,7 @@ def list_team_api_keys( item_type=TeamAPIKeyEntity, ) - def revoke_team_api_key(self, key_id: int): + 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", @@ -723,3 +729,4 @@ def revoke_team_api_key(self, key_id: int): }, ) response.raise_for_status() + return TeamAPIKeyEntity(**response.res_data["data"]) diff --git a/tests/integration/client/test_team_api_keys.py b/tests/integration/client/test_team_api_keys.py index 99c33f3a1..94879ee7f 100644 --- a/tests/integration/client/test_team_api_keys.py +++ b/tests/integration/client/test_team_api_keys.py @@ -8,7 +8,6 @@ finishes, so a run leaves the team as it found it. """ -import contextlib import uuid from datetime import datetime from datetime import timedelta @@ -48,29 +47,46 @@ def setUpClass(cls): ) def setUp(self): - #: The names this test minted. A rotation reuses the name of the key it - #: rotates, so the pair it leaves behind is covered by the same entry. + #: 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): - for key in self.client.list_team_api_keys(status__in=LIVE, nane__startswith=NAME_PREFIX).keys(): - self.minted.append(key) - if key["name"] in self.minted: - with contextlib.suppress(AppException): - self.client.revoke_team_api_key(public_id=key["public_id"]) + """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]}" - self.minted.append(name) - return self.client.generate_team_api_key(name=name, **kwargs) + 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. - def listed(self, public_id: str) -> dict: - """One key as the backend now reports it, by its public id.""" + 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() - key = next((k for k in keys if k["public_id"] == public_id), None) - assert key is not None, f"{public_id} is not among the team's keys" - return key + 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): @@ -79,7 +95,7 @@ def test_a_generated_key_is_active_and_carries_its_secret(self): assert generated["status"] == "ACTIVE" assert generated["scope"] == {"team_id": self.team_id} - assert generated["name"] in self.minted + 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_") @@ -120,15 +136,6 @@ def test_a_duplicate_name_is_refused_by_the_backend(self): with pytest.raises(AppException): self.generate(name=taken) - def test_the_generated_key_authenticates_as_its_team(self): - generated = self.generate() - - client = env.build_client(generated["api_key"]) - - assert client.controller.token_context.scope == TokenScope.TEAM - assert client.controller.team_id == self.team_id - - class TestListTeamAPIKeys(TeamAPIKeyTestCase): def test_a_generated_key_is_listed_first(self): generated = self.generate() @@ -142,30 +149,49 @@ def test_a_generated_key_is_listed_first(self): def test_a_listed_key_carries_no_secret(self): generated = self.generate() - assert "api_key" not in self.listed(generated["public_id"]) + 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"]) - by_status = self.client.list_team_api_keys( - name=generated["name"], status__in=LIVE + active = self.client.list_team_api_keys(name=generated["name"], status="Active") + revoked = self.client.list_team_api_keys( + name=generated["name"], status="Revoked" ) - excluded = self.client.list_team_api_keys( - name=generated["name"], status__notin=LIVE + other_than_active = self.client.list_team_api_keys( + name=generated["name"], status__ne="Active" ) - assert [k["public_id"] for k in by_name] == [generated["public_id"]] - assert [k["public_id"] for k in by_status] == [generated["public_id"]] - assert excluded == [] + 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 == [] + + @pytest.mark.xfail( + strict=True, + reason="backend: a status $in/$notin filter answers 500 Internal server " + "error, though $eq and $ne on the same column are fine", + ) + 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() - assert generated["public_id"] in [ - k["public_id"] - for k in self.client.list_team_api_keys(name__starts=NAME_PREFIX) - ] + 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}") == [] @@ -187,22 +213,33 @@ def test_rotating_mints_a_new_key_and_leaves_the_old_one_rotating(self): name=old["name"], overlap=1, expires_in=90 ) - assert new["public_id"] != old["public_id"] + 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["public_id"]) + rotated = self.listed(old) assert rotated["status"] == "ROTATING" - assert rotated["overlapEndAt"] + 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["public_id"] != old["public_id"] - assert self.listed(old["public_id"])["status"] == "ROTATING" + 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="expire_immediately") + + # 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() @@ -221,7 +258,7 @@ def test_an_overlap_longer_than_the_new_expiry_is_rejected(self): ): self.client.rotate_team_api_key(name=old["name"], overlap=30, expires_in=7) - assert self.listed(old["public_id"])["status"] == "ACTIVE" + assert self.listed(old)["status"] == "ACTIVE" def test_an_overlap_outside_the_offered_windows_is_rejected(self): old = self.generate() @@ -230,7 +267,7 @@ def test_an_overlap_outside_the_offered_windows_is_rejected(self): with pytest.raises(AppException): self.client.rotate_team_api_key(name=old["name"], overlap=invalid) - assert self.listed(old["public_id"])["status"] == "ACTIVE" + assert self.listed(old)["status"] == "ACTIVE" def test_an_unknown_key_cannot_be_rotated(self): with self.assertRaisesRegex(AppException, "API key not found"): @@ -243,17 +280,24 @@ def test_revoking_marks_the_key_revoked(self): assert self.client.revoke_team_api_key(public_id=generated["public_id"]) is None - revoked = self.listed(generated["public_id"]) + revoked = self.listed(generated) assert revoked["status"] == "REVOKED" assert revoked["revokedAt"] - def test_a_rotating_key_can_be_revoked(self): + 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() - self.client.rotate_team_api_key(name=old["name"], overlap=7) + 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["public_id"])["status"] == "REVOKED" + assert self.listed(old)["status"] == "REVOKED" def test_a_revoked_key_cannot_be_revoked_again(self): generated = self.generate() From fddb9f88b7ebef307abeaa6240e225bb4481e76b Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 29 Sep 2026 11:57:58 +0400 Subject: [PATCH 5/9] changes in docs --- .../lib/app/interface/sdk_interface.py | 33 +++++-------------- .../integration/client/test_team_api_keys.py | 2 +- 2 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 8e97ab448..56a590d2c 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1415,8 +1415,7 @@ def generate_team_api_key( expires_in: ExpiresIn = 365, ) -> dict: """ - Generates a new team API key bound to the given team; the key is active - immediately. Must use Organization API Key and team ID for this function. + 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 @@ -1434,11 +1433,6 @@ def generate_team_api_key( is returned only here and cannot be retrieved later. :rtype: dict - :raises AppException: If the name is empty, or if ``expires_in`` is neither a - number of days, a duration, nor a date. Everything else the backend - decides - a duplicate name, an expiry it will not accept, or a key not - allowed to create keys - is reported with the backend's own message. - Request Example: :: @@ -1487,9 +1481,9 @@ def rotate_team_api_key( 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 and team ID for this function. + 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 @@ -1513,10 +1507,6 @@ def rotate_team_api_key( returned only here and cannot be retrieved later. :rtype: dict - :raises AppException: If the key does not exist, is already revoked or - expired, if the overlap outlasts the new key's expiration, or if - ``expires_in`` is neither a number of days, a duration, nor a date. - Request Example: :: @@ -1553,9 +1543,9 @@ def rotate_team_api_key( 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 and team ID, Team API Key or Personal API Key for this - function. + 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. @@ -1593,9 +1583,6 @@ def list_team_api_keys(self, **filters) -> list[dict]: if the team has no keys or none of them match. :rtype: list of dicts - :raises AppException: If a filter param is not supported, or if ``status`` - contains a value outside the four accepted statuses. - Request Example: :: @@ -1654,17 +1641,13 @@ def list_team_api_keys(self, **filters) -> list[dict]: 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 and team ID, Team API Key or Personal API Key for - this function. + 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 - :raises AppException: If the key does not exist or does not belong to the - caller, or if it is already revoked or expired. - Request Example: :: diff --git a/tests/integration/client/test_team_api_keys.py b/tests/integration/client/test_team_api_keys.py index 94879ee7f..3155d4963 100644 --- a/tests/integration/client/test_team_api_keys.py +++ b/tests/integration/client/test_team_api_keys.py @@ -16,7 +16,6 @@ import pytest from src.superannotate import AppException -from src.superannotate.lib.core.entities.context import TokenScope from tests import env #: Every key a run mints carries this prefix, so one left behind by a run that died @@ -136,6 +135,7 @@ def test_a_duplicate_name_is_refused_by_the_backend(self): with pytest.raises(AppException): self.generate(name=taken) + class TestListTeamAPIKeys(TeamAPIKeyTestCase): def test_a_generated_key_is_listed_first(self): generated = self.generate() From fa03d83e77cebda71c6685fc56928e96def477c8 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 1 Oct 2026 09:59:00 +0400 Subject: [PATCH 6/9] update tests --- .../client/test_project_admin_token.py | 27 ++++++++++++------- .../integration/client/test_team_api_keys.py | 5 ---- 2 files changed, 17 insertions(+), 15 deletions(-) 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 index 3155d4963..2052059ad 100644 --- a/tests/integration/client/test_team_api_keys.py +++ b/tests/integration/client/test_team_api_keys.py @@ -168,11 +168,6 @@ def test_filtering_by_name_and_status(self): assert revoked == [] assert other_than_active == [] - @pytest.mark.xfail( - strict=True, - reason="backend: a status $in/$notin filter answers 500 Internal server " - "error, though $eq and $ne on the same column are fine", - ) def test_filtering_by_several_statuses_at_once(self): generated = self.generate() From 1e6e0e35f5cf6790daa9288d1fc319c18895b277 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 5 Oct 2026 16:59:26 +0400 Subject: [PATCH 7/9] explore interface update --- .../lib/app/interface/sdk_interface.py | 10 +++++----- src/superannotate/lib/core/__init__.py | 3 --- .../lib/infrastructure/controller.py | 19 ++++++++----------- .../services/work_management.py | 3 +++ .../integration/client/test_team_api_keys.py | 4 ++-- 5 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 58c7b0e53..2104d0155 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1487,7 +1487,7 @@ def generate_team_api_key( def rotate_team_api_key( self, name: str, - overlap: Literal["expire_immediately", 1, 7, 14, 30] = 7, + overlap: Literal[0, 1, 7, 14, 30] = 7, expires_in: ExpiresIn = 365, ) -> dict: """ @@ -1500,9 +1500,9 @@ def rotate_team_api_key( the Active one is rotated. :type name: str - :param overlap: How long the rotated key stays valid: "expire_immediately", - or 1, 7, 14, 30 days. Defaults to 7. - :type overlap: Union[int, str], optional + :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: @@ -1526,7 +1526,7 @@ def rotate_team_api_key( # To rotate immediately with no overlap: - sa_client.rotate_team_api_key(name="api_key_to_rotate", overlap="expire_immediately") + sa_client.rotate_team_api_key(name="api_key_to_rotate", overlap=0) Response Example: :: diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index cb42be0c0..bd6a1c606 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -200,9 +200,6 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): } -#: The overlap that expires the rotated key at once, instead of keeping a window. -TEAM_API_KEY_EXPIRE_IMMEDIATELY = "expire_immediately" - API_KEY_NOT_FOUND_ERROR = "API key not found" API_KEY_ALREADY_REVOKED_ERROR = "This key is already revoked or expired" diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index a777efb61..d9efdf8df 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -657,7 +657,7 @@ def generate_team_api_key( def rotate_team_api_key( self, name: str, - overlap: int | str, + overlap: int, expires_in: int | timedelta | datetime, ) -> TeamAPIKeyEntity: key = self._find_rotatable_key(name) @@ -722,20 +722,17 @@ def _resolve_expiration(cls, expires_in: int | timedelta | datetime) -> str: return cls._as_wire_date(expires_at) @classmethod - def _resolve_overlap_end(cls, overlap: int | str, expires_at: str) -> str: + 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 - "expire_immediately" 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. + 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. """ - days = ( - 0 if overlap == constants.TEAM_API_KEY_EXPIRE_IMMEDIATELY else int(overlap) - ) now = datetime.now(timezone.utc) - overlap_end = now + timedelta(days=days) + 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( diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index c0445d9d8..02f7f2f69 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -16,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 @@ -680,6 +681,8 @@ def create_team_api_key(self, name: str, expires_at: str) -> TeamAPIKeyEntity: }, data={"name": name, "expiresAt": expires_at}, ) + if response.status_code == 403: + raise SAAuthError("Unauthorized") response.raise_for_status() return TeamAPIKeyEntity(**response.res_data["data"]) diff --git a/tests/integration/client/test_team_api_keys.py b/tests/integration/client/test_team_api_keys.py index 2052059ad..00dcdf8c4 100644 --- a/tests/integration/client/test_team_api_keys.py +++ b/tests/integration/client/test_team_api_keys.py @@ -231,7 +231,7 @@ def test_a_key_is_rotated_by_public_id_too(self): def test_rotating_immediately_leaves_no_overlap(self): old = self.generate() - self.client.rotate_team_api_key(name=old["name"], overlap="expire_immediately") + 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 @@ -258,7 +258,7 @@ def test_an_overlap_longer_than_the_new_expiry_is_rejected(self): def test_an_overlap_outside_the_offered_windows_is_rejected(self): old = self.generate() - for invalid in (3, 0, "immediately"): + for invalid in (2, 3, 60, "immediately"): with pytest.raises(AppException): self.client.rotate_team_api_key(name=old["name"], overlap=invalid) From 50da64aae1280c7d9cd82dc295abe3c10ef1370b Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 6 Oct 2026 15:09:27 +0400 Subject: [PATCH 8/9] Add controller new methods --- .../lib/core/serviceproviders.py | 12 +++++++++ .../services/work_management.py | 26 +++++++++++++++++++ tests/applicatoin/test_custom_workflow.py | 1 - 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 3c867799a..9caa93407 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -325,6 +325,18 @@ def list_team_api_keys( 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 diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 02f7f2f69..919859aa9 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -217,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, @@ -229,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, @@ -701,6 +723,8 @@ def rotate_team_api_key( # 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"]) @@ -731,5 +755,7 @@ def revoke_team_api_key(self, key_id: int) -> TeamAPIKeyEntity: ), }, ) + 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, From 98dcab0805637200c976197861bb0d66ff602d13 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 9 Oct 2026 12:13:39 +0400 Subject: [PATCH 9/9] Update changelog --- CHANGELOG.rst | 20 ++++++++++--------- pytest.ini | 2 +- tests/integration/client/test_token_scopes.py | 5 ++++- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2d5fb6b5a..5e4eb80f9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,27 +6,29 @@ History All release highlights of this project will be documented in this file. -4.6.2 - Sep _, 2026 +4.6.2 - Oct 12, 2026 ____________________ **Added** - - ``SAClient.generate_team_api_key()`` Generates a new team API key bound to the given team, active immediately. Requires an Organization API Key and a team ID. + - ``SAClient.generate_team_api_key()`` Generates a new API key for the specified team. - - ``SAClient.rotate_team_api_key()`` 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. Requires an Organization API Key and a team ID. + - ``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 the team API keys in the given team, newest first, with filtering by ``id``, ``name`` and ``status``. + - ``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.revoke_team_api_key()`` Revokes a Team API Key that is in the Active or Rotating state. - - ``SAClient.explore`` New namespace for querying and curating project data with the Explore query language (https://doc.superannotate.com/docs/queries-beta). + - ``SAClient.explore`` Added a new namespace for working with Explore-related SDK functions. - - ``SAClient.explore.query()`` Returns items that satisfy an Explore query. ``.count()`` returns the number of matching items without fetching them. + - ``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.get_subsets()`` Returns the subsets of a project. + - ``SAClient.explore.query().count()`` Returns the subsets available in a project. - - ``SAClient.explore.add_items_to_subset()`` Associates items with a subset, creating the subset if it does not exist. + - ``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** 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/tests/integration/client/test_token_scopes.py b/tests/integration/client/test_token_scopes.py index 234d767a0..e9f4feb52 100644 --- a/tests/integration/client/test_token_scopes.py +++ b/tests/integration/client/test_token_scopes.py @@ -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)