Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Credentials the test suite runs with - copy to .env and fill in (see tests/env.py).
# pytest tests/integration
# Tests that only apply to one kind of token are skipped when another kind is set here.
SA_TOKEN=<API key>
SA_URL=https://api.devsuperannotate.com
# Required for an organization API key, which carries no team of its own. Any other key
# resolves its own team, and a value given here has to match it.
# SA_TEAM_ID=<team id>

# The project-admin contributor suite (tests/integration/client) needs two more keys:
# a team contributor's personal key, and the team owner's to set the projects up with.
# It is skipped while SA_PROJECT_ADMIN_TOKEN is unset.
# SA_OWNER_PERSONAL_TOKEN=<team owner's personal API key>
# SA_CONTRIBUTOR_TOKEN=
14 changes: 12 additions & 2 deletions docs/source/userguide/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ on the team setup page, for more details please visit our documentation at https

- **Team API key** — scoped to one team. Works with ``SAClient``.
- **Personal (team-user) API key** — scoped to one team, tied to your user. Works with ``SAClient``.
- **Organization API key** — not scoped to a team. Not supported by the SDK;
``SAClient`` will reject it.
- **Organization API key** — not scoped to a team, so the team to operate in has to be
given along with it: ``SAClient(token="<API key>", team_id=<team id>)``, or
``SA_TEAM_ID`` in the environment or the config file.


SAClient can be used with or without arguments
Expand Down Expand Up @@ -76,6 +77,13 @@ ______________________________________________

sa_client = SAClient(token="<API key>")

An Organization API key carries no team, so it is passed together with the team to
operate in:

.. code-block:: python

sa_client = SAClient(token="<Organization API key>", team_id=<team id>)


*Method 2:* Create a custom config file:

Expand All @@ -93,6 +101,8 @@ Custom config.ini example:

[DEFAULT]
SA_TOKEN = <API key>
; Only an Organization API key needs it; other keys carry their own team.
SA_TEAM_ID = <team id>
LOGGING_LEVEL = INFO
LOGGING_PATH = /Users/username/data/superannotate_logs

Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ minversion = 3.7
log_cli=true
python_files = test_*.py
;pytest_plugins = ['pytest_profiling']
addopts = -n 6 --dist loadscope
;addopts = -n 12 --dist loadscope
13 changes: 12 additions & 1 deletion src/superannotate/lib/app/interface/base_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ class BaseInterfaceFacade:
REGISTRY = []

@validate_arguments
def __init__(self, token: TokenStr | None = None, config_path: str | None = None):
def __init__(
self,
token: TokenStr | None = None,
config_path: str | None = None,
team_id: int | None = None,
):
try:
if token:
config = ConfigEntity(SA_TOKEN=token)
Expand Down Expand Up @@ -65,6 +70,9 @@ def __init__(self, token: TokenStr | None = None, config_path: str | None = None
raise AppException(wrap_error(e))
if not config:
raise AppException("Credentials not provided.")
# An explicit team_id wins over whatever the config source provided.
if team_id is not None:
config.TEAM_ID = team_id
setup_logging(config.LOGGING_LEVEL, config.LOGGING_PATH)
self.controller = Controller(config)
BaseInterfaceFacade.REGISTRY.append(self)
Expand All @@ -80,6 +88,9 @@ def _retrieve_configs_from_json(path: Path) -> ConfigEntity:
raise AppException("Invalid token.")
host = json_data.get("main_endpoint")
verify_ssl = json_data.get("ssl_verify")
team_id = json_data.get("team_id")
if team_id is not None:
config.TEAM_ID = int(team_id)
if host:
config.API_URL = host
if verify_ssl:
Expand Down
14 changes: 12 additions & 2 deletions src/superannotate/lib/app/interface/sdk_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,20 @@ class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta):
:param config_path: path to config file
:type config_path: path-like (str or Path)

:param team_id: the team to operate in. Required for an Organization API key, which
is not bound to a team; for any other key it is optional and, when given, must
match the team the key grants access to.
:type team_id: int

"""

def __init__(self, token: str | None = None, config_path: str | None = None):
super().__init__(token, config_path)
def __init__(
self,
token: str | None = None,
config_path: str | None = None,
team_id: int | None = None,
):
super().__init__(token, config_path, team_id=team_id)

def get_project_by_id(self, project_id: int):
"""Returns the project metadata
Expand Down
5 changes: 4 additions & 1 deletion src/superannotate/lib/core/entities/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,12 @@ def _validate_token(value: str) -> str:


class ConfigEntity(BaseModel):
model_config = ConfigDict(extra="ignore")
model_config = ConfigDict(extra="ignore", populate_by_name=True)

API_TOKEN: TokenStr = Field(alias="SA_TOKEN")
#: The team to operate in. Only an organization API key needs it — its scope carries
#: no team; every other token resolves its own team.
TEAM_ID: int | None = Field(alias="SA_TEAM_ID", default=None)
API_URL: str = Field(alias="SA_URL", default=BACKEND_URL)
LOGGING_LEVEL: Literal[
"NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
Expand Down
2 changes: 1 addition & 1 deletion src/superannotate/lib/core/entities/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def __eq__(self, other):


class UserEntity(BaseModel):
model_config = ConfigDict(extra="ignore")
model_config = ConfigDict(extra="allow")

id: str | None = None
first_name: str | None = None
Expand Down
2 changes: 1 addition & 1 deletion src/superannotate/lib/core/entities/work_managament.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class WMUserEntity(TimedBaseModel):

id: int | None = None
team_id: int | None = None
role: WMUserTypeEnum
role: WMUserTypeEnum | None = None
email: str | None = None
state: WMUserStateEnum | None = None
custom_fields: dict | None = Field(default_factory=dict, alias="customField")
Expand Down
5 changes: 5 additions & 0 deletions src/superannotate/lib/core/serviceproviders.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def paginate(
chunk_size: int = 2000,
query_params: dict[str, Any] | None = None,
headers: dict | None = None,
method: Literal["get", "post"] = "get",
) -> ServiceResponse:
raise NotImplementedError

Expand Down Expand Up @@ -233,6 +234,10 @@ def update_user_activity(
def list_scores(self) -> WMScoreListResponse:
raise NotImplementedError

@abstractmethod
def list_project_scores(self, project_id: int) -> WMScoreListResponse:
raise NotImplementedError

@abstractmethod
def create_score(
self,
Expand Down
125 changes: 72 additions & 53 deletions src/superannotate/lib/core/usecases/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
ANNOTATION_CHUNK_SIZE_MB = 10 * 1024 * 1024
URI_THRESHOLD = 4 * 1024 - 120

STATUS_CHANGE_ERROR_MSG = "Failed to change status."


@dataclass
class Report:
Expand Down Expand Up @@ -418,20 +420,25 @@ def execute(self):
{i.item.name for i in items_to_upload}
- set(self._report.failed_annotations).union(set(skipped))
)
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system():
if uploaded_annotations and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=self._folder,
item_names=uploaded_annotations,
)
if not statuses_changed:
self._response.errors = AppException("Failed to change status.")

try:
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system():
if uploaded_annotations and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=self._folder,
item_names=uploaded_annotations,
)
if not statuses_changed:
self._response.errors = AppException(
STATUS_CHANGE_ERROR_MSG
)
except AppException as e:
if e.message != "Forbidden":
raise e
self._response.data = {
"succeeded": uploaded_annotations,
"failed": failed,
Expand Down Expand Up @@ -741,19 +748,22 @@ def execute(self):
name_path_mappings.keys()
- set(self._report.failed_annotations).union(set(missing_annotations))
)
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system() and uploaded_annotations and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=self._folder,
item_names=uploaded_annotations,
try:
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if not statuses_changed:
self._response.errors = AppException("Failed to change status.")

if workflow.is_system() and uploaded_annotations and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=self._folder,
item_names=uploaded_annotations,
)
if not statuses_changed:
self._response.errors = AppException(STATUS_CHANGE_ERROR_MSG)
except AppException as e:
if e.message != "Forbidden":
raise e
if missing_annotations:
logger.warning(
f"Couldn't find {len(missing_annotations)}/{len(name_path_mappings.keys())} "
Expand Down Expand Up @@ -950,20 +960,25 @@ def execute(self):
self.reporter.log_warning(
f"Couldn't find attribute {attr}."
)
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system() and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=self._folder,
item_names=[self._image.name],
try:
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if not statuses_changed:
self._response.errors = AppException(
"Failed to change status."
if workflow.is_system() and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=self._folder,
item_names=[self._image.name],
)
if not statuses_changed:
self._response.errors = AppException(
STATUS_CHANGE_ERROR_MSG
)
except AppException as e:
if e.message != "Forbidden":
raise e

if self._verbose:
self.reporter.log_info(
f"Uploading annotations for image {str(self._image.name)} in project {self._project.name}."
Expand Down Expand Up @@ -1719,7 +1734,7 @@ def execute(self):
)
)
except Exception as e:
logger.error(e)
logger.exception(e)
self._response.errors = AppException("Can't get annotations.")
return self._response
self.reporter.stop_spinner()
Expand Down Expand Up @@ -2021,22 +2036,26 @@ def execute(self):
folder_id=folder.id,
item_id_category_map=item_id_category_map,
)
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
uploaded.extend(uploaded_annotations)
if workflow.is_system():
if uploaded_annotations and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=folder,
item_names=uploaded_annotations,
)
if not statuses_changed:
self._response.errors = AppException(
"Failed to change status."
try:
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system():
if uploaded_annotations and not self._keep_status:
statuses_changed = set_annotation_statuses_in_progress(
service_provider=self._service_provider,
project=self._project,
folder=folder,
item_names=uploaded_annotations,
)
if not statuses_changed:
self._response.errors = AppException(
STATUS_CHANGE_ERROR_MSG
)
except AppException as e:
if e.message != "Forbidden":
raise e

self.reporter.finish_progress()
self._report.failed_annotations = []
Expand Down
20 changes: 12 additions & 8 deletions src/superannotate/lib/core/usecases/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,15 +712,19 @@ def __init__(
self._s3_repo = s3_repo
self._service_provider = service_provider
if annotation_status_value is None:
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system():
annotation_status_value = (
self._service_provider.get_annotation_status_value(
self._project, "NotStarted"
)
try:
workflow = self._service_provider.work_management.get_workflow(
self._project.workflow_id
)
if workflow.is_system():
annotation_status_value = (
self._service_provider.get_annotation_status_value(
self._project, "NotStarted"
)
)
except AppException as e:
if e.message != "Forbidden":
raise e
self._annotation_status_value = annotation_status_value
self._auth_data = None

Expand Down
Loading